From bf6221f47fe22c5e77804dde38034e78a1f5017f Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 7 Oct 2026 16:20:07 +0200 Subject: [PATCH 01/10] tokens --- .changeset/explorer-design-system.md | 11 + .prettierignore | 7 +- apps/explorer/SKILL.md | 39 + apps/explorer/design/README.md | 87 ++ apps/explorer/design/assets/Logos/README.md | 5 + apps/explorer/design/assets/Logos/favicon.png | Bin 0 -> 2584 bytes .../design/assets/Logos/walkerOS-icon.png | Bin 0 -> 15971 bytes .../design/assets/Logos/walkerOS-logo.svg | 29 + .../design/assets/Logos/walkerOS-mark.svg | 5 + .../components/AnnouncementBar/README.md | 8 + .../components/AnnouncementBar/preview.html | 6 + .../ArticleTeaserTracking/README.md | 7 + .../ArticleTeaserTracking/preview.html | 6 + .../design/components/Button/README.md | 7 + .../design/components/Button/preview.html | 6 + .../design/components/CheckList/README.md | 7 + .../design/components/CheckList/preview.html | 6 + .../design/components/Cover/preview.html | 28 + .../DestinationMappingViz/README.md | 7 + .../DestinationMappingViz/preview.html | 6 + .../design/components/EventLegend/README.md | 7 + .../components/EventLegend/preview.html | 6 + .../design/components/FaqItem/README.md | 7 + .../design/components/FaqItem/preview.html | 6 + .../design/components/FeatureItem/README.md | 5 + .../components/FeatureItem/preview.html | 6 + .../components/HeroTaggingViz/README.md | 7 + .../components/HeroTaggingViz/preview.html | 6 + .../design/components/InlineCode/README.md | 5 + .../design/components/InlineCode/preview.html | 6 + .../components/InstallCommand/README.md | 5 + .../components/InstallCommand/preview.html | 6 + .../design/components/PlanCard/README.md | 7 + .../design/components/PlanCard/preview.html | 6 + .../design/components/ProblemCard/README.md | 5 + .../components/ProblemCard/preview.html | 6 + .../components/SectionHeading/README.md | 7 + .../components/SectionHeading/preview.html | 6 + .../design/components/SiteFooter/README.md | 5 + .../design/components/SiteFooter/preview.html | 6 + .../design/components/SiteHeader/README.md | 7 + .../design/components/SiteHeader/preview.html | 7 + apps/explorer/design/components/bundle.css | 108 ++ apps/explorer/design/components/bundle.js | 154 +++ apps/explorer/design/components/index.d.ts | 65 ++ apps/explorer/design/components/src/viz.jsx | 457 ++++++++ apps/explorer/design/tokens.json | 984 ++++++++++++++++++ apps/explorer/package.json | 13 +- .../__snapshots__/generate.test.ts.snap | 109 ++ .../src/design/__tests__/check.test.ts | 406 ++++++++ .../src/design/__tests__/color-math.ts | 106 ++ .../src/design/__tests__/contrast.test.ts | 260 +++++ .../src/design/__tests__/entry.test.ts | 119 +++ apps/explorer/src/design/__tests__/fixture.ts | 157 +++ .../src/design/__tests__/generate.test.ts | 48 + .../src/design/__tests__/tokens.test.ts | 248 +++++ apps/explorer/src/design/base.css | 55 + apps/explorer/src/design/check/bin.ts | 25 + apps/explorer/src/design/check/cli.ts | 180 ++++ apps/explorer/src/design/check/glob.ts | 34 + apps/explorer/src/design/check/rules.ts | 240 +++++ apps/explorer/src/design/generate.ts | 178 ++++ apps/explorer/src/design/index.ts | 267 +++++ apps/explorer/src/design/names.ts | 30 + .../src/design/tailwind-namespaces.ts | 99 ++ apps/explorer/src/design/tokens.ts | 648 ++++++++++++ apps/explorer/tsup.config.ts | 34 + package-lock.json | 3 + 68 files changed, 5436 insertions(+), 2 deletions(-) create mode 100644 .changeset/explorer-design-system.md create mode 100644 apps/explorer/design/README.md create mode 100644 apps/explorer/design/assets/Logos/README.md create mode 100644 apps/explorer/design/assets/Logos/favicon.png create mode 100644 apps/explorer/design/assets/Logos/walkerOS-icon.png create mode 100644 apps/explorer/design/assets/Logos/walkerOS-logo.svg create mode 100644 apps/explorer/design/assets/Logos/walkerOS-mark.svg create mode 100644 apps/explorer/design/components/AnnouncementBar/README.md create mode 100644 apps/explorer/design/components/AnnouncementBar/preview.html create mode 100644 apps/explorer/design/components/ArticleTeaserTracking/README.md create mode 100644 apps/explorer/design/components/ArticleTeaserTracking/preview.html create mode 100644 apps/explorer/design/components/Button/README.md create mode 100644 apps/explorer/design/components/Button/preview.html create mode 100644 apps/explorer/design/components/CheckList/README.md create mode 100644 apps/explorer/design/components/CheckList/preview.html create mode 100644 apps/explorer/design/components/Cover/preview.html create mode 100644 apps/explorer/design/components/DestinationMappingViz/README.md create mode 100644 apps/explorer/design/components/DestinationMappingViz/preview.html create mode 100644 apps/explorer/design/components/EventLegend/README.md create mode 100644 apps/explorer/design/components/EventLegend/preview.html create mode 100644 apps/explorer/design/components/FaqItem/README.md create mode 100644 apps/explorer/design/components/FaqItem/preview.html create mode 100644 apps/explorer/design/components/FeatureItem/README.md create mode 100644 apps/explorer/design/components/FeatureItem/preview.html create mode 100644 apps/explorer/design/components/HeroTaggingViz/README.md create mode 100644 apps/explorer/design/components/HeroTaggingViz/preview.html create mode 100644 apps/explorer/design/components/InlineCode/README.md create mode 100644 apps/explorer/design/components/InlineCode/preview.html create mode 100644 apps/explorer/design/components/InstallCommand/README.md create mode 100644 apps/explorer/design/components/InstallCommand/preview.html create mode 100644 apps/explorer/design/components/PlanCard/README.md create mode 100644 apps/explorer/design/components/PlanCard/preview.html create mode 100644 apps/explorer/design/components/ProblemCard/README.md create mode 100644 apps/explorer/design/components/ProblemCard/preview.html create mode 100644 apps/explorer/design/components/SectionHeading/README.md create mode 100644 apps/explorer/design/components/SectionHeading/preview.html create mode 100644 apps/explorer/design/components/SiteFooter/README.md create mode 100644 apps/explorer/design/components/SiteFooter/preview.html create mode 100644 apps/explorer/design/components/SiteHeader/README.md create mode 100644 apps/explorer/design/components/SiteHeader/preview.html create mode 100644 apps/explorer/design/components/bundle.css create mode 100644 apps/explorer/design/components/bundle.js create mode 100644 apps/explorer/design/components/index.d.ts create mode 100644 apps/explorer/design/components/src/viz.jsx create mode 100644 apps/explorer/design/tokens.json create mode 100644 apps/explorer/src/design/__tests__/__snapshots__/generate.test.ts.snap create mode 100644 apps/explorer/src/design/__tests__/check.test.ts create mode 100644 apps/explorer/src/design/__tests__/color-math.ts create mode 100644 apps/explorer/src/design/__tests__/contrast.test.ts create mode 100644 apps/explorer/src/design/__tests__/entry.test.ts create mode 100644 apps/explorer/src/design/__tests__/fixture.ts create mode 100644 apps/explorer/src/design/__tests__/generate.test.ts create mode 100644 apps/explorer/src/design/__tests__/tokens.test.ts create mode 100644 apps/explorer/src/design/base.css create mode 100644 apps/explorer/src/design/check/bin.ts create mode 100644 apps/explorer/src/design/check/cli.ts create mode 100644 apps/explorer/src/design/check/glob.ts create mode 100644 apps/explorer/src/design/check/rules.ts create mode 100644 apps/explorer/src/design/generate.ts create mode 100644 apps/explorer/src/design/index.ts create mode 100644 apps/explorer/src/design/names.ts create mode 100644 apps/explorer/src/design/tailwind-namespaces.ts create mode 100644 apps/explorer/src/design/tokens.ts diff --git a/.changeset/explorer-design-system.md b/.changeset/explorer-design-system.md new file mode 100644 index 000000000..c690c1a7b --- /dev/null +++ b/.changeset/explorer-design-system.md @@ -0,0 +1,11 @@ +--- +'@walkeros/explorer': minor +--- + +Explorer now ships the walkerOS design system. Import +`@walkeros/explorer/design/tokens.css` for colour, type, spacing, radius and +layer tokens (dark by default, light under `data-theme="light"`), the opt-in +`design/base.css`, the Tailwind v4 bridge `design/tailwind.css`, and typed +colour and font constants from `@walkeros/explorer/design`. A +`walkeros-design-check` command lints consuming code. Existing exports are +unchanged. diff --git a/.prettierignore b/.prettierignore index 22be2a42c..32cbb2bb8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -32,4 +32,9 @@ logs/ # MDX docs are not formatted by any tool: both prettier and remark rewrite the # `code={`...`}` template literals that carry the documentation snippets. -website/docs/**/*.mdx \ No newline at end of file +website/docs/**/*.mdx + +# walkerOS design system: design/ mirrors the Claude Design artifact file for +# file, and src/design/index.ts is generated from design/tokens.json. +apps/explorer/design/ +apps/explorer/src/design/index.ts diff --git a/apps/explorer/SKILL.md b/apps/explorer/SKILL.md index 59eedc466..2df6e8551 100644 --- a/apps/explorer/SKILL.md +++ b/apps/explorer/SKILL.md @@ -10,6 +10,45 @@ Entry point for working with the walkerOS explorer component library. | [STYLE.md](STYLE.md) | Complete CSS variable reference (colors, spacing, typography) | | [README.md](README.md) | Usage guidelines, component patterns | +## Design area + +`design/` holds the walkerOS design system in the Claude Design artifact's file +layout. `design/tokens.json` is the only source of colour, type, spacing, radius +and layer values. + +- Change a token in `design/tokens.json` only. The explorer build parses it + (`src/design/tokens.ts`; a malformed token fails the build with its JSON + path), regenerates `src/design/index.ts` and writes `dist/design/tokens.css`, + `tailwind.css` and `base.css`. Never edit generated files. +- If the drift test in `src/design/__tests__/generate.test.ts` reports a stale + `src/design/index.ts`, run `npm run build` in `apps/explorer`. A turbo cache + hit restores `dist/` without running the generator, so a turbo build does not + refresh it. +- Every new or changed colour passes `src/design/__tests__/contrast.test.ts` in + both themes. The test fails for a colour that is in no contrast row and not on + its `UNPAIRED` list, which names the reason for each colour left out. +- Exports: `@walkeros/explorer/design/tokens.css`, + `@walkeros/explorer/design/base.css`, + `@walkeros/explorer/design/tailwind.css`, and colour and font constants from + `@walkeros/explorer/design` for engines that cannot read CSS variables. +- A Tailwind `text- + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/explorer/design/assets/Logos/walkerOS-mark.svg b/apps/explorer/design/assets/Logos/walkerOS-mark.svg new file mode 100644 index 000000000..e9a5456ed --- /dev/null +++ b/apps/explorer/design/assets/Logos/walkerOS-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/explorer/design/components/AnnouncementBar/README.md b/apps/explorer/design/components/AnnouncementBar/README.md new file mode 100644 index 000000000..c84838cb5 --- /dev/null +++ b/apps/explorer/design/components/AnnouncementBar/README.md @@ -0,0 +1,8 @@ +Full-width `primary` strip above the header announcing the latest release. + +Use one per page, at the very top, for release news only. Text is `small` 14px/600 in `on-primary`; the link inherits the colour and stays underlined. + +**Consumer provides:** `children` (the message), optional `linkLabel` + `href`. + +- Do: "What's new: Just shipped v4.6 · Read the release notes". +- Don't: stack promos, add icons, or put white text on it. diff --git a/apps/explorer/design/components/AnnouncementBar/preview.html b/apps/explorer/design/components/AnnouncementBar/preview.html new file mode 100644 index 000000000..648783dda --- /dev/null +++ b/apps/explorer/design/components/AnnouncementBar/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/ArticleTeaserTracking/README.md b/apps/explorer/design/components/ArticleTeaserTracking/README.md new file mode 100644 index 000000000..ca2ce02e5 --- /dev/null +++ b/apps/explorer/design/components/ArticleTeaserTracking/README.md @@ -0,0 +1,7 @@ +Tagging feature demo: one `ArticleTeaser` tagged once, walked through atom → molecule → organisms → pages, showing how every instance inherits entity, action, properties, context and globals. + +Five steps (Atom, Molecule, HomeFeed, RelatedStories, Pages) reveal the code on the left while the rendered home feed and related-stories cards pick up their outlines and pills on the right. Autoplay starts when the code pane scrolls 35% into view; clicking a step jumps to it. The legend and caption explain each step. + +**Consumer provides:** optional `autoplay` (default true), `speed` (default 1), `initialStep` (1–5, shows that step fully revealed). + +Stays dark in both themes. Wording of the captions follows the walkerOS nouns: entity, action, context, globals. diff --git a/apps/explorer/design/components/ArticleTeaserTracking/preview.html b/apps/explorer/design/components/ArticleTeaserTracking/preview.html new file mode 100644 index 000000000..729d5e348 --- /dev/null +++ b/apps/explorer/design/components/ArticleTeaserTracking/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/Button/README.md b/apps/explorer/design/components/Button/README.md new file mode 100644 index 000000000..a6b4f30db --- /dev/null +++ b/apps/explorer/design/components/Button/README.md @@ -0,0 +1,7 @@ +The walkerOS call-to-action: a `primary` fill with `on-primary` text, or a flat `surface` secondary. + +16px/600, 12×24 padding, `radius-md`. Primary brightens on hover; secondary turns its `border` to `primary`. Use at most one primary per group; it leads. Add `arrow` for forward-moving CTAs ("Set up with your agent →"). + +**Consumer provides:** `children`, `href` (renders a link) or `onClick`, `variant` ('primary' | 'secondary'), optional `arrow`, `block` (full width inside cards). + +- Don't: white text on `primary`, ghost/outline variants in brand blue, icons other than the arrow. diff --git a/apps/explorer/design/components/Button/preview.html b/apps/explorer/design/components/Button/preview.html new file mode 100644 index 000000000..72fe8a78e --- /dev/null +++ b/apps/explorer/design/components/Button/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/CheckList/README.md b/apps/explorer/design/components/CheckList/README.md new file mode 100644 index 000000000..b2a289f76 --- /dev/null +++ b/apps/explorer/design/components/CheckList/README.md @@ -0,0 +1,7 @@ +Inline or stacked list of short claims, each led by a `primary` check. + +`inline` wraps horizontally (hero proof points, 15px `fg-2`, centered under the CTAs); `stack` lists plan features with 14px checks. + +**Consumer provides:** `items` (strings), `layout` ('inline' | 'stack'), `align` ('center'). + +Keep each item under ~6 words; facts, not adjectives ("MIT-licensed", "Config-as-code"). diff --git a/apps/explorer/design/components/CheckList/preview.html b/apps/explorer/design/components/CheckList/preview.html new file mode 100644 index 000000000..5e6d19d15 --- /dev/null +++ b/apps/explorer/design/components/CheckList/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/Cover/preview.html b/apps/explorer/design/components/Cover/preview.html new file mode 100644 index 000000000..8aaf88313 --- /dev/null +++ b/apps/explorer/design/components/Cover/preview.html @@ -0,0 +1,28 @@ + +
+ +
walkerOS
Event data collection that ships with the component.
+
+ diff --git a/apps/explorer/design/components/DestinationMappingViz/README.md b/apps/explorer/design/components/DestinationMappingViz/README.md new file mode 100644 index 000000000..cc050f3ba --- /dev/null +++ b/apps/explorer/design/components/DestinationMappingViz/README.md @@ -0,0 +1,7 @@ +Mapping feature demo: pick one `elb()` event and see the mapping rule and the exact call it produces in GA4, Meta Pixel, TikTok Pixel and Amplitude. + +Three event chips (`product view`, `lead submit`, `order complete`); the selected chip takes a `primary` border. Each destination card shows its `mapping` rule and what it `sends`, with the mapped name highlighted in entity green. A closing caption states the payoff. + +**Consumer provides:** optional `initialEvent` (0–2), `caption` (string, or `''` to hide). + +Interactive, no autoplay. Stays dark in both themes. diff --git a/apps/explorer/design/components/DestinationMappingViz/preview.html b/apps/explorer/design/components/DestinationMappingViz/preview.html new file mode 100644 index 000000000..89ed75bc4 --- /dev/null +++ b/apps/explorer/design/components/DestinationMappingViz/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/EventLegend/README.md b/apps/explorer/design/components/EventLegend/README.md new file mode 100644 index 000000000..cd2b9eb3d --- /dev/null +++ b/apps/explorer/design/components/EventLegend/README.md @@ -0,0 +1,7 @@ +The fixed colour key for the five parts of a walkerOS event, as used in every product visualisation. + +Geist Mono 11px in `viz-fg-2` on `viz-bg`; each part pairs its `event-*` swatch with its name. Stays dark in both themes. + +**Consumer provides:** optional `parts` to show a subset ('entity' | 'action' | 'property' | 'context' | 'globals'). + +Never reuse these hues for status or decoration. diff --git a/apps/explorer/design/components/EventLegend/preview.html b/apps/explorer/design/components/EventLegend/preview.html new file mode 100644 index 000000000..09e5718bb --- /dev/null +++ b/apps/explorer/design/components/EventLegend/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/FaqItem/README.md b/apps/explorer/design/components/FaqItem/README.md new file mode 100644 index 000000000..7d8b5a3c3 --- /dev/null +++ b/apps/explorer/design/components/FaqItem/README.md @@ -0,0 +1,7 @@ +A native `
` disclosure for one FAQ question, stacked 12px apart (max 900px). + +`surface` + `border`, `radius-faq`. The question is 17px/600 with a `fg-3` plus that becomes a minus when open; the answer sits under a `border` rule in `fg-2`, paragraphs max 72ch. + +**Consumer provides:** `question`, `children` (a string or an array of paragraph strings/nodes), optional `open`. + +Answer the question that was actually asked, and include an honest caveat where one exists. diff --git a/apps/explorer/design/components/FaqItem/preview.html b/apps/explorer/design/components/FaqItem/preview.html new file mode 100644 index 000000000..f4c12e156 --- /dev/null +++ b/apps/explorer/design/components/FaqItem/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/FeatureItem/README.md b/apps/explorer/design/components/FeatureItem/README.md new file mode 100644 index 000000000..466ce7b38 --- /dev/null +++ b/apps/explorer/design/components/FeatureItem/README.md @@ -0,0 +1,5 @@ +A checked feature with title, one-sentence description and a `Docs →` link. + +Used in a two-column `auto-fit` grid (min 300px, gaps 44/48px) beside a `heading-xl` block. 20px `primary` check, `title-item` title, `fg-2` body, 14px link. + +**Consumer provides:** `title`, `children`, optional `href`, `linkLabel` (default 'Docs'). diff --git a/apps/explorer/design/components/FeatureItem/preview.html b/apps/explorer/design/components/FeatureItem/preview.html new file mode 100644 index 000000000..26761d14e --- /dev/null +++ b/apps/explorer/design/components/FeatureItem/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/HeroTaggingViz/README.md b/apps/explorer/design/components/HeroTaggingViz/README.md new file mode 100644 index 000000000..0bfc82f76 --- /dev/null +++ b/apps/explorer/design/components/HeroTaggingViz/README.md @@ -0,0 +1,7 @@ +Hero demo: `data-elb` attributes are typed into an article teaser, the card lights up as each part is tagged, a cursor clicks it, and the events land in a live `walkerOS.events` table. + +Sits under the hero CTAs, full container width. An 11.5s loop cycles three articles: entity (`data-elb`), impression action, category and title properties, click action, then the `article impression` and `article open` rows appear. Highlights use the event palette: entity green, action purple, property red. + +**Consumer provides:** optional `playing` (default true), `speed` (default 1), `startAt` (ms into the loop, for a static frame). + +Stays dark in both themes (`viz-*` tokens). Wraps below ~940px into a stacked layout. Respect reduced motion by passing `playing={false}` with a `startAt` around 9000 (all parts tagged, rows visible). diff --git a/apps/explorer/design/components/HeroTaggingViz/preview.html b/apps/explorer/design/components/HeroTaggingViz/preview.html new file mode 100644 index 000000000..8507d92b0 --- /dev/null +++ b/apps/explorer/design/components/HeroTaggingViz/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/InlineCode/README.md b/apps/explorer/design/components/InlineCode/README.md new file mode 100644 index 000000000..0ac7b91c0 --- /dev/null +++ b/apps/explorer/design/components/InlineCode/README.md @@ -0,0 +1,5 @@ +Mono code token inside prose: attribute names, function calls, event names. + +`code-inline` (0.88em mono) on `surface-2` with a `border` outline and `radius-sm`, text in `fg`. + +**Consumer provides:** `children` (the literal code). diff --git a/apps/explorer/design/components/InlineCode/preview.html b/apps/explorer/design/components/InlineCode/preview.html new file mode 100644 index 000000000..b82301b1d --- /dev/null +++ b/apps/explorer/design/components/InlineCode/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/InstallCommand/README.md b/apps/explorer/design/components/InstallCommand/README.md new file mode 100644 index 000000000..082a2764f --- /dev/null +++ b/apps/explorer/design/components/InstallCommand/README.md @@ -0,0 +1,5 @@ +A copy-to-clipboard chip for the CLI install command, set in `mono`. + +Sits beside the primary CTA in the hero. The `$` prompt is `fg-3`; the copy icon turns into a `primary` check for 1.4s after copying. + +**Consumer provides:** optional `command` (default `npm i -g @walkeros/cli`). Always a real, runnable command. diff --git a/apps/explorer/design/components/InstallCommand/preview.html b/apps/explorer/design/components/InstallCommand/preview.html new file mode 100644 index 000000000..508e7fde3 --- /dev/null +++ b/apps/explorer/design/components/InstallCommand/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/PlanCard/README.md b/apps/explorer/design/components/PlanCard/README.md new file mode 100644 index 000000000..31e7cc3a2 --- /dev/null +++ b/apps/explorer/design/components/PlanCard/README.md @@ -0,0 +1,7 @@ +A service tier: label, name, description, checked features and a full-width CTA. + +Three in a row (Community · Implementation · Support). Exactly one is `highlight`: `primary` border, `plan-highlight` ring, `link` label and a primary CTA; the others use secondary CTAs. + +**Consumer provides:** `label`, `title`, `children`, `features` (strings), `cta` ({label, href}), optional `highlight`. + +Never show seat prices; the copy stresses it is the same MIT code on every tier. diff --git a/apps/explorer/design/components/PlanCard/preview.html b/apps/explorer/design/components/PlanCard/preview.html new file mode 100644 index 000000000..2ba9fe2f2 --- /dev/null +++ b/apps/explorer/design/components/PlanCard/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/ProblemCard/README.md b/apps/explorer/design/components/ProblemCard/README.md new file mode 100644 index 000000000..d0f8217cd --- /dev/null +++ b/apps/explorer/design/components/ProblemCard/README.md @@ -0,0 +1,5 @@ +Numbered card naming one problem, in a three-up `auto-fit` grid (min 280px, gap `space-5`). + +`surface` + `border`, `radius-lg`, `card-pad-sm`. The number is `code-step` mono in `link`; title `title-card`; text `fg-2`. Hover tints the border toward `primary`. + +**Consumer provides:** `number` ('01'…), `title`, `children` (one or two sentences). diff --git a/apps/explorer/design/components/ProblemCard/preview.html b/apps/explorer/design/components/ProblemCard/preview.html new file mode 100644 index 000000000..0734b10e8 --- /dev/null +++ b/apps/explorer/design/components/ProblemCard/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/SectionHeading/README.md b/apps/explorer/design/components/SectionHeading/README.md new file mode 100644 index 000000000..c155d170f --- /dev/null +++ b/apps/explorer/design/components/SectionHeading/README.md @@ -0,0 +1,7 @@ +Eyebrow, heading and optional lead — the opening of every section, or the hero when `level` is 1. + +Eyebrow is `eyebrow` in `link`; H2 is `heading-lg`; H1 is `display` with an optional `highlight` phrase in `primary`. Max width 760px (hero centered, 20ch heading). + +**Consumer provides:** `title`, optional `eyebrow`, `lead`, `level` (1 | 2), `highlight` (H1 only), `align`. + +The eyebrow names the problem; the heading states the outcome in two short beats. diff --git a/apps/explorer/design/components/SectionHeading/preview.html b/apps/explorer/design/components/SectionHeading/preview.html new file mode 100644 index 000000000..3b439bb9e --- /dev/null +++ b/apps/explorer/design/components/SectionHeading/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/SiteFooter/README.md b/apps/explorer/design/components/SiteFooter/README.md new file mode 100644 index 000000000..2bba39fb0 --- /dev/null +++ b/apps/explorer/design/components/SiteFooter/README.md @@ -0,0 +1,5 @@ +Footer on `bg-2` with the brand statement, four link columns and the copyright line. + +Statement in `footer-statement`; column heads 15px/700; links `fg-2` turning `link` on hover; copyright `fg-3` 14px centered. + +**Consumer provides:** optional `statement`, `columns` ({title, links: [label, href][]}[]), `copyright` — defaults are the relaunch's. diff --git a/apps/explorer/design/components/SiteFooter/preview.html b/apps/explorer/design/components/SiteFooter/preview.html new file mode 100644 index 000000000..f0be70616 --- /dev/null +++ b/apps/explorer/design/components/SiteFooter/preview.html @@ -0,0 +1,6 @@ + +
+ diff --git a/apps/explorer/design/components/SiteHeader/README.md b/apps/explorer/design/components/SiteHeader/README.md new file mode 100644 index 000000000..bed36be99 --- /dev/null +++ b/apps/explorer/design/components/SiteHeader/README.md @@ -0,0 +1,7 @@ +Sticky 60px site header with the walkerOS wordmark, primary nav, GitHub link and theme toggle. + +Sits under the AnnouncementBar. Background is `bg` at 88% with an 8px backdrop blur and a bottom `border`. Nav links are `ui` 15px/500 in `fg`, turning `link` on hover. The toggle shows a sun in dark and a moon in light. + +**Consumer provides:** `links` ({label, href}[]), `theme` ('dark' | 'light'), `onToggleTheme`, optional `githubHref`, `homeHref`. + +Keep the nav to three items (Documentation, Playground, Services); GitHub always sits right. diff --git a/apps/explorer/design/components/SiteHeader/preview.html b/apps/explorer/design/components/SiteHeader/preview.html new file mode 100644 index 000000000..30ddc3da7 --- /dev/null +++ b/apps/explorer/design/components/SiteHeader/preview.html @@ -0,0 +1,7 @@ + +
+ diff --git a/apps/explorer/design/components/bundle.css b/apps/explorer/design/components/bundle.css new file mode 100644 index 000000000..cc736da9f --- /dev/null +++ b/apps/explorer/design/components/bundle.css @@ -0,0 +1,108 @@ +/* walkerOS components — every value from tokens.css */ +.wos-root, .wos-root * { box-sizing: border-box; } +.wos-root { font-family: var(--font-sans); color: var(--fg); -webkit-font-smoothing: antialiased; } +.wos-root a { color: var(--link); text-decoration: none; } +.wos-root a:hover { text-decoration: underline; } +.wos-root :focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-xs); } + +/* Button */ +.wos-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; font-family: var(--font-sans); font-weight: 600; font-size: 16px; line-height: 1.5; padding: 12px 24px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: border-color .18s, filter .18s; } +.wos-root a.wos-btn:hover { text-decoration: none; } +.wos-btn--primary, .wos-root a.wos-btn--primary { background: var(--primary); color: var(--on-primary); } +.wos-btn--primary:hover { filter: brightness(1.08); color: var(--on-primary); } +.wos-btn--secondary, .wos-root a.wos-btn--secondary { background: var(--surface); color: var(--fg); border-color: var(--border); } +.wos-btn--secondary:hover { border-color: var(--primary); color: var(--fg); } +.wos-btn--block { width: 100%; } + +/* InstallCommand */ +.wos-cmd { display: inline-flex; align-items: center; gap: 12px; font: 500 15px/1.5 var(--font-mono); padding: 12px 18px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface); color: var(--fg); cursor: pointer; transition: border-color .18s; } +.wos-cmd:hover { border-color: var(--primary); } +.wos-cmd__prompt { color: var(--fg-3); } +.wos-cmd__icon { display: inline-flex; color: var(--fg-3); } +.wos-cmd__icon--done { color: var(--primary); } + +/* Check icon + CheckList */ +.wos-check { flex: none; color: var(--primary); } +.wos-checklist { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 10px 28px; color: var(--fg-2); font-size: 15px; } +.wos-checklist--center { justify-content: center; } +.wos-checklist--stack { flex-direction: column; flex-wrap: nowrap; gap: 9px; } +.wos-checklist li { display: flex; align-items: center; gap: 8px; } +.wos-checklist--stack li { align-items: flex-start; gap: 10px; } +.wos-checklist--stack .wos-check { margin-top: 4px; } + +/* AnnouncementBar */ +.wos-announce { background: var(--primary); color: var(--on-primary); text-align: center; font-size: 14px; font-weight: 600; line-height: 1.5; padding: 7px 16px; } +.wos-root .wos-announce a { color: inherit; text-decoration: underline; } + +/* SiteHeader */ +.wos-header { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); } +.wos-header__in { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter); display: flex; align-items: center; gap: var(--gutter); height: var(--header-h); } +.wos-header__logo { flex: none; display: block; height: 22px; } +.wos-header__logo img { height: 22px; width: auto; display: block; } +.wos-header__nav { display: flex; gap: 22px; margin-right: auto; min-width: 0; overflow-x: auto; white-space: nowrap; } +.wos-header__end { display: flex; align-items: center; gap: 14px; flex: none; } +.wos-root .wos-navlink { color: var(--fg); font-weight: 500; font-size: 15px; } +.wos-root .wos-navlink:hover { color: var(--link); text-decoration: none; } +.wos-iconbtn { background: none; border: 0; color: var(--fg-2); cursor: pointer; padding: 6px; border-radius: var(--radius-full); display: inline-flex; } +.wos-iconbtn:hover { background: var(--surface-2); color: var(--fg); } + +/* SectionHeading */ +.wos-sh { max-width: 760px; margin: 0; } +.wos-sh--center { margin: 0 auto; text-align: center; } +.wos-eyebrow { color: var(--link); font-size: 13px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; line-height: 1.5; margin: 0 0 16px; } +.wos-h1 { font-size: clamp(38px, 6vw, 64px); line-height: 1.08; letter-spacing: -.025em; font-weight: 700; margin: 0 auto 22px; max-width: 20ch; text-wrap: balance; color: var(--fg); } +.wos-h1 em { font-style: normal; color: var(--primary); } +.wos-h2 { font-size: clamp(28px, 3.6vw, 40px); line-height: 1.15; letter-spacing: -.02em; font-weight: 700; margin: 0 0 14px; text-wrap: balance; color: var(--fg); } +.wos-lead { color: var(--fg-2); font-size: 18px; line-height: 1.65; margin: 0; } +.wos-sh--center .wos-lead { font-size: clamp(18px, 2vw, 21px); max-width: 58ch; margin: 0 auto; } + +/* ProblemCard */ +.wos-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--card-pad-sm); transition: border-color .18s; } +.wos-card:hover { border-color: color-mix(in srgb, var(--primary) 50%, var(--border)); } +.wos-card__title { font-size: 19px; font-weight: 600; line-height: 1.35; margin: 0 0 8px; display: flex; gap: 12px; align-items: baseline; color: var(--fg); } +.wos-card__num { font-family: var(--font-mono); font-size: 13px; color: var(--link); } +.wos-card__text { margin: 0; color: var(--fg-2); font-size: 16px; line-height: 1.65; } + +/* FeatureItem */ +.wos-feature { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 14px; align-items: start; } +.wos-feature .wos-check { margin-top: 3px; } +.wos-feature__title { margin: 0 0 8px; font-size: 18px; font-weight: 600; line-height: 1.35; color: var(--fg); } +.wos-feature__text { margin: 0 0 8px; color: var(--fg-2); font-size: 16px; line-height: 1.65; text-wrap: pretty; } +.wos-root .wos-feature__link { font-size: 14px; font-weight: 500; } + +/* PlanCard */ +.wos-plan { display: flex; flex-direction: column; gap: var(--space-3-5); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--card-pad); } +.wos-plan--highlight { border-color: var(--primary); box-shadow: var(--plan-highlight); } +.wos-plan__label { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--fg-3); } +.wos-plan--highlight .wos-plan__label { color: var(--link); } +.wos-plan__title { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -.015em; line-height: 1.3; color: var(--fg); } +.wos-plan__text { margin: 0; color: var(--fg-2); flex: 1; font-size: 16px; line-height: 1.65; } + +/* FaqItem */ +.wos-faq { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-faq); } +.wos-faq summary { list-style: none; cursor: pointer; padding: 18px 22px; font-weight: 600; font-size: 17px; line-height: 1.5; display: flex; justify-content: space-between; gap: 16px; color: var(--fg); } +.wos-faq summary::-webkit-details-marker { display: none; } +.wos-faq summary:hover { color: var(--link); } +.wos-faq summary::after { content: '+'; color: var(--fg-3); font-size: 22px; line-height: 1; } +.wos-faq[open] summary::after { content: '−'; } +.wos-faq__body { padding: 16px 22px 20px; color: var(--fg-2); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 12px; font-size: 16px; line-height: 1.65; } +.wos-faq__body p { margin: 0; max-width: 72ch; } + +/* InlineCode */ +.wos-code { font-family: var(--font-mono); font-size: .88em; background: var(--surface-2); border: 1px solid var(--border); padding: .1em .4em; border-radius: var(--radius-sm); color: var(--fg); } + +/* EventLegend */ +.wos-legend { display: flex; flex-wrap: wrap; gap: 14px; font-family: var(--font-viz-mono); font-size: 11px; color: var(--viz-fg-2); background: var(--viz-bg); padding: 12px 16px; border-radius: var(--radius-lg); } +.wos-legend span { display: flex; align-items: center; gap: 6px; } +.wos-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; } + +/* SiteFooter */ +.wos-footer { background: var(--bg-2); border-top: 1px solid var(--border); padding: 64px 0 40px; } +.wos-footer__in { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter); } +.wos-footer__statement { font-size: clamp(24px, 3.2vw, 34px); font-weight: 700; letter-spacing: -.02em; line-height: 1.2; margin: 0 0 44px; max-width: 22ch; color: var(--fg); } +.wos-footer__cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 28px; } +.wos-footer__cols h4 { margin: 0 0 12px; font-size: 15px; font-weight: 700; color: var(--fg); } +.wos-footer__cols ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-size: 15px; } +.wos-root .wos-footer__cols a { color: var(--fg-2); } +.wos-root .wos-footer__cols a:hover { color: var(--link); text-decoration: none; } +.wos-footer__copy { text-align: center; color: var(--fg-3); font-size: 14px; margin: 48px 0 0; } diff --git a/apps/explorer/design/components/bundle.js b/apps/explorer/design/components/bundle.js new file mode 100644 index 000000000..bbe698d7c --- /dev/null +++ b/apps/explorer/design/components/bundle.js @@ -0,0 +1,154 @@ +/* @ds-bundle: {"format":4,"namespace":"WalkerOS","components":[{"name":"AnnouncementBar"},{"name":"SiteHeader"},{"name":"Button"},{"name":"InstallCommand"},{"name":"CheckList"},{"name":"SectionHeading"},{"name":"ProblemCard"},{"name":"FeatureItem"},{"name":"PlanCard"},{"name":"FaqItem"},{"name":"InlineCode"},{"name":"EventLegend"},{"name":"SiteFooter"},{"name":"HeroTaggingViz"},{"name":"ArticleTeaserTracking"},{"name":"DestinationMappingViz"}]} */ +(function () { + var R = window.React; + var h = R.createElement; + var LOGO = '/_blob/7bda8c16c37907d4900a00176dc655f0'; + + function cx() { return Array.prototype.filter.call(arguments, Boolean).join(' '); } + + function CheckIcon(p) { + var s = (p && p.size) || 16; + return h('svg', { className: 'wos-check', width: s, height: s, viewBox: '0 0 16 16', 'aria-hidden': true }, + h('path', { d: 'M3 8.5l3 3 7-7', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' })); + } + function CopyIcon() { + return h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', 'aria-hidden': true }, + h('rect', { x: 9, y: 9, width: 12, height: 12, rx: 2, fill: 'none', stroke: 'currentColor', strokeWidth: 2 }), + h('path', { d: 'M5 15V5a2 2 0 0 1 2-2h10', fill: 'none', stroke: 'currentColor', strokeWidth: 2 })); + } + function SunIcon() { + return h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, + h('circle', { cx: 12, cy: 12, r: 4 }), + h('path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' })); + } + function MoonIcon() { + return h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, + h('path', { d: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z' })); + } + + function Button(p) { + var variant = p.variant || 'primary'; + var cls = cx('wos-btn', 'wos-btn--' + variant, p.block && 'wos-btn--block', p.className); + var kids = [p.children, p.arrow ? ' →' : null]; + return p.href + ? h('a', { className: cls, href: p.href }, kids) + : h('button', { type: 'button', className: cls, onClick: p.onClick }, kids); + } + + function InstallCommand(p) { + var cmd = p.command || 'npm i -g @walkeros/cli'; + var st = R.useState(false), done = st[0], setDone = st[1]; + function copy() { + try { navigator.clipboard.writeText(cmd); } catch (e) {} + setDone(true); + setTimeout(function () { setDone(false); }, 1400); + } + return h('button', { type: 'button', className: 'wos-cmd', onClick: copy, 'aria-label': 'Copy install command' }, + h('span', null, h('span', { className: 'wos-cmd__prompt' }, '$ '), cmd), + h('span', { className: cx('wos-cmd__icon', done && 'wos-cmd__icon--done') }, done ? h(CheckIcon) : h(CopyIcon))); + } + + function CheckList(p) { + var stack = p.layout === 'stack'; + return h('ul', { className: cx('wos-checklist', stack && 'wos-checklist--stack', p.align === 'center' && 'wos-checklist--center') }, + (p.items || []).map(function (t, i) { return h('li', { key: i }, h(CheckIcon, { size: stack ? 14 : 16 }), t); })); + } + + function AnnouncementBar(p) { + return h('div', { className: 'wos-announce' }, + p.children, p.linkLabel ? [' · ', h('a', { key: 'a', href: p.href || '#' }, p.linkLabel)] : null); + } + + function SiteHeader(p) { + var links = p.links || [{ label: 'Documentation', href: '#' }, { label: 'Playground', href: '#' }, { label: 'Services', href: '#' }]; + var dark = p.theme !== 'light'; + return h('header', { className: 'wos-header' }, + h('div', { className: 'wos-header__in' }, + h('a', { className: 'wos-header__logo', href: p.homeHref || '#', 'aria-label': 'walkerOS home' }, h('img', { src: LOGO, alt: 'walkerOS' })), + h('nav', { className: 'wos-header__nav', 'aria-label': 'Main' }, + links.map(function (l, i) { return h('a', { key: i, className: 'wos-navlink', href: l.href }, l.label); })), + h('div', { className: 'wos-header__end' }, + h('a', { className: 'wos-navlink', href: p.githubHref || 'https://github.com/elbwalker/walkerOS' }, 'GitHub'), + h('button', { type: 'button', className: 'wos-iconbtn', onClick: p.onToggleTheme, 'aria-label': dark ? 'Switch to light mode' : 'Switch to dark mode' }, + dark ? h(SunIcon) : h(MoonIcon))))); + } + + function SectionHeading(p) { + var hero = p.level === 1; + return h('div', { className: cx('wos-sh', (hero || p.align === 'center') && 'wos-sh--center') }, + p.eyebrow ? h('p', { className: 'wos-eyebrow' }, p.eyebrow) : null, + hero + ? h('h1', { className: 'wos-h1' }, p.title, p.highlight ? [' ', h('em', { key: 'e' }, p.highlight)] : null) + : h('h2', { className: 'wos-h2' }, p.title), + p.lead ? h('p', { className: 'wos-lead' }, p.lead) : null); + } + + function ProblemCard(p) { + return h('div', { className: 'wos-card' }, + h('h3', { className: 'wos-card__title' }, h('span', { className: 'wos-card__num' }, p.number), p.title), + h('p', { className: 'wos-card__text' }, p.children)); + } + + function FeatureItem(p) { + return h('div', { className: 'wos-feature' }, + h(CheckIcon, { size: 20 }), + h('div', null, + h('h3', { className: 'wos-feature__title' }, p.title), + h('p', { className: 'wos-feature__text' }, p.children), + p.href ? h('a', { className: 'wos-feature__link', href: p.href }, (p.linkLabel || 'Docs') + ' →') : null)); + } + + function PlanCard(p) { + return h('div', { className: cx('wos-plan', p.highlight && 'wos-plan--highlight') }, + h('p', { className: 'wos-plan__label' }, p.label), + h('h3', { className: 'wos-plan__title' }, p.title), + h('p', { className: 'wos-plan__text' }, p.children), + p.features ? h(CheckList, { items: p.features, layout: 'stack' }) : null, + p.cta ? h(Button, { href: p.cta.href || '#', variant: p.highlight ? 'primary' : 'secondary', block: true }, p.cta.label) : null); + } + + function FaqItem(p) { + var body = Array.isArray(p.children) ? p.children : [p.children]; + return h('details', { className: 'wos-faq', open: p.open }, + h('summary', null, p.question), + h('div', { className: 'wos-faq__body' }, body.map(function (c, i) { return typeof c === 'string' ? h('p', { key: i }, c) : c; }))); + } + + function InlineCode(p) { return h('code', { className: 'wos-code' }, p.children); } + + var PARTS = [['entity', 'event-entity'], ['action', 'event-action'], ['property', 'event-property'], ['context', 'event-context'], ['globals', 'event-globals']]; + function EventLegend(p) { + var only = p.parts; + return h('div', { className: 'wos-legend' }, + PARTS.filter(function (x) { return !only || only.indexOf(x[0]) >= 0; }).map(function (x) { + return h('span', { key: x[0] }, h('i', { style: { background: 'var(--' + x[1] + ')' } }), x[0]); + })); + } + + var FOOTER = [ + { title: 'Learn', links: [['Documentation', 'https://www.walkeros.io/docs/'], ['Playground', 'https://www.walkeros.io/playground/'], ['Comparisons', 'https://www.walkeros.io/docs/comparisons/'], ['Storybook demo', 'https://storybook.walkeros.io/']] }, + { title: 'Open source', links: [['GitHub', 'https://github.com/elbwalker/walkerOS'], ['npm', 'https://www.npmjs.com/package/@walkeros/core'], ['Changelog', 'https://github.com/elbwalker/walkerOS/releases'], ['License', 'https://github.com/elbwalker/walkerOS/blob/main/LICENSE']] }, + { title: 'Community', links: [['GitHub Discussions', 'https://github.com/elbwalker/walkerOS/discussions'], ['LinkedIn', 'https://www.linkedin.com/company/elbwalker/']] }, + { title: 'Company', links: [['About us', 'https://www.elbwalker.com'], ['Services', 'https://www.elbwalker.com/services'], ['Privacy Policy', 'https://www.elbwalker.com/privacy'], ['Imprint', 'https://www.elbwalker.com/imprint']] } + ]; + function SiteFooter(p) { + var cols = p.columns || FOOTER; + return h('footer', { className: 'wos-footer' }, + h('div', { className: 'wos-footer__in' }, + h('p', { className: 'wos-footer__statement' }, p.statement || 'Own the thing your growth numbers depend on.'), + h('div', { className: 'wos-footer__cols' }, cols.map(function (c, i) { + return h('div', { key: i }, h('h4', null, c.title), h('ul', null, c.links.map(function (l, j) { + return h('li', { key: j }, h('a', { href: l[1] }, l[0])); + }))); + })), + h('p', { className: 'wos-footer__copy' }, p.copyright || 'Copyright © 2026 elbwalker GmbH, Hamburg · hello@elbwalker.com'))); + } + + window.WalkerOS = { + AnnouncementBar: AnnouncementBar, SiteHeader: SiteHeader, Button: Button, InstallCommand: InstallCommand, + CheckList: CheckList, SectionHeading: SectionHeading, ProblemCard: ProblemCard, FeatureItem: FeatureItem, + PlanCard: PlanCard, FaqItem: FaqItem, InlineCode: InlineCode, EventLegend: EventLegend, SiteFooter: SiteFooter + }; +})(); +/* ── product visualisations (compiled from components/src/viz.jsx) ── */ +(()=>{const React=window.React,h=React.createElement,{useState,useEffect,useRef}=React,cache=new Map;function st(css){let o=cache.get(css);if(o)return o;o={};for(const decl of css.split(";")){const i=decl.indexOf(":");if(i<0)continue;const k=decl.slice(0,i).trim();if(!k)continue;const key=k.startsWith("--")?k:k.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());o[key]=decl.slice(i+1).trim()}return cache.size<2e3&&cache.set(css,o),o}const V={bg:"var(--viz-bg)",code:"var(--viz-code-bg)",border:"var(--viz-border)",fg:"var(--viz-fg)",fg2:"var(--viz-fg-2)",comment:"var(--viz-comment)",ln:"var(--viz-line-number)",surface:"var(--viz-surface)",primary:"var(--primary)",entity:"var(--event-entity)",action:"var(--event-action)",prop:"var(--event-property)",ctx:"var(--event-context)",glob:"var(--event-globals)",tag:"var(--viz-tag)",str:"var(--viz-string)",num:"var(--viz-number)",punct:"var(--viz-punct)"},SANS="font-family:var(--font-viz)",MONO="font-family:var(--font-viz-mono)",Toks=({list})=>list.map((tk2,i)=>h("span",{key:i,style:st(`color:${tk2.c}; background:${tk2.bg||"transparent"}; border-radius:3px`)},tk2.t)),MCOL={str:V.entity,key:V.glob,num:V.num,fn:V.action,p:V.punct,x:V.fg},MHL="rgba(197,228,120,0.16)";function mtokens(line,hl){const out=[],re=/"[^"]*"|'[^']*'|\d+(?:\.\d+)?|[A-Za-z_]\w*|\s+|./g;let m;for(;m=re.exec(line);){const t=m[0],rest=line.slice(re.lastIndex),isKey=/^\s*:/.test(rest);let c=MCOL.p,bg="transparent";/^["']/.test(t)?(c=isKey?MCOL.key:MCOL.str,hl&&t.slice(1,-1)===hl&&(bg=MHL)):/^\d/.test(t)||t==="true"||t==="false"?c=MCOL.num:/^(gtag|fbq|elb|ttq|track|amplitude|revenue)$/.test(t)?c=MCOL.fn:/^[A-Za-z_]/.test(t)?c=isKey?MCOL.key:MCOL.x:/^\s+$/.test(t)&&(c=MCOL.x),out.push({t,c,bg})}return out}const MD={ga4:{name:"GA4"},meta:{name:"Meta Pixel"},amp:{name:"Amplitude"},tt:{name:"TikTok Pixel"}},MEVENTS=[{entity:"product",action:"view",dests:[{...MD.ga4,mapped:"view_item",out:["gtag('event', 'view_item', {"," currency: 'EUR', value: 420,"," items: [{ item_id: 'ers' }]","})"]},{...MD.meta,mapped:"ViewContent",out:["fbq('track', 'ViewContent', {"," value: 420, currency: 'EUR',"," contents: [{ id: 'ers', quantity: 1 }],"," content_type: 'product'","})"]},{...MD.tt,mapped:"ViewContent",out:["ttq.track('ViewContent', {"," content_id: 'ers', content_type: 'product',"," value: 420, currency: 'EUR'","})"]},{...MD.amp,mapped:"Product Viewed",out:["amplitude.track('Product Viewed', {"," product_id: 'ers', price: 420, currency: 'EUR'","})"]}]},{entity:"lead",action:"submit",dests:[{...MD.ga4,mapped:"generate_lead",out:["gtag('event', 'generate_lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.meta,mapped:"Lead",out:["fbq('track', 'Lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.tt,mapped:"Contact",out:["ttq.track('Contact', {"," value: 2500, currency: 'EUR'","})"]},{...MD.amp,mapped:"Lead Submitted",out:["amplitude.track('Lead Submitted', {"," lead_type: 'demo', value: 2500, currency: 'EUR'","})"]}]},{entity:"order",action:"complete",dests:[{...MD.ga4,mapped:"purchase",out:["gtag('event', 'purchase', {"," transaction_id: '0rd3r1d', value: 462,"," currency: 'EUR', items: [\u2026]","})"]},{...MD.meta,mapped:"Purchase",out:["fbq('track', 'Purchase', {"," value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.tt,mapped:"CompletePayment",out:["ttq.track('CompletePayment', {"," order_id: '0rd3r1d', value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.amp,mapped:"purchase",ruleText:'"order": { "complete": { "settings": { "revenue": {\u2026} } } }',out:["amplitude.revenue({"," productId: 'ers', price: 420, quantity: 1,"," revenueType: 'purchase', currency: 'EUR'","});","amplitude.track('order complete', { \u2026 })"]}]}],MSHORT={product:"elb('product view', { id: 'ers', price: 420, currency: 'EUR' })",lead:"elb('lead submit', { type: 'demo', value: 2500, currency: 'EUR' })",order:"elb('order complete', { id: '0rd3r1d', value: 462, currency: 'EUR' })"};function DestinationMappingViz(props){var _a;const[ev,setEv]=useState(props.initialEvent||0),E=MEVENTS[ev],entityName=E.entity+" "+E.action,caption=(_a=props.caption)!=null?_a:"40+ destination adapters ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.";return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:#1c2638; border:1px solid #38455e; border-radius:14px; overflow:hidden`)},h("div",{style:st("padding:18px 20px 0; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:${V.fg2}`)},"Pick an event"),h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},MEVENTS.map((e,i)=>{const a=i===ev;return h("button",{key:i,type:"button",className:"wos-viz-chip",onClick:()=>setEv(i),"aria-pressed":a,style:st(`position:relative; display:flex; overflow:hidden; padding:0; border-radius:7px; border:1px solid ${a?V.primary:"#38455e"}; background:${a?"rgba(1,181,226,0.08)":"transparent"}; ${MONO}; font-size:13px; cursor:pointer; transition:border-color .25s, background .25s`)},h("span",{style:st(`padding:7px 10px; color:${V.fg}; background:rgba(255,255,255,0.04)`)},e.entity),h("span",{style:st(`padding:7px 10px; color:${V.primary}`)},e.action))}))),h("div",{style:st(`margin:16px 20px 0; padding:10px 14px; background:${V.code}; border:1px solid #2f3a4d; border-radius:8px; overflow-x:auto; ${MONO}; font-size:12.5px; line-height:1.6; white-space:pre`)},h(Toks,{list:mtokens(MSHORT[E.entity],entityName)})),h("div",{style:st("padding:18px 20px 20px; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:12.5px; font-style:italic; color:${V.comment}`)},"// same event, four destinations, zero app code touched"),h("div",{style:st("display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap:10px")},E.dests.map((d,i)=>{const rule=d.ruleText?d.ruleText:`"${E.entity}": { "${E.action}": { "name": "${d.mapped}" } }`;return h("div",{key:d.name,style:st(`min-width:0; background:${V.code}; border:1px solid #2f3a4d; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:8px`)},h("span",{style:st("font-size:14px; font-weight:600")},d.name),h("div",{style:st(`display:flex; gap:10px; align-items:baseline; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"mapping"),h("span",{style:st("white-space:pre")},h(Toks,{list:mtokens(rule,d.mapped)}))),h("div",{style:st(`display:flex; gap:10px; align-items:flex-start; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"sends"),h("div",{style:st("min-width:0")},d.out.map((l,j)=>h("div",{key:j,style:st("white-space:pre")},h(Toks,{list:mtokens(l,d.mapped)}))))))}))),caption?h("div",{style:st(`border-top:1px solid #38455e; padding:18px 20px 20px; font-size:15px; line-height:1.6; color:${V.punct}; text-wrap:pretty`)},caption):null)}const ARTICLES=[{kicker:"Tech",cat:"tech",img:"bug",title:"Return of the Bug"},{kicker:"Tech",cat:"tech",title:"The quiet return of RSS"},{kicker:"Food",cat:"food",title:"Why sourdough came back"}],HL_=11500,P={siS:0,siE:500,aS:900,aE:1500,dS:1800,dE:2600,eS:2900,eE:3600,bS:3900,bE:4700,cS:5e3,cE:5800,imp:6200,mS:6900,mE:7700,click:7850,row:8e3,fo:10600,soS:10800,soE:11400},HC={tag:V.tag,attr:V.glob,p:V.punct,str:V.str,txt:V.fg},MAX_ROWS=24,tk=(t,c)=>({t,c,txt:!0,caret:!1}),len=a=>a.reduce((s,x)=>s+x.t.length,0),trunc=(a,n)=>{const o=[];let l=n;for(const x of a){if(l<=0)break;const s=x.t.slice(0,l);o.push({...x,t:s}),l-=s.length}return o},clamp=x=>Math.max(0,Math.min(1,x)),ease=x=>x<.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;function Thumb({art}){const abs={viewBox:"0 0 112 86",preserveAspectRatio:"xMidYMid slice",style:st("position:absolute; inset:0; width:100%; height:100%"),"aria-hidden":!0};return art.img==="bug"?h("svg",abs,h("rect",{width:112,height:86,fill:"#dfe8cf"}),h("ellipse",{cx:56,cy:66,rx:30,ry:6,fill:"#bccaa6"}),h("g",{stroke:"#3b3232",strokeWidth:2.4,strokeLinecap:"round",fill:"none"},["M36 46 L28 42","M35 54 L27 56","M38 61 L31 66","M76 46 L84 42","M77 54 L85 56","M74 61 L81 66","M51 25 Q47 17 42 16","M61 25 Q65 17 70 16"].map((d,i)=>h("path",{key:i,d}))),h("circle",{cx:42,cy:16,r:2,fill:"#3b3232"}),h("circle",{cx:70,cy:16,r:2,fill:"#3b3232"}),h("ellipse",{cx:56,cy:30,rx:10,ry:7.5,fill:"#3b3232"}),h("circle",{cx:52,cy:29,r:1.6,fill:"#ffffff"}),h("circle",{cx:60,cy:29,r:1.6,fill:"#ffffff"}),h("ellipse",{cx:56,cy:50,rx:21,ry:18,fill:"#d9534a"}),h("path",{d:"M56 33 L56 68",stroke:"#3b3232",strokeWidth:2}),h("circle",{cx:47,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:65,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:45,cy:56,r:2.8,fill:"#3b3232"}),h("circle",{cx:67,cy:56,r:2.8,fill:"#3b3232"}),h("path",{d:"M44 38 Q48 35 52 36",stroke:"#ef8a7f",strokeWidth:2,fill:"none",strokeLinecap:"round"})):art.cat==="tech"?h("svg",abs,h("rect",{width:112,height:86,fill:"#334058"}),h("rect",{x:38,y:25,width:36,height:36,rx:8,fill:"#f08a24"}),h("circle",{cx:47,cy:52,r:3.2,fill:"#ffffff"}),h("path",{d:"M45 42 A10 10 0 0 1 57 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"}),h("path",{d:"M45 34.5 A17.5 17.5 0 0 1 64.5 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"})):h("svg",abs,h("rect",{width:112,height:86,fill:"#eadcc6"}),h("ellipse",{cx:56,cy:62,rx:44,ry:10,fill:"#b07d4f"}),h("ellipse",{cx:56,cy:50,rx:31,ry:19,fill:"#c3834a"}),h("ellipse",{cx:56,cy:45,rx:25,ry:13,fill:"#d99d5e"}),h("path",{d:"M40 44 Q56 35 72 44",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}),h("path",{d:"M44 51 Q56 45 68 51",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}))}const Pill=({bg,children})=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px`)},children);function HeroTaggingViz(props){var _a,_b;const playing=(_a=props.playing)!=null?_a:!0,speed=(_b=props.speed)!=null?_b:1,[t,setT]=useState(props.startAt||0),el=useRef(props.startAt||0),wall0=useRef(Date.now()),cfg=useRef({playing,speed});cfg.current={playing,speed},useEffect(()=>{let last=performance.now(),raf;const loop=now=>{const dt=Math.min(64,now-last);last=now,cfg.current.playing&&(el.current+=dt*cfg.current.speed),setT(el.current),raf=requestAnimationFrame(loop)};return raf=requestAnimationFrame(loop),()=>cancelAnimationFrame(raf)},[]);const k=Math.floor(t/HL_),ph=t-k*HL_,art=ARTICLES[k%3],A=[tk("data-elb",HC.attr),tk("=",HC.p),tk('"article"',HC.str)],B=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"title:#innerText"',HC.str)],E=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"category:#innerText"',HC.str)],Cc=[tk(" ",HC.p),tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"click:open"',HC.str)],D=[tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"impression"',HC.str)],typed=(a,s,e)=>Math.round(clamp((ph-s)/(e-s))*len(a)),na=typed(A,P.aS,P.aE),nb=typed(B,P.bS,P.bE),nc=typed(Cc,P.cS,P.cE),nd=typed(D,P.dS,P.dE),ne=typed(E,P.eS,P.eE);let caret=null;ph>=P.aS-400&&ph=P.dS-300&&ph=P.eS-100&&ph=P.bS-100&&ph=P.cS-100&&ph=P.aS&&ph<=P.aE||ph>=P.bS&&ph<=P.bE||ph>=P.cS&&ph<=P.cE||ph>=P.dS&&ph<=P.dE||ph>=P.eS&&ph<=P.eE||ph%900<500?1:0},wc=(a,on)=>on?[...a,car]:a,lines=[{toks:[tk("=P.aS-400&&{toks:[tk(" ",HC.p),...wc(trunc(A,na),caret==="a")],on:!0},ph>=P.dS-300&&{toks:[tk(" ",HC.p),...wc(trunc(D,nd),caret==="d")],on:!0},{toks:[tk(">",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.kicker,HC.txt),tk("",HC.tag)],on:ph>=P.eS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.title,HC.txt),tk("",HC.tag)],on:ph>=P.bS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk("Open \u2192",HC.txt),tk("",HC.tag)],on:ph>=P.cS-100},{toks:[tk("",HC.tag)]}].filter(Boolean).map((l,i)=>({...l,n:i+1})),fade=ph<250?ph/250:ph>P.fo?1-clamp((ph-P.fo)/(HL_-P.fo-150)):1,so=ease(clamp((ph-P.soS)/(P.soE-P.soS))),si=ease(clamp((ph-P.siS)/(P.siE-P.siS))),cardOff=ph>=P.soS?-so*190:(1-si)*190,impOn=ph>=P.dE,entityOn=ph>=P.aE,propsOn=ph>=P.bE,catOn=ph>=P.eE,actionOn=ph>=P.cE,m=ease(clamp((ph-P.mS)/(P.mE-P.mS))),press=ph>=P.click&&ph=P.mS-200&&phP.fo?1-clamp((ph-P.fo)/200):1):0,propGlow="0 0 10px 1px rgba(240,113,127,0.55)",rows=[],evs=[];for(let j=k;j>=0&&evs.length=P.row)&&evs.push({j,at:P.row,action:"open"}),(j=P.imp)&&evs.push({j,at:P.imp,action:"impression"});for(const e of evs.slice(0,MAX_ROWS)){const a=ARTICLES[e.j%3],age=e.j===k?ph-e.at:1e4;rows.push({key:e.j+e.action,n:rows.length+1,name:"article "+e.action,title:a.title,cat:a.kicker.toUpperCase(),bg:`rgba(197,228,120,${(.14*(1-clamp(age/1600))).toFixed(3)})`,op:clamp(age/300),ty:`translateX(${((1-ease(clamp(age/500)))*-80).toFixed(1)}px)`})}const skeletons=["72%","58%","80%","50%","66%","62%"].slice(0,Math.max(0,6-rows.length)).map((w,i)=>({w,n:rows.length+i+1})),grid="display:grid; grid-template-columns:38px 166px minmax(250px,1fr)",cellR="border-right:1px solid "+V.surface;return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border:1px solid #2f3a4d; border-radius:16px; padding:16px; display:flex; flex-wrap:wrap; gap:16px; align-items:stretch`)},h("div",{style:st(`flex:1.2 1 520px; min-width:0; background:${V.code}; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st("display:flex; align-items:center; gap:14px; padding:12px 16px; border-bottom:1px solid #243044")},h("div",{style:st("display:flex; gap:7px")},[0,1,2].map(i=>h("span",{key:i,style:st(`width:10px; height:10px; border-radius:50%; background:${V.border}`)}))),h("span",{style:st(`${MONO}; font-size:12px; color:${V.primary}`)},"ArticleTeaser.html")),h("div",{style:st(`padding:12px 0 14px; ${MONO}; font-size:13px; line-height:1.95; min-height:268px; opacity:${fade}`)},lines.map(ln=>h("div",{key:ln.n,style:st(`display:flex; background:${ln.on?"rgba(197,228,120,0.07)":"transparent"}`)},h("span",{style:st(`width:3px; flex:none; background:${ln.on?V.entity:"transparent"}`)}),h("span",{style:st(`width:30px; flex:none; text-align:right; padding-right:14px; color:${V.ln}`)},ln.n),h("span",{style:st("min-width:0; white-space:pre-wrap; word-break:break-all; padding-right:16px")},ln.toks.map((x,i)=>x.caret?h("span",{key:i,style:st(`display:inline-block; width:2px; height:1.15em; margin-left:1px; vertical-align:-0.2em; background:${V.primary}; opacity:${x.o}`)}):h("span",{key:i,style:st(`color:${x.c}`)},x.t)))))),h("div",{style:st("flex:1; position:relative; overflow:hidden; border-top:1px solid #243044; background:repeating-linear-gradient(45deg, #172030 0 8px, #1c2535 8px 16px); padding:34px 24px 28px; display:flex; justify-content:center; align-items:center")},h("div",{style:st(`position:relative; width:100%; max-width:430px; display:flex; gap:14px; background:${V.surface}; border:1.5px solid ${entityOn?V.entity:"#38455e"}; border-radius:10px; padding:12px; opacity:${clamp(ph/250)*(1-so)}; transform:translateY(${cardOff.toFixed(1)}px)`)},h("div",{style:st("position:absolute; top:-10px; left:12px; display:flex; gap:6px")},entityOn?h(Pill,{bg:V.entity},"article"):null,impOn?h(Pill,{bg:V.action},"impression"):null),h("div",{style:st("flex:none; width:112px; min-height:86px; border-radius:6px; overflow:hidden; position:relative; background:#2e3a50")},h(Thumb,{art})),h("div",{style:st("flex:1; min-width:0; display:flex; flex-direction:column; gap:4px")},h("span",{style:st(`align-self:flex-start; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; color:${V.fg2}; border-radius:3px; padding:0 3px; margin-left:-3px; outline:1.5px solid ${catOn?V.prop:"transparent"}; box-shadow:${catOn?propGlow:"none"}`)},art.kicker),h("span",{style:st("font-size:15px; font-weight:600; line-height:1.3; text-wrap:pretty")},h("span",{style:st(`border-radius:3px; outline:1.5px solid ${propsOn?V.prop:"transparent"}; outline-offset:1px; box-shadow:${propsOn?propGlow:"none"}`)},art.title)),h("div",{style:st("margin-top:auto; padding-top:6px; display:flex; align-items:center; gap:8px")},h("span",{style:st(`position:relative; font-size:13px; font-weight:500; color:${V.primary}; padding:1px 5px; margin-left:-5px; border-radius:5px; outline:1.5px solid ${actionOn?V.action:"transparent"}`)},"Open \u2192",h("span",{style:st(`position:absolute; left:${(22+(1-m)*190).toFixed(1)}px; top:${(11-(1-m)*80).toFixed(1)}px; width:22px; height:22px; margin:-11px 0 0 -11px; pointer-events:none; opacity:${curOp}`)},h("span",{style:st(`position:absolute; inset:0; border-radius:50%; border:2px solid ${V.entity}; opacity:${ph>=P.click?((1-rp)*.9).toFixed(3):0}; transform:scale(${(1+rp*1.8).toFixed(3)})`)}),h("span",{style:st(`position:absolute; inset:0; border-radius:50%; background:rgba(255,255,255,0.22); border:2px solid rgba(255,255,255,0.9); box-shadow:0 4px 14px rgba(0,0,0,0.4); transform:scale(${press?.8:1})`)})))))))),h("div",{style:st(`flex:1 1 400px; min-width:0; background:#1e2839; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid ${V.surface}; ${MONO}; font-size:12px`)},h("span",{style:st(`color:${V.fg2}`)},h("span",{style:st(`color:${V.action}`)},"SELECT")," * ",h("span",{style:st(`color:${V.action}`)},"FROM")," ",h("span",{style:st(`color:${V.fg}`)},"walkerOS.events")),h("span",{style:st(`display:flex; align-items:center; gap:7px; color:${V.entity}`)},h("span",{style:st(`width:7px; height:7px; border-radius:50%; background:${V.entity}`)}),"live")),h("div",{style:st("flex:1; position:relative; min-height:300px")},h("div",{style:st(`position:absolute; inset:0; overflow-x:auto; overflow-y:hidden; ${MONO}; font-size:12px`)},h("div",{style:st("min-width:454px")},h("div",{style:st(`${grid}; background:#243044; border-bottom:1px solid ${V.border}`)},h("span",{style:st(`padding:8px 10px; ${cellR}; color:${V.comment}; font-size:11px; text-align:right`)},"Row"),h("span",{style:st(`padding:8px 10px; ${cellR}; display:flex; flex-direction:column; gap:1px`)},h("span",{style:st(`color:${V.fg}`)},"name"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"STRING")),h("span",{style:st("padding:8px 10px; display:flex; flex-direction:column; gap:1px")},h("span",{style:st(`color:${V.fg}`)},"data"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"JSON"))),rows.map(r=>h("div",{key:r.key,style:st(`${grid}; border-bottom:1px solid ${V.surface}; background:${r.bg}; opacity:${r.op}; transform:${r.ty}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.comment}; text-align:right`)},r.n),h("span",{style:st(`padding:10px; ${cellR}; color:${V.fg}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},r.name),h("span",{style:st(`padding:10px; color:${V.punct}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},'{"',h("span",{style:st(`color:${V.glob}`)},"title"),'":"',h("span",{style:st(`color:${V.str}`)},r.title),'","',h("span",{style:st(`color:${V.glob}`)},"category"),'":"',h("span",{style:st(`color:${V.str}`)},r.cat),'"}'))),skeletons.map(s=>h("div",{key:"s"+s.n,style:st(`${grid}; border-bottom:1px solid ${V.surface}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.ln}; text-align:right`)},s.n),h("span",{style:st(`padding:10px; ${cellR}; display:flex; align-items:center`)},h("span",{style:st(`height:8px; width:75%; border-radius:4px; background:${V.surface}`)})),h("span",{style:st("padding:10px; display:flex; align-items:center")},h("span",{style:st(`height:8px; width:${s.w}; border-radius:4px; background:#253145`)})))))))))}const AC={kw:V.action,tag:V.tag,at:V.glob,p:V.punct,s:V.str,x:V.fg},TY={entity:[V.entity,"rgba(197,228,120,"],action:[V.action,"rgba(199,146,234,"],prop:[V.prop,"rgba(240,113,127,"],ctx:[V.ctx,"rgba(242,166,90,"],glob:[V.glob,"rgba(127,196,234,"]},at=(t,c,hl)=>({t,c,h:!!hl}),F=(step,name,kind)=>({step,file:!0,name,kind}),L=(step,toks,fx,type)=>({step,toks,fx,type});function buildItems(){const it=[F(1,"Link.tsx","atom"),L(1,[at("export const",AC.kw),at(" Link ",AC.x),at("= ({ href, children }) => (",AC.p)]),L(1,[at(" "),at("",AC.tag)],"action","action"),L(1,[at(" "),at("{children}",AC.p)]),L(1,[at(" "),at("",AC.tag)]),L(1,[at(");",AC.p)]),F(2,"ArticleTeaser.tsx","molecule"),L(2,[at("export const",AC.kw),at(" ArticleTeaser ",AC.x),at("= ({",AC.p)]),L(2,[at(" title, category, image, url, position,",AC.p)]),L(2,[at("}) => (",AC.p)]),L(2,[at(" "),at("",AC.tag)],"pos","prop"),L(2,[at(" "),at("",AC.tag)],"img"),L(2,[at(" "),at("",AC.tag),at("{category}",AC.p),at("",AC.tag)],"cat","prop"),L(2,[at(" "),at("",AC.tag),at("{title}",AC.p),at("",AC.tag)],"title","prop"),L(2,[at(" "),at("",AC.tag),at("Open \u2192",AC.x),at("",AC.tag)]),L(2,[at(" "),at("",AC.tag)]),L(2,[at(");",AC.p)])];return[[3,"HomeFeed.tsx","homefeed","stories","home"],[4,"RelatedStories.tsx","related","related","related"]].forEach(([s,f,ctx,arr,fx])=>it.push(F(s,f,"organism"),L(s,[at("",AC.tag)],fx,"ctx"),L(s,[at(" {"+arr+".map((s, i) => ",AC.p),at("",AC.tag),at(")}",AC.p)]),L(s,[at("",AC.tag)]))),[["HomePage.tsx","homepage","HomeFeed","gHome"],["ArticlePage.tsx","article","RelatedStories","gArt"]].forEach(([file,pt,org,fx])=>it.push(F(5,file,"template"),L(5,[at("",AC.tag)],fx,"glob"),L(5,[at(" "),at("<"+org+" />",AC.tag)]),L(5,[at("",AC.tag)]))),it}const ITEMS=buildItems(),HOME=[{kicker:"Tech",title:"Return of the Bug"},{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Food",title:"Why sourdough came back"}],RELATED=[{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Science",title:"A year on the ice shelf"},{kicker:"Culture",title:"Small venues, big nights"}],STEPS=[[1,"Atom"],[2,"Molecule"],[3,"HomeFeed"],[4,"RelatedStories"],[5,"Pages"]],CAPTIONS={1:"Atom. Link carries the click:open action wherever it is rendered.",2:"Molecule. ArticleTeaser makes each card an article entity and reads category and title from its elements.",3:"Organism. HomeFeed wraps three teasers and adds list:homefeed context to their events.",4:"Organism. RelatedStories reuses the same ArticleTeaser and adds list:related context.",5:"Pages. Each page template sets pagetype as a global, which is added to every event on that page."},aglow=rgba=>"0 0 10px 1px "+rgba+"0.5)";class ArticleTeaserTracking extends React.Component{constructor(p){super(p),this.state={n:p.initialStep?this.stepEnd(p.initialStep):0},this.codeRef=React.createRef()}get auto(){var _a;return(_a=this.props.autoplay)!=null?_a:!0}get speed(){var _a;return(_a=this.props.speed)!=null?_a:1}stepEnd(k){let last=ITEMS.findIndex(x=>x.step===k);for(;ITEMS[last+1]&&ITEMS[last+1].step===k;)last++;return last+1}componentDidMount(){const go=()=>this.schedule(800),el=this.codeRef.current;if(!el||!window.IntersectionObserver)return go();this.io=new IntersectionObserver(es=>{es.some(e=>e.isIntersecting)&&(this.io.disconnect(),go())},{threshold:.35}),this.io.observe(el)}componentWillUnmount(){clearTimeout(this.tm),clearTimeout(this.st),this.io&&this.io.disconnect()}componentDidUpdate(pp){pp.autoplay!==this.props.autoplay&&this.auto&&this.schedule(500)}scrollToCurrent(n){clearTimeout(this.st),this.st=setTimeout(()=>{const el=this.codeRef.current;if(!el)return;const tgt=el.querySelector('[data-ln="'+(n-1)+'"]');if(!tgt){el.scrollTo({top:0,behavior:"smooth"});return}const y=tgt.getBoundingClientRect().top-el.getBoundingClientRect().top+el.scrollTop;el.scrollTo({top:Math.max(0,y-el.clientHeight/2),behavior:"smooth"})},400)}setN(n){this.setState({n}),this.scrollToCurrent(n)}schedule(ms){clearTimeout(this.tm),this.tm=setTimeout(()=>this.advance(),ms/this.speed)}advance(){if(!this.auto)return;const n=this.state.n;if(n>=ITEMS.length)return;const s=ITEMS[n].step;if(ITEMS[n].file&&(n===0||ITEMS[n-1].step!==s)){this.setN(n+1),this.schedule(1300);return}let j=n;for(;jx.step===k);this.auto?(this.setN(first),this.schedule(250)):this.setN(this.stepEnd(k))}render(){const n=Math.min(this.state.n,ITEMS.length),visStep=n?ITEMS[n-1].step:0,cur=Math.max(1,visStep),seen=new Set(ITEMS.slice(0,n).map(x=>x.fx).filter(Boolean)),f={};["action","entity","pos","img","cat","title","home","related","gHome","gArt"].forEach(x=>f[x]=seen.has(x));let num=0,firstFile=!0;const lines=ITEMS.map((it,i)=>{const vis=it.step<=visStep,on=ih("span",{key:j,style:st(`color:${k.c||AC.p}; background:${k.h&&ty&&on?ty[1]+"0.16)":"transparent"}; border-radius:3px; transition:background .35s`)},k.t)))))}),card=(c,i)=>h("div",{key:i,style:st(`position:relative; min-height:224px; border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:8px; border:1.5px ${f.entity?"solid":"dashed"} ${f.entity?V.entity:"#2b374c"}; background:${f.entity?V.surface:"transparent"}; transition:all .45s ease`)},h("span",{style:st("position:absolute; top:-10px; left:10px; display:flex; gap:4px")},h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${V.entity}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${f.entity?1:0}; transition:opacity .4s`)},"article"),h("span",{style:st(`padding:0 6px; border-radius:4px; border:1.5px solid ${V.prop}; background:#1a2232; color:${V.prop}; ${MONO}; font-size:10.5px; line-height:16px; opacity:${f.pos?1:0}; transition:opacity .4s`)},i+1)),h("div",{style:st(`height:86px; flex:none; border-radius:6px; overflow:hidden; position:relative; background:repeating-linear-gradient(45deg, #2e3a50 0 6px, #323f56 6px 12px); display:flex; align-items:center; justify-content:center; ${MONO}; font-size:10.5px; color:${V.comment}; opacity:${f.img?1:0}; transition:opacity .45s`)},"photo"),h("span",{style:st(`align-self:flex-start; padding:1px 6px; border-radius:4px; background:#334058; color:#c3c9d9; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; opacity:${f.cat?1:0}; outline:1.5px solid ${f.cat?V.prop:"transparent"}; box-shadow:${f.cat?aglow(TY.prop[1]):"none"}; transition:all .45s`)},c.kicker),h("span",{style:st(`align-self:flex-start; font-size:14px; font-weight:600; line-height:1.3; text-wrap:pretty; border-radius:3px; outline:1.5px solid ${f.title?V.prop:"transparent"}; outline-offset:2px; box-shadow:${f.title?aglow(TY.prop[1]):"none"}; opacity:${f.title?1:0}; transition:all .45s`)},c.title),h("span",{style:st(`margin-top:auto; align-self:flex-start; padding:2px 6px; margin-left:-6px; border-radius:4px; font-size:13px; font-weight:500; color:${V.primary}; outline:1.5px solid ${f.action?V.action:"transparent"}; box-shadow:${f.action?aglow(TY.action[1]):"none"}; opacity:${f.action?1:0}; transition:all .45s`)},"Open \u2192")),pill=(bg,txt,op)=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${op}; transition:opacity .5s`)},txt),group=(on,label,listOn,header,ctx,cards)=>h("div",{style:st(`position:relative; border-radius:16px; padding:12px; border:1.5px dashed ${on?V.glob:"transparent"}; background:${on?"rgba(127,196,234,0.04)":"transparent"}; transition:all .5s`)},h("span",{style:st("position:absolute; top:-10px; left:14px")},pill(V.glob,label,on?1:0)),h("div",{style:st(`border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:16px; border:1.5px solid ${listOn?V.ctx:"#243044"}; background:${listOn?"rgba(242,166,90,0.05)":"transparent"}; transition:all .5s`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; min-height:19px; opacity:${listOn?1:0}; transition:opacity .5s`)},header,pill(V.ctx,ctx,1)),h("div",{style:st("display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:12px")},cards.map(card)))),legend=[["entity",V.entity],["action",V.action],["property",V.prop],["context",V.ctx],["globals",V.glob]];return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border-radius:16px; padding:24px; display:flex; flex-direction:column; gap:18px`)},h("div",{style:st("display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px 20px")},h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},STEPS.map(([k,label])=>{const a=k===cur,done=kthis.jump(k),"aria-pressed":a,style:st(`display:flex; align-items:center; gap:8px; padding:6px 12px 6px 6px; border-radius:999px; border:1px solid ${a?V.entity:V.border}; background:${a?"rgba(197,228,120,0.10)":"transparent"}; color:${a?V.fg:V.fg2}; ${SANS}; font-size:13px; font-weight:500; cursor:pointer; transition:all .3s`)},h("span",{style:st(`width:20px; height:20px; border-radius:50%; background:${a?V.entity:done?V.comment:V.border}; color:#1a2232; font-weight:600; font-size:11px; line-height:20px; text-align:center; transition:all .3s`)},k),h("span",null,label))})),h("div",{style:st(`display:flex; flex-wrap:wrap; gap:14px; ${MONO}; font-size:11px; color:${V.fg2}`)},legend.map(([n2,c])=>h("span",{key:n2,style:st("display:flex; align-items:center; gap:6px")},h("span",{style:st(`width:9px; height:9px; border-radius:2px; background:${c}`)}),n2)))),h("div",{style:st("font-size:15px; line-height:1.5; color:#c3c9d9; min-height:23px; text-wrap:pretty")},CAPTIONS[cur]),h("div",{style:st("display:flex; flex-wrap:wrap; gap:16px; align-items:stretch")},h("div",{style:st(`flex:1 1 380px; min-width:0; align-self:flex-start; position:sticky; top:16px; background:${V.code}; border:1px solid ${V.border}; border-radius:12px; overflow:hidden; padding:4px 0 10px`)},h("div",{ref:this.codeRef,style:st(`position:relative; overflow:auto; max-height:min(640px, calc(100vh - 48px)); ${MONO}; font-size:12px`)},lines)),h("div",{style:st("flex:1.1 1 420px; min-width:0; display:flex; flex-direction:column; gap:24px; padding-top:10px; align-self:flex-start")},group(f.gHome,"pagetype:homepage",f.home,h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 homepage"),"list:homefeed",HOME),group(f.gArt,"pagetype:article",f.related,h("span",{style:st("display:flex; align-items:center; gap:10px")},h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 article page"),h("span",{style:st("font-size:14px; font-weight:600")},"Related stories")),"list:related",RELATED))))}}Object.assign(window.WalkerOS=window.WalkerOS||{},{HeroTaggingViz,ArticleTeaserTracking,DestinationMappingViz});})(); diff --git a/apps/explorer/design/components/index.d.ts b/apps/explorer/design/components/index.d.ts new file mode 100644 index 000000000..c04cc97b0 --- /dev/null +++ b/apps/explorer/design/components/index.d.ts @@ -0,0 +1,65 @@ +// walkerOS design system — window.WalkerOS (React 18). Documentation types. + +/** Full-width `primary` strip above the header announcing the latest release. */ +export interface AnnouncementBarProps { children: React.ReactNode; linkLabel?: string; href?: string; } +export declare function AnnouncementBar(props: AnnouncementBarProps): JSX.Element; + +/** Sticky 60px site header with the walkerOS wordmark, primary nav, GitHub link and theme toggle. */ +export interface SiteHeaderProps { links?: { label: string; href: string }[]; theme?: 'dark' | 'light'; onToggleTheme?: () => void; githubHref?: string; homeHref?: string; } +export declare function SiteHeader(props: SiteHeaderProps): JSX.Element; + +/** The walkerOS call-to-action: a `primary` fill with `on-primary` text, or a flat `surface` secondary. */ +export interface ButtonProps { children: React.ReactNode; href?: string; onClick?: () => void; variant?: 'primary' | 'secondary'; arrow?: boolean; block?: boolean; className?: string; } +export declare function Button(props: ButtonProps): JSX.Element; + +/** A copy-to-clipboard chip for the CLI install command, set in `mono`. */ +export interface InstallCommandProps { command?: string; } +export declare function InstallCommand(props: InstallCommandProps): JSX.Element; + +/** Inline or stacked list of short claims, each led by a `primary` check. */ +export interface CheckListProps { items: React.ReactNode[]; layout?: 'inline' | 'stack'; align?: 'start' | 'center'; } +export declare function CheckList(props: CheckListProps): JSX.Element; + +/** Eyebrow, heading and optional lead — the opening of every section, or the hero when `level` is 1. */ +export interface SectionHeadingProps { title: React.ReactNode; eyebrow?: string; lead?: React.ReactNode; level?: 1 | 2; highlight?: React.ReactNode; align?: 'start' | 'center'; } +export declare function SectionHeading(props: SectionHeadingProps): JSX.Element; + +/** Numbered card naming one problem, in a three-up `auto-fit` grid (min 280px, gap `space-5`). */ +export interface ProblemCardProps { number: string; title: React.ReactNode; children: React.ReactNode; } +export declare function ProblemCard(props: ProblemCardProps): JSX.Element; + +/** A checked feature with title, one-sentence description and a `Docs →` link. */ +export interface FeatureItemProps { title: React.ReactNode; children: React.ReactNode; href?: string; linkLabel?: string; } +export declare function FeatureItem(props: FeatureItemProps): JSX.Element; + +/** A service tier: label, name, description, checked features and a full-width CTA. */ +export interface PlanCardProps { label: string; title: React.ReactNode; children: React.ReactNode; features?: React.ReactNode[]; cta?: { label: string; href?: string }; highlight?: boolean; } +export declare function PlanCard(props: PlanCardProps): JSX.Element; + +/** A native `
` disclosure for one FAQ question, stacked 12px apart (max 900px). */ +export interface FaqItemProps { question: React.ReactNode; children: React.ReactNode | React.ReactNode[]; open?: boolean; } +export declare function FaqItem(props: FaqItemProps): JSX.Element; + +/** Mono code token inside prose: attribute names, function calls, event names. */ +export interface InlineCodeProps { children: React.ReactNode; } +export declare function InlineCode(props: InlineCodeProps): JSX.Element; + +/** The fixed colour key for the five parts of a walkerOS event, as used in every product visualisation. */ +export interface EventLegendProps { parts?: Array<'entity' | 'action' | 'property' | 'context' | 'globals'>; } +export declare function EventLegend(props: EventLegendProps): JSX.Element; + +/** Footer on `bg-2` with the brand statement, four link columns and the copyright line. */ +export interface SiteFooterProps { statement?: React.ReactNode; columns?: { title: string; links: [string, string][] }[]; copyright?: React.ReactNode; } +export declare function SiteFooter(props: SiteFooterProps): JSX.Element; + +/** Hero demo: `data-elb` attributes are typed into an article teaser, the card lights up as each part is tagged, a cursor clicks it, and the events land in a live `walkerOS.events` table. */ +export interface HeroTaggingVizProps { playing?: boolean; speed?: number; startAt?: number; } +export declare function HeroTaggingViz(props: HeroTaggingVizProps): JSX.Element; + +/** Tagging feature demo: one `ArticleTeaser` tagged once, walked through atom → molecule → organisms → pages, showing how every instance inherits entity, action, properties, context and globals. */ +export interface ArticleTeaserTrackingProps { autoplay?: boolean; speed?: number; initialStep?: 1 | 2 | 3 | 4 | 5; } +export declare function ArticleTeaserTracking(props: ArticleTeaserTrackingProps): JSX.Element; + +/** Mapping feature demo: pick one `elb()` event and see the mapping rule and the exact call it produces in GA4, Meta Pixel, TikTok Pixel and Amplitude. */ +export interface DestinationMappingVizProps { initialEvent?: 0 | 1 | 2; caption?: string; } +export declare function DestinationMappingViz(props: DestinationMappingVizProps): JSX.Element; diff --git a/apps/explorer/design/components/src/viz.jsx b/apps/explorer/design/components/src/viz.jsx new file mode 100644 index 000000000..07e717765 --- /dev/null +++ b/apps/explorer/design/components/src/viz.jsx @@ -0,0 +1,457 @@ +/* walkerOS product visualisations — ported from the relaunch design + (Hero Tagging Viz, Article Teaser Tracking Animated, Destination Mapping Viz). + Visualisations stay dark in both themes: they paint with viz-* and event-* tokens, + plus a few fixed literals the design uses for alpha tints. */ +const React = window.React; +const h = React.createElement; +const { useState, useEffect, useRef } = React; + +// CSS text → React style object, so the design's inline styles carry over verbatim. +const cache = new Map(); +function st(css) { + let o = cache.get(css); + if (o) return o; + o = {}; + for (const decl of css.split(';')) { + const i = decl.indexOf(':'); + if (i < 0) continue; + const k = decl.slice(0, i).trim(); + if (!k) continue; + const key = k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + o[key] = decl.slice(i + 1).trim(); + } + if (cache.size < 2000) cache.set(css, o); + return o; +} + +const V = { + bg: 'var(--viz-bg)', code: 'var(--viz-code-bg)', border: 'var(--viz-border)', fg: 'var(--viz-fg)', fg2: 'var(--viz-fg-2)', + comment: 'var(--viz-comment)', ln: 'var(--viz-line-number)', surface: 'var(--viz-surface)', primary: 'var(--primary)', + entity: 'var(--event-entity)', action: 'var(--event-action)', prop: 'var(--event-property)', ctx: 'var(--event-context)', glob: 'var(--event-globals)', + tag: 'var(--viz-tag)', str: 'var(--viz-string)', num: 'var(--viz-number)', punct: 'var(--viz-punct)', +}; +const SANS = "font-family:var(--font-viz)"; +const MONO = "font-family:var(--font-viz-mono)"; +const Toks = ({ list }) => list.map((tk, i) => h('span', { key: i, style: st(`color:${tk.c}; background:${tk.bg || 'transparent'}; border-radius:3px`) }, tk.t)); + +/* ───────────── DestinationMappingViz ───────────── */ +const MCOL = { str: V.entity, key: V.glob, num: V.num, fn: V.action, p: V.punct, x: V.fg }; +const MHL = 'rgba(197,228,120,0.16)'; +function mtokens(line, hl) { + const out = []; + const re = /"[^"]*"|'[^']*'|\d+(?:\.\d+)?|[A-Za-z_]\w*|\s+|./g; + let m; + while ((m = re.exec(line))) { + const t = m[0], rest = line.slice(re.lastIndex); + const isKey = /^\s*:/.test(rest); + let c = MCOL.p, bg = 'transparent'; + if (/^["']/.test(t)) { c = isKey ? MCOL.key : MCOL.str; if (hl && t.slice(1, -1) === hl) bg = MHL; } + else if (/^\d/.test(t) || t === 'true' || t === 'false') c = MCOL.num; + else if (/^(gtag|fbq|elb|ttq|track|amplitude|revenue)$/.test(t)) c = MCOL.fn; + else if (/^[A-Za-z_]/.test(t)) c = isKey ? MCOL.key : MCOL.x; + else if (/^\s+$/.test(t)) c = MCOL.x; + out.push({ t, c, bg }); + } + return out; +} +const MD = { ga4: { name: 'GA4' }, meta: { name: 'Meta Pixel' }, amp: { name: 'Amplitude' }, tt: { name: 'TikTok Pixel' } }; +const MEVENTS = [ + { entity: 'product', action: 'view', + dests: [ + { ...MD.ga4, mapped: 'view_item', out: [`gtag('event', 'view_item', {`, ` currency: 'EUR', value: 420,`, ` items: [{ item_id: 'ers' }]`, `})`] }, + { ...MD.meta, mapped: 'ViewContent', out: [`fbq('track', 'ViewContent', {`, ` value: 420, currency: 'EUR',`, ` contents: [{ id: 'ers', quantity: 1 }],`, ` content_type: 'product'`, `})`] }, + { ...MD.tt, mapped: 'ViewContent', out: [`ttq.track('ViewContent', {`, ` content_id: 'ers', content_type: 'product',`, ` value: 420, currency: 'EUR'`, `})`] }, + { ...MD.amp, mapped: 'Product Viewed', out: [`amplitude.track('Product Viewed', {`, ` product_id: 'ers', price: 420, currency: 'EUR'`, `})`] }, + ] }, + { entity: 'lead', action: 'submit', + dests: [ + { ...MD.ga4, mapped: 'generate_lead', out: [`gtag('event', 'generate_lead', {`, ` value: 2500, currency: 'EUR'`, `})`] }, + { ...MD.meta, mapped: 'Lead', out: [`fbq('track', 'Lead', {`, ` value: 2500, currency: 'EUR'`, `})`] }, + { ...MD.tt, mapped: 'Contact', out: [`ttq.track('Contact', {`, ` value: 2500, currency: 'EUR'`, `})`] }, + { ...MD.amp, mapped: 'Lead Submitted', out: [`amplitude.track('Lead Submitted', {`, ` lead_type: 'demo', value: 2500, currency: 'EUR'`, `})`] }, + ] }, + { entity: 'order', action: 'complete', + dests: [ + { ...MD.ga4, mapped: 'purchase', out: [`gtag('event', 'purchase', {`, ` transaction_id: '0rd3r1d', value: 462,`, ` currency: 'EUR', items: […]`, `})`] }, + { ...MD.meta, mapped: 'Purchase', out: [`fbq('track', 'Purchase', {`, ` value: 462, currency: 'EUR',`, ` contents: […], content_type: 'product'`, `})`] }, + { ...MD.tt, mapped: 'CompletePayment', out: [`ttq.track('CompletePayment', {`, ` order_id: '0rd3r1d', value: 462, currency: 'EUR',`, ` contents: […], content_type: 'product'`, `})`] }, + { ...MD.amp, mapped: 'purchase', ruleText: '"order": { "complete": { "settings": { "revenue": {…} } } }', out: [`amplitude.revenue({`, ` productId: 'ers', price: 420, quantity: 1,`, ` revenueType: 'purchase', currency: 'EUR'`, `});`, `amplitude.track('order complete', { … })`] }, + ] }, +]; +const MSHORT = { + product: "elb('product view', { id: 'ers', price: 420, currency: 'EUR' })", + lead: "elb('lead submit', { type: 'demo', value: 2500, currency: 'EUR' })", + order: "elb('order complete', { id: '0rd3r1d', value: 462, currency: 'EUR' })", +}; + +function DestinationMappingViz(props) { + const [ev, setEv] = useState(props.initialEvent || 0); + const E = MEVENTS[ev]; + const entityName = E.entity + ' ' + E.action; + const caption = props.caption ?? '40+ destination adapters ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.'; + return h('div', { className: 'wos-viz', style: st(`${SANS}; color:${V.fg}; width:100%; background:#1c2638; border:1px solid #38455e; border-radius:14px; overflow:hidden`) }, + h('div', { style: st('padding:18px 20px 0; display:flex; flex-direction:column; gap:10px') }, + h('div', { style: st(`${MONO}; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:${V.fg2}`) }, 'Pick an event'), + h('div', { style: st('display:flex; flex-wrap:wrap; gap:8px') }, + MEVENTS.map((e, i) => { + const a = i === ev; + return h('button', { key: i, type: 'button', className: 'wos-viz-chip', onClick: () => setEv(i), 'aria-pressed': a, + style: st(`position:relative; display:flex; overflow:hidden; padding:0; border-radius:7px; border:1px solid ${a ? V.primary : '#38455e'}; background:${a ? 'rgba(1,181,226,0.08)' : 'transparent'}; ${MONO}; font-size:13px; cursor:pointer; transition:border-color .25s, background .25s`) }, + h('span', { style: st(`padding:7px 10px; color:${V.fg}; background:rgba(255,255,255,0.04)`) }, e.entity), + h('span', { style: st(`padding:7px 10px; color:${V.primary}`) }, e.action)); + }))), + h('div', { style: st(`margin:16px 20px 0; padding:10px 14px; background:${V.code}; border:1px solid #2f3a4d; border-radius:8px; overflow-x:auto; ${MONO}; font-size:12.5px; line-height:1.6; white-space:pre`) }, + h(Toks, { list: mtokens(MSHORT[E.entity], entityName) })), + h('div', { style: st('padding:18px 20px 20px; display:flex; flex-direction:column; gap:10px') }, + h('div', { style: st(`${MONO}; font-size:12.5px; font-style:italic; color:${V.comment}`) }, '// same event, four destinations, zero app code touched'), + h('div', { style: st('display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap:10px') }, + E.dests.map((d, i) => { + const rule = d.ruleText ? d.ruleText : `"${E.entity}": { "${E.action}": { "name": "${d.mapped}" } }`; + return h('div', { key: d.name, style: st(`min-width:0; background:${V.code}; border:1px solid #2f3a4d; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:8px`) }, + h('span', { style: st('font-size:14px; font-weight:600') }, d.name), + h('div', { style: st(`display:flex; gap:10px; align-items:baseline; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`) }, + h('span', { style: st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`) }, 'mapping'), + h('span', { style: st('white-space:pre') }, h(Toks, { list: mtokens(rule, d.mapped) }))), + h('div', { style: st(`display:flex; gap:10px; align-items:flex-start; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`) }, + h('span', { style: st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`) }, 'sends'), + h('div', { style: st('min-width:0') }, d.out.map((l, j) => h('div', { key: j, style: st('white-space:pre') }, h(Toks, { list: mtokens(l, d.mapped) })))))); + }))), + caption ? h('div', { style: st(`border-top:1px solid #38455e; padding:18px 20px 20px; font-size:15px; line-height:1.6; color:${V.punct}; text-wrap:pretty`) }, caption) : null); +} + +/* ───────────── HeroTaggingViz ───────────── */ +const ARTICLES = [ + { kicker: 'Tech', cat: 'tech', img: 'bug', title: 'Return of the Bug' }, + { kicker: 'Tech', cat: 'tech', title: 'The quiet return of RSS' }, + { kicker: 'Food', cat: 'food', title: 'Why sourdough came back' }, +]; +const HL_ = 11500; +const P = { siS: 0, siE: 500, aS: 900, aE: 1500, dS: 1800, dE: 2600, eS: 2900, eE: 3600, bS: 3900, bE: 4700, cS: 5000, cE: 5800, imp: 6200, mS: 6900, mE: 7700, click: 7850, row: 8000, fo: 10600, soS: 10800, soE: 11400 }; +const HC = { tag: V.tag, attr: V.glob, p: V.punct, str: V.str, txt: V.fg }; +const MAX_ROWS = 24; +const tk = (t, c) => ({ t, c, txt: true, caret: false }); +const len = a => a.reduce((s, x) => s + x.t.length, 0); +const trunc = (a, n) => { const o = []; let l = n; for (const x of a) { if (l <= 0) break; const s = x.t.slice(0, l); o.push({ ...x, t: s }); l -= s.length; } return o; }; +const clamp = x => Math.max(0, Math.min(1, x)); +const ease = x => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; + +function Thumb({ art }) { + const abs = { viewBox: '0 0 112 86', preserveAspectRatio: 'xMidYMid slice', style: st('position:absolute; inset:0; width:100%; height:100%'), 'aria-hidden': true }; + if (art.img === 'bug') return h('svg', abs, + h('rect', { width: 112, height: 86, fill: '#dfe8cf' }), h('ellipse', { cx: 56, cy: 66, rx: 30, ry: 6, fill: '#bccaa6' }), + h('g', { stroke: '#3b3232', strokeWidth: 2.4, strokeLinecap: 'round', fill: 'none' }, + ['M36 46 L28 42', 'M35 54 L27 56', 'M38 61 L31 66', 'M76 46 L84 42', 'M77 54 L85 56', 'M74 61 L81 66', 'M51 25 Q47 17 42 16', 'M61 25 Q65 17 70 16'].map((d, i) => h('path', { key: i, d }))), + h('circle', { cx: 42, cy: 16, r: 2, fill: '#3b3232' }), h('circle', { cx: 70, cy: 16, r: 2, fill: '#3b3232' }), + h('ellipse', { cx: 56, cy: 30, rx: 10, ry: 7.5, fill: '#3b3232' }), h('circle', { cx: 52, cy: 29, r: 1.6, fill: '#ffffff' }), h('circle', { cx: 60, cy: 29, r: 1.6, fill: '#ffffff' }), + h('ellipse', { cx: 56, cy: 50, rx: 21, ry: 18, fill: '#d9534a' }), h('path', { d: 'M56 33 L56 68', stroke: '#3b3232', strokeWidth: 2 }), + h('circle', { cx: 47, cy: 44, r: 3.4, fill: '#3b3232' }), h('circle', { cx: 65, cy: 44, r: 3.4, fill: '#3b3232' }), + h('circle', { cx: 45, cy: 56, r: 2.8, fill: '#3b3232' }), h('circle', { cx: 67, cy: 56, r: 2.8, fill: '#3b3232' }), + h('path', { d: 'M44 38 Q48 35 52 36', stroke: '#ef8a7f', strokeWidth: 2, fill: 'none', strokeLinecap: 'round' })); + if (art.cat === 'tech') return h('svg', abs, + h('rect', { width: 112, height: 86, fill: '#334058' }), h('rect', { x: 38, y: 25, width: 36, height: 36, rx: 8, fill: '#f08a24' }), + h('circle', { cx: 47, cy: 52, r: 3.2, fill: '#ffffff' }), + h('path', { d: 'M45 42 A10 10 0 0 1 57 54', stroke: '#ffffff', strokeWidth: 3.5, fill: 'none', strokeLinecap: 'round' }), + h('path', { d: 'M45 34.5 A17.5 17.5 0 0 1 64.5 54', stroke: '#ffffff', strokeWidth: 3.5, fill: 'none', strokeLinecap: 'round' })); + return h('svg', abs, + h('rect', { width: 112, height: 86, fill: '#eadcc6' }), h('ellipse', { cx: 56, cy: 62, rx: 44, ry: 10, fill: '#b07d4f' }), + h('ellipse', { cx: 56, cy: 50, rx: 31, ry: 19, fill: '#c3834a' }), h('ellipse', { cx: 56, cy: 45, rx: 25, ry: 13, fill: '#d99d5e' }), + h('path', { d: 'M40 44 Q56 35 72 44', stroke: '#f3dfb8', strokeWidth: 2.2, fill: 'none', strokeLinecap: 'round' }), + h('path', { d: 'M44 51 Q56 45 68 51', stroke: '#f3dfb8', strokeWidth: 2.2, fill: 'none', strokeLinecap: 'round' })); +} + +const Pill = ({ bg, children }) => h('span', { style: st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px`) }, children); + +function HeroTaggingViz(props) { + const playing = props.playing ?? true, speed = props.speed ?? 1; + const [t, setT] = useState(props.startAt || 0); + const el = useRef(props.startAt || 0), wall0 = useRef(Date.now()), cfg = useRef({ playing, speed }); + cfg.current = { playing, speed }; + useEffect(() => { + let last = performance.now(), raf; + const loop = now => { + const dt = Math.min(64, now - last); last = now; + if (cfg.current.playing) el.current += dt * cfg.current.speed; + setT(el.current); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + const k = Math.floor(t / HL_), ph = t - k * HL_, art = ARTICLES[k % 3]; + const A = [tk('data-elb', HC.attr), tk('=', HC.p), tk('"article"', HC.str)]; + const B = [tk(' ', HC.p), tk('data-elb-article', HC.attr), tk('=', HC.p), tk('"title:#innerText"', HC.str)]; + const E = [tk(' ', HC.p), tk('data-elb-article', HC.attr), tk('=', HC.p), tk('"category:#innerText"', HC.str)]; + const Cc = [tk(' ', HC.p), tk('data-elbaction', HC.attr), tk('=', HC.p), tk('"click:open"', HC.str)]; + const D = [tk('data-elbaction', HC.attr), tk('=', HC.p), tk('"impression"', HC.str)]; + const typed = (a, s, e) => Math.round(clamp((ph - s) / (e - s)) * len(a)); + const na = typed(A, P.aS, P.aE), nb = typed(B, P.bS, P.bE), nc = typed(Cc, P.cS, P.cE), nd = typed(D, P.dS, P.dE), ne = typed(E, P.eS, P.eE); + let caret = null; + if (ph >= P.aS - 400 && ph < P.dS - 300) caret = 'a'; + else if (ph >= P.dS - 300 && ph < P.eS - 100) caret = 'd'; + else if (ph >= P.eS - 100 && ph < P.bS - 100) caret = 'e'; + else if (ph >= P.bS - 100 && ph < P.cS - 100) caret = 'b'; + else if (ph >= P.cS - 100 && ph < P.mS) caret = 'c'; + const typing = (ph >= P.aS && ph <= P.aE) || (ph >= P.bS && ph <= P.bE) || (ph >= P.cS && ph <= P.cE) || (ph >= P.dS && ph <= P.dE) || (ph >= P.eS && ph <= P.eE); + const car = { caret: true, txt: false, o: typing || ph % 900 < 500 ? 1 : 0 }; + const wc = (a, on) => on ? [...a, car] : a; + const raw = [ + { toks: [tk('= P.aS - 400 && { toks: [tk(' ', HC.p), ...wc(trunc(A, na), caret === 'a')], on: true }, + ph >= P.dS - 300 && { toks: [tk(' ', HC.p), ...wc(trunc(D, nd), caret === 'd')], on: true }, + { toks: [tk('>', HC.tag)] }, + { toks: [tk(' ', HC.p), tk('', HC.tag)] }, + { toks: [tk(' ', HC.p), tk('', HC.tag), tk(art.kicker, HC.txt), tk('', HC.tag)], on: ph >= P.eS - 100 }, + { toks: [tk(' ', HC.p), tk('', HC.tag), tk(art.title, HC.txt), tk('', HC.tag)], on: ph >= P.bS - 100 }, + { toks: [tk(' ', HC.p), tk('', HC.tag), tk('Open →', HC.txt), tk('', HC.tag)], on: ph >= P.cS - 100 }, + { toks: [tk('', HC.tag)] }, + ]; + const lines = raw.filter(Boolean).map((l, i) => ({ ...l, n: i + 1 })); + const fade = ph < 250 ? ph / 250 : ph > P.fo ? 1 - clamp((ph - P.fo) / (HL_ - P.fo - 150)) : 1; + const so = ease(clamp((ph - P.soS) / (P.soE - P.soS))), si = ease(clamp((ph - P.siS) / (P.siE - P.siS))); + const cardOff = ph >= P.soS ? -so * 190 : (1 - si) * 190; + const impOn = ph >= P.dE, entityOn = ph >= P.aE, propsOn = ph >= P.bE, catOn = ph >= P.eE, actionOn = ph >= P.cE; + const m = ease(clamp((ph - P.mS) / (P.mE - P.mS))); + const press = ph >= P.click && ph < P.click + 180; + const rp = clamp((ph - P.click) / 550); + const curOp = ph >= P.mS - 200 && ph < P.fo + 200 ? clamp((ph - (P.mS - 200)) / 200) * (ph > P.fo ? 1 - clamp((ph - P.fo) / 200) : 1) : 0; + const propGlow = '0 0 10px 1px rgba(240,113,127,0.55)'; + + const rows = [], evs = []; + for (let j = k; j >= 0 && evs.length < MAX_ROWS; j--) { + if (j < k || ph >= P.row) evs.push({ j, at: P.row, action: 'open' }); + if (j < k || ph >= P.imp) evs.push({ j, at: P.imp, action: 'impression' }); + } + for (const e of evs.slice(0, MAX_ROWS)) { + const a = ARTICLES[e.j % 3], age = e.j === k ? ph - e.at : 1e4; + rows.push({ key: e.j + e.action, n: rows.length + 1, name: 'article ' + e.action, title: a.title, cat: a.kicker.toUpperCase(), + bg: `rgba(197,228,120,${(0.14 * (1 - clamp(age / 1600))).toFixed(3)})`, op: clamp(age / 300), ty: `translateX(${((1 - ease(clamp(age / 500))) * -80).toFixed(1)}px)` }); + } + const widths = ['72%', '58%', '80%', '50%', '66%', '62%']; + const skeletons = widths.slice(0, Math.max(0, 6 - rows.length)).map((w, i) => ({ w, n: rows.length + i + 1 })); + const grid = 'display:grid; grid-template-columns:38px 166px minmax(250px,1fr)'; + const cellR = 'border-right:1px solid ' + V.surface; + + return h('div', { className: 'wos-viz', style: st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border:1px solid #2f3a4d; border-radius:16px; padding:16px; display:flex; flex-wrap:wrap; gap:16px; align-items:stretch`) }, + // code + rendered teaser + h('div', { style: st(`flex:1.2 1 520px; min-width:0; background:${V.code}; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`) }, + h('div', { style: st('display:flex; align-items:center; gap:14px; padding:12px 16px; border-bottom:1px solid #243044') }, + h('div', { style: st('display:flex; gap:7px') }, [0, 1, 2].map(i => h('span', { key: i, style: st(`width:10px; height:10px; border-radius:50%; background:${V.border}`) }))), + h('span', { style: st(`${MONO}; font-size:12px; color:${V.primary}`) }, 'ArticleTeaser.html')), + h('div', { style: st(`padding:12px 0 14px; ${MONO}; font-size:13px; line-height:1.95; min-height:268px; opacity:${fade}`) }, + lines.map(ln => h('div', { key: ln.n, style: st(`display:flex; background:${ln.on ? 'rgba(197,228,120,0.07)' : 'transparent'}`) }, + h('span', { style: st(`width:3px; flex:none; background:${ln.on ? V.entity : 'transparent'}`) }), + h('span', { style: st(`width:30px; flex:none; text-align:right; padding-right:14px; color:${V.ln}`) }, ln.n), + h('span', { style: st('min-width:0; white-space:pre-wrap; word-break:break-all; padding-right:16px') }, + ln.toks.map((x, i) => x.caret + ? h('span', { key: i, style: st(`display:inline-block; width:2px; height:1.15em; margin-left:1px; vertical-align:-0.2em; background:${V.primary}; opacity:${x.o}`) }) + : h('span', { key: i, style: st(`color:${x.c}`) }, x.t)))))), + h('div', { style: st('flex:1; position:relative; overflow:hidden; border-top:1px solid #243044; background:repeating-linear-gradient(45deg, #172030 0 8px, #1c2535 8px 16px); padding:34px 24px 28px; display:flex; justify-content:center; align-items:center') }, + h('div', { style: st(`position:relative; width:100%; max-width:430px; display:flex; gap:14px; background:${V.surface}; border:1.5px solid ${entityOn ? V.entity : '#38455e'}; border-radius:10px; padding:12px; opacity:${clamp(ph / 250) * (1 - so)}; transform:translateY(${cardOff.toFixed(1)}px)`) }, + h('div', { style: st('position:absolute; top:-10px; left:12px; display:flex; gap:6px') }, + entityOn ? h(Pill, { bg: V.entity }, 'article') : null, + impOn ? h(Pill, { bg: V.action }, 'impression') : null), + h('div', { style: st('flex:none; width:112px; min-height:86px; border-radius:6px; overflow:hidden; position:relative; background:#2e3a50') }, h(Thumb, { art })), + h('div', { style: st('flex:1; min-width:0; display:flex; flex-direction:column; gap:4px') }, + h('span', { style: st(`align-self:flex-start; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; color:${V.fg2}; border-radius:3px; padding:0 3px; margin-left:-3px; outline:1.5px solid ${catOn ? V.prop : 'transparent'}; box-shadow:${catOn ? propGlow : 'none'}`) }, art.kicker), + h('span', { style: st('font-size:15px; font-weight:600; line-height:1.3; text-wrap:pretty') }, + h('span', { style: st(`border-radius:3px; outline:1.5px solid ${propsOn ? V.prop : 'transparent'}; outline-offset:1px; box-shadow:${propsOn ? propGlow : 'none'}`) }, art.title)), + h('div', { style: st('margin-top:auto; padding-top:6px; display:flex; align-items:center; gap:8px') }, + h('span', { style: st(`position:relative; font-size:13px; font-weight:500; color:${V.primary}; padding:1px 5px; margin-left:-5px; border-radius:5px; outline:1.5px solid ${actionOn ? V.action : 'transparent'}`) }, 'Open →', + h('span', { style: st(`position:absolute; left:${(22 + (1 - m) * 190).toFixed(1)}px; top:${(11 - (1 - m) * 80).toFixed(1)}px; width:22px; height:22px; margin:-11px 0 0 -11px; pointer-events:none; opacity:${curOp}`) }, + h('span', { style: st(`position:absolute; inset:0; border-radius:50%; border:2px solid ${V.entity}; opacity:${ph >= P.click ? ((1 - rp) * 0.9).toFixed(3) : 0}; transform:scale(${(1 + rp * 1.8).toFixed(3)})`) }), + h('span', { style: st(`position:absolute; inset:0; border-radius:50%; background:rgba(255,255,255,0.22); border:2px solid rgba(255,255,255,0.9); box-shadow:0 4px 14px rgba(0,0,0,0.4); transform:scale(${press ? 0.8 : 1})`) })))))))), + // live events table + h('div', { style: st(`flex:1 1 400px; min-width:0; background:#1e2839; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`) }, + h('div', { style: st(`display:flex; justify-content:space-between; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid ${V.surface}; ${MONO}; font-size:12px`) }, + h('span', { style: st(`color:${V.fg2}`) }, h('span', { style: st(`color:${V.action}`) }, 'SELECT'), ' * ', h('span', { style: st(`color:${V.action}`) }, 'FROM'), ' ', h('span', { style: st(`color:${V.fg}`) }, 'walkerOS.events')), + h('span', { style: st(`display:flex; align-items:center; gap:7px; color:${V.entity}`) }, h('span', { style: st(`width:7px; height:7px; border-radius:50%; background:${V.entity}`) }), 'live')), + h('div', { style: st('flex:1; position:relative; min-height:300px') }, + h('div', { style: st(`position:absolute; inset:0; overflow-x:auto; overflow-y:hidden; ${MONO}; font-size:12px`) }, + h('div', { style: st('min-width:454px') }, + h('div', { style: st(`${grid}; background:#243044; border-bottom:1px solid ${V.border}`) }, + h('span', { style: st(`padding:8px 10px; ${cellR}; color:${V.comment}; font-size:11px; text-align:right`) }, 'Row'), + h('span', { style: st(`padding:8px 10px; ${cellR}; display:flex; flex-direction:column; gap:1px`) }, h('span', { style: st(`color:${V.fg}`) }, 'name'), h('span', { style: st(`color:${V.comment}; font-size:10px`) }, 'STRING')), + h('span', { style: st('padding:8px 10px; display:flex; flex-direction:column; gap:1px') }, h('span', { style: st(`color:${V.fg}`) }, 'data'), h('span', { style: st(`color:${V.comment}; font-size:10px`) }, 'JSON'))), + rows.map(r => h('div', { key: r.key, style: st(`${grid}; border-bottom:1px solid ${V.surface}; background:${r.bg}; opacity:${r.op}; transform:${r.ty}; line-height:1.4`) }, + h('span', { style: st(`padding:10px; ${cellR}; color:${V.comment}; text-align:right`) }, r.n), + h('span', { style: st(`padding:10px; ${cellR}; color:${V.fg}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`) }, r.name), + h('span', { style: st(`padding:10px; color:${V.punct}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`) }, + '{"', h('span', { style: st(`color:${V.glob}`) }, 'title'), '":"', h('span', { style: st(`color:${V.str}`) }, r.title), '","', + h('span', { style: st(`color:${V.glob}`) }, 'category'), '":"', h('span', { style: st(`color:${V.str}`) }, r.cat), '"}'))), + skeletons.map(s => h('div', { key: 's' + s.n, style: st(`${grid}; border-bottom:1px solid ${V.surface}; line-height:1.4`) }, + h('span', { style: st(`padding:10px; ${cellR}; color:${V.ln}; text-align:right`) }, s.n), + h('span', { style: st(`padding:10px; ${cellR}; display:flex; align-items:center`) }, h('span', { style: st(`height:8px; width:75%; border-radius:4px; background:${V.surface}`) })), + h('span', { style: st('padding:10px; display:flex; align-items:center') }, h('span', { style: st(`height:8px; width:${s.w}; border-radius:4px; background:#253145`) }))))))))); +} + +/* ───────────── ArticleTeaserTracking ───────────── */ +const AC = { kw: V.action, tag: V.tag, at: V.glob, p: V.punct, s: V.str, x: V.fg }; +const TY = { entity: [V.entity, 'rgba(197,228,120,'], action: [V.action, 'rgba(199,146,234,'], prop: [V.prop, 'rgba(240,113,127,'], ctx: [V.ctx, 'rgba(242,166,90,'], glob: [V.glob, 'rgba(127,196,234,'] }; +const at = (t, c, hl) => ({ t, c, h: !!hl }); +const F = (step, name, kind) => ({ step, file: true, name, kind }); +const L = (step, toks, fx, type) => ({ step, toks, fx, type }); +function buildItems() { + const it = [ + F(1, 'Link.tsx', 'atom'), + L(1, [at('export const', AC.kw), at(' Link ', AC.x), at('= ({ href, children }) => (', AC.p)]), + L(1, [at(' '), at('', AC.tag)], 'action', 'action'), + L(1, [at(' '), at('{children}', AC.p)]), + L(1, [at(' '), at('', AC.tag)]), + L(1, [at(');', AC.p)]), + F(2, 'ArticleTeaser.tsx', 'molecule'), + L(2, [at('export const', AC.kw), at(' ArticleTeaser ', AC.x), at('= ({', AC.p)]), + L(2, [at(' title, category, image, url, position,', AC.p)]), + L(2, [at('}) => (', AC.p)]), + L(2, [at(' '), at('', AC.tag)], 'pos', 'prop'), + L(2, [at(' '), at('', AC.tag)], 'img'), + L(2, [at(' '), at('', AC.tag), at('{category}', AC.p), at('', AC.tag)], 'cat', 'prop'), + L(2, [at(' '), at('', AC.tag), at('{title}', AC.p), at('', AC.tag)], 'title', 'prop'), + L(2, [at(' '), at('', AC.tag), at('Open →', AC.x), at('', AC.tag)]), + L(2, [at(' '), at('', AC.tag)]), + L(2, [at(');', AC.p)]), + ]; + [[3, 'HomeFeed.tsx', 'homefeed', 'stories', 'home'], [4, 'RelatedStories.tsx', 'related', 'related', 'related']].forEach(([s, f, ctx, arr, fx]) => it.push( + F(s, f, 'organism'), + L(s, [at('', AC.tag)], fx, 'ctx'), + L(s, [at(' {' + arr + '.map((s, i) => ', AC.p), at('', AC.tag), at(')}', AC.p)]), + L(s, [at('', AC.tag)]), + )); + [['HomePage.tsx', 'homepage', 'HomeFeed', 'gHome'], ['ArticlePage.tsx', 'article', 'RelatedStories', 'gArt']].forEach(([file, pt, org, fx]) => it.push( + F(5, file, 'template'), + L(5, [at('', AC.tag)], fx, 'glob'), + L(5, [at(' '), at('<' + org + ' />', AC.tag)]), + L(5, [at('', AC.tag)]), + )); + return it; +} +const ITEMS = buildItems(); +const HOME = [{ kicker: 'Tech', title: 'Return of the Bug' }, { kicker: 'Tech', title: 'The quiet return of RSS' }, { kicker: 'Food', title: 'Why sourdough came back' }]; +const RELATED = [{ kicker: 'Tech', title: 'The quiet return of RSS' }, { kicker: 'Science', title: 'A year on the ice shelf' }, { kicker: 'Culture', title: 'Small venues, big nights' }]; +const STEPS = [[1, 'Atom'], [2, 'Molecule'], [3, 'HomeFeed'], [4, 'RelatedStories'], [5, 'Pages']]; +const CAPTIONS = { + 1: 'Atom. Link carries the click:open action wherever it is rendered.', + 2: 'Molecule. ArticleTeaser makes each card an article entity and reads category and title from its elements.', + 3: 'Organism. HomeFeed wraps three teasers and adds list:homefeed context to their events.', + 4: 'Organism. RelatedStories reuses the same ArticleTeaser and adds list:related context.', + 5: 'Pages. Each page template sets pagetype as a global, which is added to every event on that page.', +}; +const aglow = rgba => '0 0 10px 1px ' + rgba + '0.5)'; + +class ArticleTeaserTracking extends React.Component { + constructor(p) { + super(p); + this.state = { n: p.initialStep ? this.stepEnd(p.initialStep) : 0 }; + this.codeRef = React.createRef(); + } + get auto() { return this.props.autoplay ?? true; } + get speed() { return this.props.speed ?? 1; } + stepEnd(k) { let last = ITEMS.findIndex(x => x.step === k); while (ITEMS[last + 1] && ITEMS[last + 1].step === k) last++; return last + 1; } + componentDidMount() { + const go = () => this.schedule(800); + const el = this.codeRef.current; + if (!el || !window.IntersectionObserver) return go(); + this.io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) { this.io.disconnect(); go(); } }, { threshold: 0.35 }); + this.io.observe(el); + } + componentWillUnmount() { clearTimeout(this.tm); clearTimeout(this.st); this.io && this.io.disconnect(); } + componentDidUpdate(pp) { if (pp.autoplay !== this.props.autoplay && this.auto) this.schedule(500); } + scrollToCurrent(n) { + clearTimeout(this.st); + this.st = setTimeout(() => { + const el = this.codeRef.current; if (!el) return; + const tgt = el.querySelector('[data-ln="' + (n - 1) + '"]'); + if (!tgt) { el.scrollTo({ top: 0, behavior: 'smooth' }); return; } + const y = tgt.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop; + el.scrollTo({ top: Math.max(0, y - el.clientHeight / 2), behavior: 'smooth' }); + }, 400); + } + setN(n) { this.setState({ n }); this.scrollToCurrent(n); } + schedule(ms) { clearTimeout(this.tm); this.tm = setTimeout(() => this.advance(), ms / this.speed); } + advance() { + if (!this.auto) return; + const n = this.state.n; + if (n >= ITEMS.length) return; + const s = ITEMS[n].step; + if (ITEMS[n].file && (n === 0 || ITEMS[n - 1].step !== s)) { this.setN(n + 1); this.schedule(1300); return; } + let j = n; + while (j < ITEMS.length && ITEMS[j].step === s && !ITEMS[j].fx) j++; + if (j < ITEMS.length && ITEMS[j].step === s) j++; + this.setN(j); + const nx = ITEMS[j]; + if (!nx) return; + this.schedule(1700 + (nx.step !== s ? 1600 : 0)); + } + jump(k) { + const first = ITEMS.findIndex(x => x.step === k); + if (this.auto) { this.setN(first); this.schedule(250); } else this.setN(this.stepEnd(k)); + } + render() { + const n = Math.min(this.state.n, ITEMS.length); + const visStep = n ? ITEMS[n - 1].step : 0; + const cur = Math.max(1, visStep); + const seen = new Set(ITEMS.slice(0, n).map(x => x.fx).filter(Boolean)); + const f = {}; ['action', 'entity', 'pos', 'img', 'cat', 'title', 'home', 'related', 'gHome', 'gArt'].forEach(x => f[x] = seen.has(x)); + let num = 0, firstFile = true; + const lines = ITEMS.map((it, i) => { + const vis = it.step <= visStep, on = i < n, isCur = i === n - 1 && !!it.fx, dim = vis && it.step < cur; + const wrap = st(`overflow:hidden; max-height:${vis ? (it.file ? '44px' : '24px') : '0px'}; opacity:${vis ? (dim ? 0.45 : 1) : 0}; transition:max-height .35s ease, opacity .35s ease`); + if (it.file) { + num = 0; + const sep = firstFile ? 'transparent' : V.surface; firstFile = false; + return h('div', { key: i, 'data-ln': i, style: wrap }, + h('div', { style: st(`padding:14px 16px 4px 19px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid ${sep}`) }, + h('span', { style: st(`color:${V.primary}`) }, it.name), h('span', { style: st(`color:${V.comment}`) }, it.kind))); + } + num++; + const ty = it.type && TY[it.type]; + return h('div', { key: i, 'data-ln': i, style: wrap }, + h('div', { style: st(`display:flex; align-items:stretch; background:${isCur ? (ty ? ty[1] + '0.10)' : 'rgba(255,255,255,0.04)') : 'transparent'}; transition:background .35s`) }, + h('span', { style: st(`width:3px; flex:none; background:${ty && on ? ty[0] : 'transparent'}; transition:background .35s`) }), + h('span', { style: st(`width:26px; flex:none; text-align:right; padding-right:12px; color:${V.ln}; line-height:1.8`) }, num), + h('span', { style: st('white-space:pre; line-height:1.8; padding-right:16px') }, + it.toks.map((k, j) => h('span', { key: j, style: st(`color:${k.c || AC.p}; background:${k.h && ty && on ? ty[1] + '0.16)' : 'transparent'}; border-radius:3px; transition:background .35s`) }, k.t))))); + }); + const card = (c, i) => h('div', { key: i, style: st(`position:relative; min-height:224px; border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:8px; border:1.5px ${f.entity ? 'solid' : 'dashed'} ${f.entity ? V.entity : '#2b374c'}; background:${f.entity ? V.surface : 'transparent'}; transition:all .45s ease`) }, + h('span', { style: st('position:absolute; top:-10px; left:10px; display:flex; gap:4px') }, + h('span', { style: st(`padding:1px 7px; border-radius:4px; background:${V.entity}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${f.entity ? 1 : 0}; transition:opacity .4s`) }, 'article'), + h('span', { style: st(`padding:0 6px; border-radius:4px; border:1.5px solid ${V.prop}; background:#1a2232; color:${V.prop}; ${MONO}; font-size:10.5px; line-height:16px; opacity:${f.pos ? 1 : 0}; transition:opacity .4s`) }, i + 1)), + h('div', { style: st(`height:86px; flex:none; border-radius:6px; overflow:hidden; position:relative; background:repeating-linear-gradient(45deg, #2e3a50 0 6px, #323f56 6px 12px); display:flex; align-items:center; justify-content:center; ${MONO}; font-size:10.5px; color:${V.comment}; opacity:${f.img ? 1 : 0}; transition:opacity .45s`) }, 'photo'), + h('span', { style: st(`align-self:flex-start; padding:1px 6px; border-radius:4px; background:#334058; color:#c3c9d9; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; opacity:${f.cat ? 1 : 0}; outline:1.5px solid ${f.cat ? V.prop : 'transparent'}; box-shadow:${f.cat ? aglow(TY.prop[1]) : 'none'}; transition:all .45s`) }, c.kicker), + h('span', { style: st(`align-self:flex-start; font-size:14px; font-weight:600; line-height:1.3; text-wrap:pretty; border-radius:3px; outline:1.5px solid ${f.title ? V.prop : 'transparent'}; outline-offset:2px; box-shadow:${f.title ? aglow(TY.prop[1]) : 'none'}; opacity:${f.title ? 1 : 0}; transition:all .45s`) }, c.title), + h('span', { style: st(`margin-top:auto; align-self:flex-start; padding:2px 6px; margin-left:-6px; border-radius:4px; font-size:13px; font-weight:500; color:${V.primary}; outline:1.5px solid ${f.action ? V.action : 'transparent'}; box-shadow:${f.action ? aglow(TY.action[1]) : 'none'}; opacity:${f.action ? 1 : 0}; transition:all .45s`) }, 'Open →')); + const pill = (bg, txt, op) => h('span', { style: st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${op}; transition:opacity .5s`) }, txt); + const group = (on, label, listOn, header, ctx, cards) => + h('div', { style: st(`position:relative; border-radius:16px; padding:12px; border:1.5px dashed ${on ? V.glob : 'transparent'}; background:${on ? 'rgba(127,196,234,0.04)' : 'transparent'}; transition:all .5s`) }, + h('span', { style: st('position:absolute; top:-10px; left:14px') }, pill(V.glob, label, on ? 1 : 0)), + h('div', { style: st(`border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:16px; border:1.5px solid ${listOn ? V.ctx : '#243044'}; background:${listOn ? 'rgba(242,166,90,0.05)' : 'transparent'}; transition:all .5s`) }, + h('div', { style: st(`display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; min-height:19px; opacity:${listOn ? 1 : 0}; transition:opacity .5s`) }, header, pill(V.ctx, ctx, 1)), + h('div', { style: st('display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:12px') }, cards.map(card)))); + const legend = [['entity', V.entity], ['action', V.action], ['property', V.prop], ['context', V.ctx], ['globals', V.glob]]; + + return h('div', { className: 'wos-viz', style: st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border-radius:16px; padding:24px; display:flex; flex-direction:column; gap:18px`) }, + h('div', { style: st('display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px 20px') }, + h('div', { style: st('display:flex; flex-wrap:wrap; gap:8px') }, STEPS.map(([k, label]) => { + const a = k === cur, done = k < cur; + return h('button', { key: k, type: 'button', onClick: () => this.jump(k), 'aria-pressed': a, + style: st(`display:flex; align-items:center; gap:8px; padding:6px 12px 6px 6px; border-radius:999px; border:1px solid ${a ? V.entity : V.border}; background:${a ? 'rgba(197,228,120,0.10)' : 'transparent'}; color:${a ? V.fg : V.fg2}; ${SANS}; font-size:13px; font-weight:500; cursor:pointer; transition:all .3s`) }, + h('span', { style: st(`width:20px; height:20px; border-radius:50%; background:${a ? V.entity : done ? V.comment : V.border}; color:#1a2232; font-weight:600; font-size:11px; line-height:20px; text-align:center; transition:all .3s`) }, k), + h('span', null, label)); + })), + h('div', { style: st(`display:flex; flex-wrap:wrap; gap:14px; ${MONO}; font-size:11px; color:${V.fg2}`) }, + legend.map(([n, c]) => h('span', { key: n, style: st('display:flex; align-items:center; gap:6px') }, h('span', { style: st(`width:9px; height:9px; border-radius:2px; background:${c}`) }), n)))), + h('div', { style: st('font-size:15px; line-height:1.5; color:#c3c9d9; min-height:23px; text-wrap:pretty') }, CAPTIONS[cur]), + h('div', { style: st('display:flex; flex-wrap:wrap; gap:16px; align-items:stretch') }, + h('div', { style: st(`flex:1 1 380px; min-width:0; align-self:flex-start; position:sticky; top:16px; background:${V.code}; border:1px solid ${V.border}; border-radius:12px; overflow:hidden; padding:4px 0 10px`) }, + h('div', { ref: this.codeRef, style: st(`position:relative; overflow:auto; max-height:min(640px, calc(100vh - 48px)); ${MONO}; font-size:12px`) }, lines)), + h('div', { style: st('flex:1.1 1 420px; min-width:0; display:flex; flex-direction:column; gap:24px; padding-top:10px; align-self:flex-start') }, + group(f.gHome, 'pagetype:homepage', f.home, + h('span', { style: st(`${MONO}; font-size:12px; color:${V.fg2}`) }, ' · homepage'), 'list:homefeed', HOME), + group(f.gArt, 'pagetype:article', f.related, + h('span', { style: st('display:flex; align-items:center; gap:10px') }, h('span', { style: st(`${MONO}; font-size:12px; color:${V.fg2}`) }, ' · article page'), h('span', { style: st('font-size:14px; font-weight:600') }, 'Related stories')), + 'list:related', RELATED)))); + } +} + +Object.assign(window.WalkerOS = window.WalkerOS || {}, { HeroTaggingViz, ArticleTeaserTracking, DestinationMappingViz }); diff --git a/apps/explorer/design/tokens.json b/apps/explorer/design/tokens.json new file mode 100644 index 000000000..d8361c563 --- /dev/null +++ b/apps/explorer/design/tokens.json @@ -0,0 +1,984 @@ +{ + "name": "walkerOS", + "version": 1, + "color": { + "themes": [ + { + "id": "dark", + "name": "Dark" + }, + { + "id": "light", + "name": "Light" + } + ], + "tokens": [ + { + "name": "bg", + "value": { + "dark": "#111827", + "light": "#ffffff" + }, + "usage": "Page background (hero, features, FAQ, closing CTA). Default ground for `fg`, `fg-2`, `link`." + }, + { + "name": "bg-2", + "value": { + "dark": "#151e2e", + "light": "#f6f7f9" + }, + "usage": "Alternating section band (Why, More features, Plans) and footer. Switch between `bg` and `bg-2` to separate sections, always with a top `border` rule." + }, + { + "name": "surface", + "value": { + "dark": "#1a2232", + "light": "#ffffff" + }, + "usage": "Cards, plan tiers, FAQ items, the install-command chip and secondary buttons." + }, + { + "name": "surface-2", + "value": { + "dark": "#222b3c", + "light": "#f1f3f6" + }, + "usage": "Inline code background, icon-button hover." + }, + { + "name": "border", + "value": { + "dark": "#2f3a4d", + "light": "#e3e6eb" + }, + "usage": "Hairline for section rules, header underline, card and input outlines. Decorative only; it does not meet 3:1, so never rely on it alone to mark a control." + }, + { + "name": "fg", + "value": { + "dark": "#ffffff", + "light": "#111827" + }, + "usage": "Headings and body copy on `bg`, `bg-2`, `surface`, `surface-2` (14:1+ both themes)." + }, + { + "name": "fg-2", + "value": { + "dark": "#9aa3b2", + "light": "#4b5563" + }, + "usage": "Secondary copy: leads, card descriptions, list items, footer links. 5.5:1+ on every ground." + }, + { + "name": "fg-3", + "value": { + "dark": "#8c93a2", + "light": "#656d7a" + }, + "usage": "Tertiary text: tier labels, `$` prompt, FAQ `+`, copyright, input placeholders. 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes (dark 4.6:1+, light 4.7:1+)." + }, + { + "name": "primary", + "value": { + "dark": "#01b5e2", + "light": "#01b5e2" + }, + "usage": "elbwalker blue. Primary button fill, check icons, highlight word in the H1, focus ring, announcement bar. Light theme: 2.4:1 on white — fine as a fill under `on-primary`, too faint for text or as the only marker (flagged, kept from source)." + }, + { + "name": "on-primary", + "value": { + "dark": "#04222b", + "light": "#04222b" + }, + "usage": "Text and icons on `primary` fills (6.9:1). Never put white on `primary` (2.4:1) — the current walkeros.io buttons do; the relaunch fixes it." + }, + { + "name": "link", + "value": { + "dark": "#4fb3e0", + "light": "#0076a0" + }, + "usage": "Links, eyebrows, mono step numbers; `info` and the light `focus` ring use it. 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes (dark 6.0:1+, light 4.6:1+). Underline on hover only." + }, + { + "name": "glow", + "value": { + "dark": "rgba(1, 181, 226, 0.16)", + "light": "rgba(1, 181, 226, 0.14)" + }, + "usage": "Soft primary halo: hero radial glow and the 4px ring around the highlighted plan." + }, + { + "name": "code-bg", + "value": "#292d3e", + "usage": "Code-block panel (Palenight family), both themes." + }, + { + "name": "code-bar", + "value": "#232636", + "usage": "Code-block title bar / tab strip." + }, + { + "name": "code-border", + "value": "#444a5e", + "usage": "Code-block outline." + }, + { + "name": "code-fg", + "value": "#bfc7d5", + "usage": "Default code text on `code-bg` (8:1)." + }, + { + "name": "viz-bg", + "value": "#1a2232", + "usage": "Product visualisation panel (tagging and mapping demos). Visualisations stay dark in both themes." + }, + { + "name": "viz-code-bg", + "value": "#131c2b", + "usage": "Code pane inside a visualisation." + }, + { + "name": "viz-border", + "value": "#334055", + "usage": "Outlines inside visualisations." + }, + { + "name": "viz-fg", + "value": "#e8eaf0", + "usage": "Primary text inside visualisations." + }, + { + "name": "viz-fg-2", + "value": "#8e96aa", + "usage": "Legend and meta text inside visualisations (5.4:1 on `viz-code-bg`)." + }, + { + "name": "viz-comment", + "value": "#6c7590", + "usage": "Code comments and file kinds inside visualisations." + }, + { + "name": "viz-line-number", + "value": "#4a5165", + "usage": "Gutter line numbers. Decorative, below 3:1." + }, + { + "name": "viz-surface", + "value": "#2b3649", + "usage": "Raised surfaces inside visualisations: the rendered teaser card, table rules." + }, + { + "name": "viz-tag", + "value": "#e38fd6", + "usage": "Syntax: HTML/JSX tag names in visualisation code (6:1+ on `viz-code-bg`)." + }, + { + "name": "viz-string", + "value": "#e5c07b", + "usage": "Syntax: string values in visualisation code and JSON cells." + }, + { + "name": "viz-number", + "value": "#f78c6c", + "usage": "Syntax: numbers and booleans in mapping code." + }, + { + "name": "viz-punct", + "value": "#9aa3b8", + "usage": "Syntax: punctuation and plain code text; visualisation captions." + }, + { + "name": "event-entity", + "value": "#c5e478", + "usage": "Event part: entity (e.g. `product`). Used for syntax highlight, legend chip and the highlighted DOM region in tagging demos." + }, + { + "name": "event-action", + "value": "#c792ea", + "usage": "Event part: action (e.g. `view`, `add`)." + }, + { + "name": "event-property", + "value": "#f0717f", + "usage": "Event part: entity property (`data-elb-product=\"name\"`)." + }, + { + "name": "event-context", + "value": "#f2a65a", + "usage": "Event part: context." + }, + { + "name": "event-globals", + "value": "#7fc4ea", + "usage": "Event part: globals." + }, + { + "name": "event-user", + "value": "#e65fc4", + "usage": "Tag kind: user (who the visitor is). Like the five event parts it sits only on dark grounds (`viz-bg`, legends, chips, mark layers) and always pairs with its word. 5.2:1 on `viz-bg`; kept apart from `event-property`, `event-action` and `danger`." + }, + { + "name": "event-consent", + "value": "#cbd5e1", + "usage": "Tag kind: consent (consent states and requirements). Dark grounds only, always with its word. 10.7:1 on `viz-bg`." + }, + { + "name": "elbwalker-100", + "value": "#edfeff", + "usage": "Brand scale (walkeros.io docs). Light-theme badge and admonition background." + }, + { + "name": "elbwalker-200", + "value": "#abf8ff", + "usage": "Brand scale. Admonition highlight (light)." + }, + { + "name": "elbwalker-300", + "value": "#69ecfe", + "usage": "Brand scale. Admonition text (dark)." + }, + { + "name": "elbwalker-400", + "value": "#27dbfe", + "usage": "Brand scale." + }, + { + "name": "elbwalker-500", + "value": "#01b5e2", + "usage": "Brand scale anchor = `primary`. Admonition border." + }, + { + "name": "elbwalker-700", + "value": "#015372", + "usage": "Brand scale. Badge text (light), badge fill (dark)." + }, + { + "name": "elbwalker-800", + "value": "#00283a", + "usage": "Brand scale. Admonition background (dark)." + }, + { + "name": "elbwalker-900", + "value": "#000203", + "usage": "Brand scale, darkest step." + }, + { + "name": "border-strong", + "value": { + "dark": "#6b778d", + "light": "#818a9a" + }, + "usage": "Borders that mark a control: input, select, checkbox and switch outlines, the 1px edge of menus, popovers and dialogs, scrollbar thumbs, flow edges. 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes; `border` stays decorative." + }, + { + "name": "focus", + "value": { + "dark": "{primary}", + "light": "{link}" + }, + "usage": "Focus ring, 2px solid with a 3px offset, on every interactive element: `primary` in dark, `link` in light, where `primary` is 2.2:1. 3:1 or more on every ground." + }, + { + "name": "backdrop", + "value": { + "dark": "rgba(3, 7, 18, 0.72)", + "light": "rgba(17, 24, 39, 0.4)" + }, + "usage": "Scrim behind modals and drawers. The layer above it is `surface` with a 1px `border-strong` edge, never a shadow." + }, + { + "name": "danger", + "value": { + "dark": "#ffa4ac", + "light": "#b91c1c" + }, + "usage": "Errors, destructive actions and invalid fields, always with an icon and a word. Text at 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. Kept apart from every event and step colour, `event-property` included." + }, + { + "name": "on-danger", + "value": { + "dark": "#450a0a", + "light": "#ffffff" + }, + "usage": "Text and icons on a `danger` fill such as a destructive button (dark 8.6:1, light 6.5:1)." + }, + { + "name": "success", + "value": { + "dark": "#8cf9ff", + "light": "#07675d" + }, + "usage": "Success and healthy states, always with an icon and a word. Sits on the blue side of green, so it stays apart from `danger` with red-green colour blindness, and apart from every step and event colour. Text at 4.5:1 or more on every ground." + }, + { + "name": "warning", + "value": { + "dark": "#cabb00", + "light": "#886000" + }, + "usage": "Warnings and states that need attention, always with an icon and a word. Kept apart from `step-transformer` and `event-context`. Text at 4.5:1 or more on every ground." + }, + { + "name": "info", + "value": "{link}", + "usage": "Neutral information, always with an icon and a word. The `link` colour in both themes." + }, + { + "name": "danger-bg", + "value": { + "dark": "rgba(255, 164, 172, 0.16)", + "light": "rgba(185, 28, 28, 0.1)" + }, + "usage": "Tinted fill for danger banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint)." + }, + { + "name": "success-bg", + "value": { + "dark": "rgba(140, 249, 255, 0.16)", + "light": "rgba(7, 103, 93, 0.1)" + }, + "usage": "Tinted fill for success banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint)." + }, + { + "name": "warning-bg", + "value": { + "dark": "rgba(202, 187, 0, 0.16)", + "light": "rgba(136, 96, 0, 0.1)" + }, + "usage": "Tinted fill for warning banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint)." + }, + { + "name": "info-bg", + "value": { + "dark": "rgba(79, 179, 224, 0.16)", + "light": "rgba(0, 118, 160, 0.1)" + }, + "usage": "Tinted fill for information banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint)." + }, + { + "name": "step-source", + "value": { + "dark": "#6ee7b7", + "light": "#428569" + }, + "usage": "Flow step colour: sources. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes." + }, + { + "name": "step-transformer", + "value": { + "dark": "#fcd34d", + "light": "#bb7000" + }, + "usage": "Flow step colour: transformers. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes." + }, + { + "name": "step-collector", + "value": { + "dark": "#7dd3fc", + "light": "#0c529b" + }, + "usage": "Flow step colour: the collector. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes." + }, + { + "name": "step-destination", + "value": { + "dark": "#c4b5fd", + "light": "#6d1fd8" + }, + "usage": "Flow step colour: destinations. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes." + }, + { + "name": "step-store", + "value": { + "dark": "#a5b4fc", + "light": "#4138c9" + }, + "usage": "Flow step colour: stores. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes." + }, + { + "name": "platform-web", + "value": { + "dark": "#0ea5e9", + "light": "#008ddd" + }, + "usage": "Platform badge: web, always with the word; 3:1 or more on every ground." + }, + { + "name": "platform-server", + "value": { + "dark": "#8b5cf6", + "light": "#8043f6" + }, + "usage": "Platform badge: server, always with the word; 3:1 or more on every ground." + }, + { + "name": "chart-1", + "value": { + "dark": "{primary}", + "light": "{link}" + }, + "usage": "First chart series: `primary` in dark, `link` in light. Series take `chart-1` to `chart-6` in order; the six stay apart in normal vision and with red-green colour blindness, and reach 3:1 on every ground. Label every series." + }, + { + "name": "chart-2", + "value": { + "dark": "#e69f00", + "light": "#ce710c" + }, + "usage": "Second chart series (orange)." + }, + { + "name": "chart-3", + "value": { + "dark": "#009563", + "light": "#217b58" + }, + "usage": "Third chart series (bluish green)." + }, + { + "name": "chart-4", + "value": { + "dark": "#e0520a", + "light": "#d32f4d" + }, + "usage": "Fourth chart series (vermilion)." + }, + { + "name": "chart-5", + "value": { + "dark": "#ff6fad", + "light": "#b87889" + }, + "usage": "Fifth chart series (reddish purple)." + }, + { + "name": "chart-6", + "value": { + "dark": "#f0e442", + "light": "#546800" + }, + "usage": "Sixth chart series (yellow in dark, olive in light)." + }, + { + "name": "annotation", + "value": { + "dark": "#22c55e", + "light": "#409116" + }, + "usage": "Human notes and comment pins over visualisations, tag plans and frames. Kept apart from every step and status colour; 3:1 or more on every ground." + }, + { + "name": "syntax-comment", + "value": "#8c94be", + "usage": "Editor syntax (Monaco, Shiki, Prism): comments, 4.6:1 on `code-bg`." + }, + { + "name": "syntax-string", + "value": "#c3e88d", + "usage": "Editor syntax (Monaco, Shiki, Prism): strings, attribute names and inserted text, 9.9:1 on `code-bg`." + }, + { + "name": "syntax-number", + "value": "#f78c6c", + "usage": "Editor syntax (Monaco, Shiki, Prism): numbers, 5.8:1 on `code-bg`." + }, + { + "name": "syntax-constant", + "value": "#ff5d78", + "usage": "Editor syntax (Monaco, Shiki, Prism): booleans, null and constants, 4.6:1 on `code-bg`." + }, + { + "name": "syntax-keyword", + "value": "#c084fc", + "usage": "Editor syntax (Monaco, Shiki, Prism): keywords and storage modifiers, 5.2:1 on `code-bg`." + }, + { + "name": "syntax-function", + "value": "#82aaff", + "usage": "Editor syntax (Monaco, Shiki, Prism): functions and built-ins, 5.9:1 on `code-bg`." + }, + { + "name": "syntax-type", + "value": "#ffcb6b", + "usage": "Editor syntax (Monaco, Shiki, Prism): types and class names, 9.1:1 on `code-bg`." + }, + { + "name": "syntax-operator", + "value": "#89ddff", + "usage": "Editor syntax (Monaco, Shiki, Prism): operators and regular expressions, 9.0:1 on `code-bg`." + }, + { + "name": "syntax-punct", + "value": "#c792ea", + "usage": "Editor syntax (Monaco, Shiki, Prism): punctuation and delimiters, 5.7:1 on `code-bg`." + }, + { + "name": "syntax-tag", + "value": "#ff5d79", + "usage": "Editor syntax (Monaco, Shiki, Prism): tags, invalid and deleted tokens, 4.6:1 on `code-bg`." + }, + { + "name": "syntax-namespace", + "value": "#b2ccd6", + "usage": "Editor syntax (Monaco, Shiki, Prism): namespaces, 8.1:1 on `code-bg`." + }, + { + "name": "code-line-number", + "value": "#676e95", + "usage": "Editor gutter line numbers on `code-bg`. Decorative, below 3:1." + }, + { + "name": "code-selection", + "value": "#717cb450", + "usage": "Editor selection over `code-bg`; `code-fg` stays at 5.4:1 on it." + }, + { + "name": "code-inserted-bg", + "value": "#c3e88d26", + "usage": "Inserted lines in diff views over `code-bg`; `code-fg` stays at 5.4:1." + }, + { + "name": "code-deleted-bg", + "value": "#ff5d7926", + "usage": "Deleted lines in diff views over `code-bg`; `code-fg` stays at 6.6:1." + } + ] + }, + "type": { + "fonts": [], + "families": { + "sans": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif", + "mono": "SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", + "viz": "'Geist', system-ui, sans-serif", + "viz-mono": "'Geist Mono', monospace" + }, + "groups": [ + { + "name": "Display", + "family": "sans", + "styles": [ + { + "name": "display", + "fontSize": "64px", + "lineHeight": 1.08, + "fontWeight": 700, + "usage": "Hero H1 only. Fluid: clamp(38px, 6vw, 64px), max 20ch, text-wrap balance. One phrase may take `primary`.", + "letterSpacing": "-0.025em", + "sample": "User behavior tracking that ships with the component." + }, + { + "name": "heading-xl", + "fontSize": "44px", + "lineHeight": 1.1, + "fontWeight": 700, + "usage": "Large split-section heading (More features). clamp(28px, 3.6vw, 44px).", + "letterSpacing": "-0.025em", + "sample": "Everything around the event, built in." + }, + { + "name": "heading-lg", + "fontSize": "40px", + "lineHeight": 1.15, + "fontWeight": 700, + "usage": "Section H2. clamp(28px, 3.6vw, 40px), text-wrap balance.", + "letterSpacing": "-0.02em", + "sample": "Questions we actually get." + }, + { + "name": "heading-md", + "fontSize": "36px", + "lineHeight": 1.15, + "fontWeight": 700, + "usage": "Feature H2 beside its explainer. clamp(26px, 3.2vw, 36px).", + "letterSpacing": "-0.02em", + "sample": "Tag a component once. Every instance is tracked, forever." + }, + { + "name": "footer-statement", + "fontSize": "34px", + "lineHeight": 1.2, + "fontWeight": 700, + "usage": "The footer's one-line brand statement. clamp(24px, 3.2vw, 34px), max 22ch.", + "letterSpacing": "-0.02em", + "sample": "Own the thing your growth numbers depend on." + }, + { + "name": "kicker-lg", + "fontSize": "28px", + "lineHeight": 1.2, + "fontWeight": 700, + "usage": "Large coloured kicker above `heading-xl`, in `primary`. clamp(22px, 2.4vw, 28px).", + "letterSpacing": "-0.01em", + "sample": "More features" + } + ] + }, + { + "name": "Text", + "family": "sans", + "styles": [ + { + "name": "title-plan", + "fontSize": "24px", + "lineHeight": 1.3, + "fontWeight": 700, + "usage": "Plan tier name.", + "letterSpacing": "-0.015em", + "sample": "Implementation" + }, + { + "name": "title-card", + "fontSize": "19px", + "lineHeight": 1.35, + "fontWeight": 600, + "usage": "Problem-card H3.", + "sample": "Instrumentation never keeps up" + }, + { + "name": "title-item", + "fontSize": "18px", + "lineHeight": 1.35, + "fontWeight": 600, + "usage": "Feature-list H3.", + "sample": "Consent before data moves" + }, + { + "name": "lead", + "fontSize": "21px", + "lineHeight": 1.65, + "fontWeight": 400, + "usage": "Hero sub-copy in `fg-2`, max 58ch. clamp(18px, 2vw, 21px).", + "sample": "Tag it once in the markup, and every place that component gets reused is already tracked." + }, + { + "name": "body-lg", + "fontSize": "17px", + "lineHeight": 1.65, + "fontWeight": 400, + "usage": "Feature explainer paragraphs, FAQ questions (600).", + "sample": "The walkerOS way: data-elb attributes live in the markup itself." + }, + { + "name": "body", + "fontSize": "16px", + "lineHeight": 1.65, + "fontWeight": 400, + "usage": "Default body copy, buttons (600).", + "sample": "Mapping rules carry their own consent requirement." + }, + { + "name": "ui", + "fontSize": "15px", + "lineHeight": 1.5, + "fontWeight": 500, + "usage": "Nav links, list items, footer links.", + "sample": "Documentation" + }, + { + "name": "small", + "fontSize": "14px", + "lineHeight": 1.5, + "fontWeight": 600, + "usage": "Announcement bar, `Docs →` links (500), copyright (400).", + "sample": "What's new: Just shipped v4.6" + }, + { + "name": "eyebrow", + "fontSize": "13px", + "lineHeight": 1.5, + "fontWeight": 600, + "usage": "Section eyebrow, UPPERCASE, in `link`. One line that states the problem the section answers.", + "letterSpacing": "0.12em", + "sample": "THE ANALYTICS LIBRARY" + }, + { + "name": "label", + "fontSize": "12px", + "lineHeight": 1.5, + "fontWeight": 700, + "usage": "Plan tier label, UPPERCASE, in `fg-3` (`link` on the highlighted plan).", + "letterSpacing": "0.12em", + "sample": "FIXED SCOPE" + } + ] + }, + { + "name": "Code", + "family": "mono", + "styles": [ + { + "name": "code-command", + "fontSize": "15px", + "lineHeight": 1.5, + "fontWeight": 500, + "usage": "Install-command chip.", + "sample": "$ npm i -g @walkeros/cli" + }, + { + "name": "code-step", + "fontSize": "13px", + "lineHeight": 1.5, + "fontWeight": 400, + "usage": "Mono step numbers (01, 02, 03) in `link`.", + "sample": "01" + }, + { + "name": "code-inline", + "fontSize": "0.88em", + "lineHeight": 1.5, + "fontWeight": 400, + "usage": "Inline code inside prose: `surface-2` fill, `border` outline, radius-sm.", + "sample": "dataLayer.push()" + } + ] + }, + { + "name": "Visualisation", + "family": "viz", + "styles": [ + { + "name": "viz-body", + "fontSize": "15px", + "lineHeight": 1.5, + "fontWeight": 400, + "usage": "Captions inside product visualisations (Geist).", + "sample": "The browser source reads the data-elb attributes." + }, + { + "name": "viz-chip", + "fontSize": "13px", + "lineHeight": 1.4, + "fontWeight": 500, + "usage": "Step pills inside visualisations.", + "sample": "Entity" + }, + { + "name": "viz-code", + "fontSize": "12px", + "lineHeight": 1.8, + "fontWeight": 400, + "usage": "Code pane inside visualisations (Geist Mono).", + "sample": "data-elb=\"article\"", + "family": "viz-mono" + }, + { + "name": "viz-legend", + "fontSize": "11px", + "lineHeight": 1.5, + "fontWeight": 400, + "usage": "Event-part legend (Geist Mono) in `viz-fg-2`.", + "sample": "entity action property", + "family": "viz-mono" + } + ] + }, + { + "name": "Product", + "family": "sans", + "styles": [ + { + "name": "product-heading", + "fontSize": "20px", + "lineHeight": 1.3, + "fontWeight": 600, + "usage": "Page and dialog titles in the app, the explorer and Tag Mode.", + "sample": "Tracking plan" + }, + { + "name": "product-title", + "fontSize": "16px", + "lineHeight": 1.4, + "fontWeight": 600, + "usage": "Panel, card and section titles in product UI.", + "sample": "Destinations" + }, + { + "name": "product-body", + "fontSize": "14px", + "lineHeight": 1.5, + "fontWeight": 400, + "usage": "Default product copy: body text, form fields, table cells, buttons (600).", + "sample": "Every event that reaches the collector is listed here." + }, + { + "name": "product-small", + "fontSize": "13px", + "lineHeight": 1.45, + "fontWeight": 400, + "usage": "Secondary rows, help text and dense tables.", + "sample": "Last seen 2 minutes ago" + }, + { + "name": "product-caption", + "fontSize": "12px", + "lineHeight": 1.4, + "fontWeight": 500, + "usage": "Captions, badges and meta labels.", + "sample": "web · v4.7" + } + ] + } + ] + }, + "spacing": { + "tokens": [ + { + "name": "space-2", + "value": "8px", + "usage": "Icon-to-label gap, footer link stack, tag rows." + }, + { + "name": "space-3", + "value": "12px", + "usage": "Button groups, FAQ stack gap." + }, + { + "name": "space-3-5", + "value": "14px", + "usage": "Plan-card internal stack; check-icon to text in feature items." + }, + { + "name": "space-5", + "value": "20px", + "usage": "Card-grid gap." + }, + { + "name": "gutter", + "value": "24px", + "usage": "Container side padding; header gap." + }, + { + "name": "card-pad-sm", + "value": "26px", + "usage": "Problem-card padding." + }, + { + "name": "card-pad", + "value": "28px", + "usage": "Plan-card padding." + }, + { + "name": "heading-gap", + "value": "44px", + "usage": "Section heading block to its content; feature-list row gap." + }, + { + "name": "split-gap", + "value": "64px", + "usage": "Column gap between heading and explainer / list." + }, + { + "name": "section-y", + "value": "88px", + "usage": "Vertical padding of every section (hero: 104px top)." + }, + { + "name": "feature-gap", + "value": "96px", + "usage": "Space between stacked feature blocks, split by a `border` rule." + }, + { + "name": "header-h", + "value": "60px", + "usage": "Sticky header height." + }, + { + "name": "container", + "value": "1240px", + "usage": "Max content width." + } + ] + }, + "radius": { + "tokens": [ + { + "name": "radius-xs", + "value": "4px", + "usage": "Focus-ring corner, sidebar badges." + }, + { + "name": "radius-sm", + "value": "5px", + "usage": "Inline code." + }, + { + "name": "radius-md", + "value": "8px", + "usage": "Buttons, install-command chip." + }, + { + "name": "radius-faq", + "value": "10px", + "usage": "FAQ disclosure items." + }, + { + "name": "radius-lg", + "value": "12px", + "usage": "Cards, plan tiers, code panes in visualisations." + }, + { + "name": "radius-xl", + "value": "16px", + "usage": "Visualisation frame." + }, + { + "name": "radius-full", + "value": "999px", + "usage": "Step pills, icon buttons." + } + ] + }, + "shadow": { + "tokens": [ + { + "name": "plan-highlight", + "value": { + "dark": "0 0 0 4px rgba(1, 181, 226, 0.16)", + "light": "0 0 0 4px rgba(1, 181, 226, 0.14)" + }, + "usage": "The one recommended plan: 4px `glow` ring plus a `primary` border. walkerOS uses no drop shadows." + } + ] + }, + "zIndex": { + "tokens": [ + { + "name": "z-raised", + "value": "1", + "usage": "A layer lifted over its siblings inside one component." + }, + { + "name": "z-sticky", + "value": "10", + "usage": "Sticky headers and toolbars." + }, + { + "name": "z-dropdown", + "value": "20", + "usage": "Menus, listboxes and comboboxes opened from a control." + }, + { + "name": "z-overlay", + "value": "40", + "usage": "The `backdrop` scrim." + }, + { + "name": "z-modal", + "value": "50", + "usage": "Dialogs and drawers above the scrim." + }, + { + "name": "z-popover", + "value": "60", + "usage": "Popovers and menus opened inside a modal." + }, + { + "name": "z-toast", + "value": "70", + "usage": "Toasts, above every other product layer. Overlays injected into third-party pages keep their own band." + } + ] + } +} \ No newline at end of file diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 8f5e34089..e19fb973e 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -7,6 +7,9 @@ "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "bin": { + "walkeros-design-check": "./dist/design/check.mjs" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -14,7 +17,15 @@ "require": "./dist/index.js" }, "./styles.css": "./dist/styles.css", - "./styles": "./dist/styles.css" + "./styles": "./dist/styles.css", + "./design": { + "types": "./dist/design/index.d.ts", + "import": "./dist/design/index.mjs", + "require": "./dist/design/index.cjs" + }, + "./design/tokens.css": "./dist/design/tokens.css", + "./design/base.css": "./dist/design/base.css", + "./design/tailwind.css": "./dist/design/tailwind.css" }, "files": [ "dist/**", diff --git a/apps/explorer/src/design/__tests__/__snapshots__/generate.test.ts.snap b/apps/explorer/src/design/__tests__/__snapshots__/generate.test.ts.snap new file mode 100644 index 000000000..bdb9d706d --- /dev/null +++ b/apps/explorer/src/design/__tests__/__snapshots__/generate.test.ts.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders tokens.css, tailwind.css and the constants for the fixture 1`] = ` +"/* walkerOS design tokens, generated from design/tokens.json by src/design/generate.ts. Edit the JSON, never this file. */ + +:root, :host, [data-theme] { + --primary: #01b5e2; + --code-bg: rgba(41, 45, 62, 1); + --edge: 0 0 0 1px rgba(0, 0, 0, 0.2); + --gutter: 24px; + --radius-md: 8px; + --z-modal: 50; + --font-sans: system-ui, 'Segoe UI', sans-serif; + --font-mono: ui-monospace, monospace; +} + +:root, :host, [data-theme="dark"] { + --bg: #111827; + --link: #4fb3e0; + --info: var(--link); + --focus: var(--primary); + --accent: var(--primary); + --ring: 0 0 0 4px rgba(1, 181, 226, 0.16); +} + +[data-theme="light"] { + --bg: #ffffff; + --link: #0076a0; + --info: var(--link); + --focus: var(--link); + --accent: var(--primary); + --ring: 0 0 0 4px rgba(1, 181, 226, 0.14); +} + +/* ---- */ +/* walkerOS design tokens, generated from design/tokens.json by src/design/generate.ts. Edit the JSON, never this file. */ + +@import "./tokens.css"; + +@theme { + --color-*: initial; + --radius-*: initial; + --font-*: initial; + --text-*: initial; + --shadow-*: initial; + --inset-shadow-*: initial; + --drop-shadow-*: initial; + --text-shadow-*: initial; + --spacing: 4px; + --radius-md: 8px; + --font-sans: system-ui, 'Segoe UI', sans-serif; + --font-mono: ui-monospace, monospace; + --text-body: 16px; + --text-body--line-height: 1.65; + --text-body--font-weight: 400; + --text-eyebrow: 13px; + --text-eyebrow--line-height: 1.5; + --text-eyebrow--font-weight: 600; + --text-eyebrow--letter-spacing: 0.12em; + --text-code: 0.88em; + --text-code--line-height: 1.5; + --text-code--font-weight: 400; + --shadow-edge: 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +@theme inline { + --color-bg: var(--bg); + --color-primary: var(--primary); + --color-link: var(--link); + --color-info: var(--info); + --color-focus: var(--focus); + --color-code-bg: var(--code-bg); + --color-accent: var(--accent); + --shadow-ring: var(--ring); +} + +/* ---- */ +/* walkerOS design constants, generated from design/tokens.json by src/design/generate.ts. Edit the JSON and run the explorer build, never this file. */ + +export type Theme = 'dark' | 'light'; + +/** Page ground. */ +export const bg = { dark: "#111827", light: "#ffffff" } as const; + +/** Brand fill. */ +export const primary = "#01b5e2"; + +/** Links. */ +export const link = { dark: "#4fb3e0", light: "#0076a0" } as const; + +/** A single alias of a themed colour. */ +export const info = { dark: "#4fb3e0", light: "#0076a0" } as const; + +/** A per-theme alias. */ +export const focus = { dark: "#01b5e2", light: "#0076a0" } as const; + +/** An invariant rgba() colour. */ +export const codeBg = "rgba(41, 45, 62, 1)"; + +/** A single alias of a single literal. */ +export const accent = "#01b5e2"; + +/** Font family \`sans\`. */ +export const fontSans = "system-ui, 'Segoe UI', sans-serif"; + +/** Font family \`mono\`. */ +export const fontMono = "ui-monospace, monospace"; +" +`; diff --git a/apps/explorer/src/design/__tests__/check.test.ts b/apps/explorer/src/design/__tests__/check.test.ts new file mode 100644 index 000000000..81f131da0 --- /dev/null +++ b/apps/explorer/src/design/__tests__/check.test.ts @@ -0,0 +1,406 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { main } from '../check/cli'; +import { globToRegExp } from '../check/glob'; +import { scanFile, type RuleId } from '../check/rules'; +import { + loadDesignTokens, + renderTailwindCss, + renderTokensCss, +} from '../generate'; +import { collectDeclaredNames } from '../names'; + +const packageDir = resolve(__dirname, '../../..'); +const tokens = loadDesignTokens(packageDir); +const designCss = { + 'tokens.css': renderTokensCss(tokens), + 'tailwind.css': renderTailwindCss(tokens), + 'base.css': readFileSync(resolve(packageDir, 'src/design/base.css'), 'utf8'), +}; +const designNames = collectDeclaredNames(Object.values(designCss).join('\n')); + +describe('globToRegExp', () => { + it.each([ + ['**/*.svg', 'src/icons/logo.svg', true], + ['**/*.svg', 'logo.svg', true], + ['src/*.ts', 'src/a.ts', true], + ['src/*.ts', 'src/a/b.ts', false], + ['src/**/brand-svgs.ts', 'src/brand-svgs.ts', true], + ['src/**/brand-svgs.ts', 'src/icons/deep/brand-svgs.ts', true], + ['**/*.{svg,png}', 'a/b.png', true], + ['a?.ts', 'ab.ts', true], + ['src/lib/z-band.ts', 'src/lib/zXband.ts', false], + ['src/graphify-out/**', 'src/graphify-out/x/y.json', true], + ])('%s matches %s: %s', (glob, path, expected) => { + expect(globToRegExp(glob)?.test(path)).toBe(expected); + }); + + it('refuses an unclosed brace', () => { + expect(globToRegExp('src/{a')).toBeUndefined(); + }); +}); + +describe('scanFile', () => { + it.each([ + ['color-literal', 'a.tsx', "const c = '#01b5e2';", ['#01b5e2']], + [ + 'color-literal', + 'a.css', + '.a { background: rgba(0, 0, 0, 0.5); }', + ['rgba(0, 0, 0, 0.5)'], + ], + ['color-literal', 'a.tsx', '{', []], + // Tailwind arbitrary values write spaces as _. + ['color-literal', 'a.tsx', "const c = 'shadow-[0_0_4px_#000]';", ['#000']], + [ + 'color-literal', + 'a.tsx', + "const c = '[box-shadow:0_0_0_1px_rgba(0,0,0,0.1)]';", + ['rgba(0,0,0,0.1)'], + ], + [ + 'color-literal', + 'a.tsx', + "const c = 'shadow-[0_0_0_1px_var(--border-strong)] bg-[color:var(--surface)]';", + [], + ], + ['named-color', 'a.css', '.a { color: white; }', ['white']], + [ + 'named-color', + 'a.css', + '.a { border: 1px solid currentColor; background: url(img/red.png); }', + [], + ], + ['named-color', 'a.tsx', '', ['white']], + ['named-color', 'a.tsx', '', []], + [ + 'palette-class', + 'a.tsx', + '

', + ['bg-zinc-500/50'], + ], + ['palette-class', 'a.css', '.a { @apply text-sky-600; }', ['text-sky-600']], + ['palette-class', 'a.tsx', '// bg-zinc-500 stays a comment', []], + [ + 'white-black-class', + 'a.tsx', + "cn('text-white', 'bg-black/40')", + ['text-white', 'bg-black/40'], + ], + [ + 'white-black-class', + 'a.tsx', + "const c = 'text-on-primary bg-backdrop'; // text-white in a comment", + [], + ], + [ + 'dark-variant', + 'a.tsx', + "const c = 'dark:bg-surface md:dark:text-fg';", + ['dark:', 'dark:'], + ], + ['dark-variant', 'a.ts', "const theme = { dark: 'x' };", []], + [ + 'shadow-class', + 'a.tsx', + "const c = 'shadow-lg drop-shadow-md';", + ['shadow-lg', 'drop-shadow-md'], + ], + [ + 'shadow-class', + 'a.tsx', + "const c = 'shadow-none shadow-plan-highlight';", + [], + ], + [ + 'shadow-class', + 'a.tsx', + "const c = 'hover:drop-shadow';", + ['drop-shadow'], + ], + [ + 'shadow-class', + 'a.ts', + "const css = 'filter: drop-shadow(0 0 2px var(--fg)); a shadow root';", + [], + ], + ['z-class', 'a.tsx', "const c = 'z-[60] -z-10';", ['z-[60]', '-z-10']], + ['z-class', 'a.tsx', "const c = 'z-(--z-modal) z-auto';", []], + [ + 'text-size-class', + 'a.tsx', + "const c = 'text-sm/6 md:text-2xl';", + ['text-sm', 'text-2xl'], + ], + [ + 'text-size-class', + 'a.tsx', + "const c = 'text-product-body text-fg-2';", + [], + ], + [ + 'radius-class', + 'a.tsx', + "const c = 'rounded rounded-2xl rounded-t-[6px]';", + ['rounded', 'rounded-2xl', 'rounded-t-[6px]'], + ], + [ + 'radius-class', + 'a.tsx', + "const c = 'rounded-md rounded-t-lg rounded-full rounded-faq rounded-(--radius-md)';", + [], + ], + [ + 'radius-class', + 'a.tsx', + "const c = 'before:rounded-[calc(var(--radius-md)-1px)]';", + [], + ], + ['var-fallback', 'a.css', '.a { color: var(--fg, #000); }', ['var(--fg,']], + [ + 'var-fallback', + 'a.css', + '.a { --local: 1px; top: var(--local, 2px); }', + [], + ], + [ + 'undeclared-var', + 'a.css', + '.a { top: var(--nowhere); }', + ['var(--nowhere'], + ], + [ + 'undeclared-var', + 'a.tsx', + "

", + [], + ], + [ + 'undeclared-var', + 'a.ts', + "el.style.setProperty('--grid-min', '1px'); const w = 'var(--grid-min) var(--tw-ring-color)';", + [], + ], + [ + 'undeclared-var', + 'a.tsx', + "const c = 'font-(--font-weight-medium) tracking-(--tracking-wide) max-w-(--container-md)';", + [], + ], + // Built in from the shared Tailwind namespace list: kept namespaces count, reset ones do not. + [ + 'undeclared-var', + 'a.css', + '.a { color: var(--color-zinc-500); animation: var(--animate-spin); }', + ['var(--color-zinc-500'], + ], + [ + 'color-scheme', + 'a.css', + '@media (prefers-color-scheme: dark) { .a { top: 0; } }', + ['prefers-color-scheme'], + ], + ['color-scheme', 'a.css', '.a { color-scheme: dark; }', []], + [ + 'z-index', + 'a.css', + '.a { z-index: 10; } .b { z-index: var(--z-modal); }', + ['z-index: 10'], + ], + [ + 'z-index', + 'a.tsx', + '
', + ['zIndex: 2147483350'], + ], + [ + 'z-index', + 'a.ts', + 'const GRIP_CSS = `:host { z-index: 2147483647 !important; }`;', + ['z-index: 2147483647'], + ], + ['z-index', 'a.tsx', "
", []], + ])('%s in %s: %s', (rule, file, snippet, expected) => { + const context = { + designNames, + declaredNames: collectDeclaredNames(snippet), + allowVarPrefixes: [], + }; + const matches = scanFile(file, snippet, context) + .filter((finding) => finding.rule === rule) + .map((finding) => finding.match); + expect(matches).toEqual(expected); + }); + + it('reports a class inside a multi-line template literal at its own line and column', () => { + const context = { + designNames, + declaredNames: new Set(), + allowVarPrefixes: [], + }; + expect(scanFile('a.tsx', 'const c = `p-2\n text-xs`;', context)).toEqual([ + { + file: 'a.tsx', + line: 2, + column: 3, + rule: 'text-size-class', + match: 'text-xs', + }, + ]); + }); +}); + +describe('walkeros-design-check', () => { + const tempDirs: string[] = []; + const tempDir = (prefix: string): string => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + }; + afterAll(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + const designDir = tempDir('design-css-'); + for (const [file, css] of Object.entries(designCss)) + writeFileSync(join(designDir, file), css); + + function run( + files: Record, + argv: readonly string[], + design = designDir, + ) { + const cwd = tempDir('design-check-'); + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(cwd, path)), { recursive: true }); + writeFileSync(join(cwd, path), content); + } + const out: string[] = []; + const err: string[] = []; + const code = main(argv, { + cwd, + designDir: design, + out: (line) => out.push(line), + err: (line) => err.push(line), + }); + return { code, out, err }; + } + + it.each< + readonly [ + string, + Record, + readonly string[], + number, + readonly string[], + ] + >([ + [ + 'a clean package', + { 'src/a.css': '.a { color: var(--fg); }' }, + ['src'], + 0, + [], + ], + [ + 'a finding', + { 'src/a.tsx': '

' }, + ['src'], + 1, + ['src/a.tsx:1:15 text-size-class text-sm'], + ], + [ + 'an allowed file next to a clean one', + { + 'src/a.tsx': '

', + 'src/b.css': '.b { top: 0; }', + }, + ['--allow', 'src/a.tsx', 'src'], + 0, + [], + ], + [ + 'a rule-scoped allow', + { 'src/band.css': '.g { z-index: 2147483647; color: #fff; }' }, + ['--allow', 'z-index:src/band.css', 'src'], + 1, + ['src/band.css:1:34 color-literal #fff'], + ], + [ + 'a third-party variable without --allow-var', + { 'src/a.css': '.a { top: var(--ifm-navbar-height); }' }, + ['src'], + 1, + ['src/a.css:1:11 undeclared-var var(--ifm-navbar-height'], + ], + [ + 'a third-party variable with --allow-var', + { 'src/a.css': '.a { top: var(--ifm-navbar-height); }' }, + ['--allow-var', 'ifm-', 'src'], + 0, + [], + ], + [ + 'a variable declared in an allowed file', + { + 'src/band.ts': "export const band = { '--band-top': '1' };", + 'src/a.css': '.a { top: var(--band-top); }', + }, + ['--allow', 'src/band.ts', 'src'], + 0, + [], + ], + ['a missing path', {}, ['nowhere'], 2, []], + [ + 'every file allowed', + { 'src/a.tsx': '

' }, + ['--allow', '**/*.tsx', 'src'], + 2, + [], + ], + ['an unknown option', { 'src/a.css': '.a {}' }, ['--fix', 'src'], 2, []], + ['--allow without a value', { 'src/a.css': '.a {}' }, ['--allow'], 2, []], + [ + 'an empty --allow-var prefix', + { 'src/a.css': '.a { top: var(--nowhere); }' }, + ['--allow-var', '', 'src'], + 2, + [], + ], + [ + 'an unknown rule in a scoped allow', + { 'src/a.css': '.a {}' }, + ['--allow', 'zindex:src/a.css', 'src'], + 2, + [], + ], + [ + 'an invalid glob', + { 'src/a.css': '.a {}' }, + ['--allow', 'src/{a', 'src'], + 2, + [], + ], + ])('%s', (_label, files, argv, code, out) => { + const result = run(files, argv); + expect(result.code).toBe(code); + expect(result.out).toEqual(out); + if (code === 2) expect(result.err.length).toBeGreaterThan(0); + }); + + it('exits 2 when explorer is not built (no design CSS beside the checker)', () => { + const result = run( + { 'src/a.css': '.a { color: var(--fg); }' }, + ['src'], + tempDir('design-empty-'), + ); + expect(result.code).toBe(2); + expect(result.err.join('\n')).toContain('build @walkeros/explorer'); + }); +}); diff --git a/apps/explorer/src/design/__tests__/color-math.ts b/apps/explorer/src/design/__tests__/color-math.ts new file mode 100644 index 000000000..239baccab --- /dev/null +++ b/apps/explorer/src/design/__tests__/color-math.ts @@ -0,0 +1,106 @@ +import { + parseColor, + resolveColor, + type DesignTokens, + type Rgba, + type ThemeId, +} from '../tokens'; + +export type Vision = 'normal' | 'protan' | 'deutan'; + +type Row = readonly [number, number, number]; + +/** Machado, Oliveira and Fernandes (2009), severity 1.0, applied to linear sRGB. */ +const CVD: Record, readonly [Row, Row, Row]> = { + protan: [ + [0.152286, 1.052583, -0.204868], + [0.114503, 0.786281, 0.099216], + [-0.003882, -0.048116, 1.051998], + ], + deutan: [ + [0.367322, 0.860646, -0.227968], + [0.280085, 0.672501, 0.047413], + [-0.01182, 0.04294, 0.968881], + ], +}; + +const linear = (channel: number): number => { + const v = channel / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; +}; + +export function tokenColor( + tokens: DesignTokens, + name: string, + theme: ThemeId, +): Rgba { + const value = resolveColor(tokens, name, theme); + const color = parseColor(value); + if (color === undefined) + throw new Error( + `${name} (${theme}) resolves to "${value}", not a literal colour`, + ); + return color; +} + +/** A translucent fill laid over an opaque ground. */ +export function over(top: Rgba, ground: Rgba): Rgba { + if (ground.a < 1) + throw new Error('the ground under a translucent fill must be opaque'); + const mix = (a: number, b: number): number => a * top.a + b * (1 - top.a); + return { + r: mix(top.r, ground.r), + g: mix(top.g, ground.g), + b: mix(top.b, ground.b), + a: 1, + }; +} + +export function luminance(color: Rgba): number { + return ( + 0.2126 * linear(color.r) + + 0.7152 * linear(color.g) + + 0.0722 * linear(color.b) + ); +} + +/** WCAG 2.x contrast ratio. Refuses translucent colours: composite them with over() first. */ +export function contrast(a: Rgba, b: Rgba): number { + if (a.a < 1 || b.a < 1) + throw new Error( + 'contrast() needs opaque colours; composite with over() first', + ); + const la = luminance(a); + const lb = luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +export function oklab( + color: Rgba, + vision: Vision = 'normal', +): readonly [number, number, number] { + const rgb: Row = [linear(color.r), linear(color.g), linear(color.b)]; + const [r, g, b] = + vision === 'normal' + ? rgb + : CVD[vision].map((row) => + Math.min( + 1, + Math.max(0, row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2]), + ), + ); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +export function deltaE(a: Rgba, b: Rgba, vision: Vision = 'normal'): number { + const [l1, a1, b1] = oklab(a, vision); + const [l2, a2, b2] = oklab(b, vision); + return Math.hypot(l1 - l2, a1 - a2, b1 - b2); +} diff --git a/apps/explorer/src/design/__tests__/contrast.test.ts b/apps/explorer/src/design/__tests__/contrast.test.ts new file mode 100644 index 000000000..32b1ece07 --- /dev/null +++ b/apps/explorer/src/design/__tests__/contrast.test.ts @@ -0,0 +1,260 @@ +import { resolve } from 'node:path'; +import { loadDesignTokens } from '../generate'; +import { THEMES, type ThemeId } from '../tokens'; +import { contrast, deltaE, oklab, over, tokenColor } from './color-math'; + +const tokens = loadDesignTokens(resolve(__dirname, '../../..')); +const color = (name: string, theme: ThemeId) => tokenColor(tokens, name, theme); + +const GROUNDS = ['bg', 'bg-2', 'surface', 'surface-2'] as const; +const STATUS = ['danger', 'success', 'warning', 'info'] as const; +const SYNTAX = [ + 'syntax-comment', + 'syntax-string', + 'syntax-number', + 'syntax-constant', + 'syntax-keyword', + 'syntax-function', + 'syntax-type', + 'syntax-operator', + 'syntax-punct', + 'syntax-tag', + 'syntax-namespace', +] as const; +const EVENTS = [ + 'event-entity', + 'event-action', + 'event-property', + 'event-context', + 'event-globals', + 'event-user', + 'event-consent', +] as const; +const CHARTS = [ + 'chart-1', + 'chart-2', + 'chart-3', + 'chart-4', + 'chart-5', + 'chart-6', +] as const; +const STEP_COLOURS = [ + 'step-source', + 'step-transformer', + 'step-collector', + 'step-destination', + 'step-store', + 'platform-web', + 'platform-server', +] as const; +const MARKS = [ + 'border-strong', + 'focus', + ...STEP_COLOURS, + ...CHARTS, + 'annotation', +] as const; + +/** + * Status pairs exempt from the 0.10 distance because the spec fixes BOTH sides: + * dark `info` is the artifact's `link`, dark `platform-web` keeps today's value, + * `event-globals` is an artifact event colour. The test also fails when an + * exempt pair no longer falls short, so the list cannot rot. + */ +const SPEC_FIXED_PAIRS = ['dark info/platform-web', 'dark info/event-globals']; + +interface Pair { + readonly fg: string; + readonly on: readonly string[]; + readonly min: number; + /** Set when each `on` colour is a translucent fill laid over each of these grounds first. */ + readonly over?: readonly string[]; +} + +const text = (fg: string, on: readonly string[]): Pair => ({ + fg, + on, + min: 4.5, +}); +const mark = (fg: string, on: readonly string[]): Pair => ({ fg, on, min: 3 }); + +/** + * Every documented pair, both themes, no exceptions. The artifact's three + * documented misses are the `fg-3` and `link` rows (fixed by value) and light + * `primary`, which appears in no text or mark row: it stays a fill under + * `on-primary`, and `focus` and `chart-1` take `link` in light. + */ +const PAIRS: readonly Pair[] = [ + ...['fg', 'fg-2', 'fg-3', 'link', ...STATUS].map((fg) => text(fg, GROUNDS)), + text('on-primary', ['primary']), + text('on-danger', ['danger']), + ...STATUS.map( + (status): Pair => ({ ...text('fg', [`${status}-bg`]), over: GROUNDS }), + ), + text('code-fg', ['code-bg']), + { + ...text('code-fg', [ + 'code-selection', + 'code-inserted-bg', + 'code-deleted-bg', + ]), + over: ['code-bg'], + }, + ...SYNTAX.map((fg) => text(fg, ['code-bg'])), + text('viz-fg', ['viz-bg', 'viz-code-bg']), + ...['viz-fg-2', 'viz-tag', 'viz-string', 'viz-number', 'viz-punct'].map( + (fg) => text(fg, ['viz-code-bg']), + ), + ...EVENTS.map((fg) => text(fg, ['viz-bg', 'viz-code-bg'])), + ...MARKS.map((fg) => mark(fg, GROUNDS)), + ...STATUS.map( + (status): Pair => ({ ...mark(status, [`${status}-bg`]), over: GROUNDS }), + ), +]; + +function shortfalls(pair: Pair, theme: ThemeId): string[] { + const fg = color(pair.fg, theme); + const bases = pair.over; + const grounds = + bases === undefined + ? pair.on.map((name) => ({ name, rgba: color(name, theme) })) + : pair.on.flatMap((fill) => + bases.map((base) => ({ + name: `${fill} over ${base}`, + rgba: over(color(fill, theme), color(base, theme)), + })), + ); + return grounds.flatMap(({ name, rgba }) => { + const ratio = contrast(fg, rgba); + return ratio < pair.min ? [`${name}: ${ratio.toFixed(2)}`] : []; + }); +} + +const label = (pair: Pair): string => + `${pair.fg} on ${pair.on.join(', ')}${pair.over === undefined ? '' : ` over ${pair.over.join(', ')}`} >= ${pair.min}:1`; + +describe.each(THEMES)('%s theme', (theme) => { + it.each(PAIRS.map((pair) => [label(pair), pair] as const))( + '%s', + (_label, pair) => { + expect(shortfalls(pair, theme)).toEqual([]); + }, + ); + + it('success and danger stay apart with red-green colour blindness (craft.md)', () => { + const success = color('success', theme); + const danger = color('danger', theme); + const measured = { + contrast: contrast(success, danger), + oklabB: oklab(success)[2], + protan: deltaE(success, danger, 'protan'), + deutan: deltaE(success, danger, 'deutan'), + }; + // Either 3:1 apart in lightness, or success leaves the red-green axis toward blue. + const apart = + measured.contrast >= 3 || + (measured.oklabB < 0 && measured.protan >= 0.1 && measured.deutan >= 0.1); + expect({ apart, ...measured }).toEqual( + expect.objectContaining({ apart: true }), + ); + }); + + it('danger is distinct from event-property', () => { + expect( + deltaE(color('danger', theme), color('event-property', theme)), + ).toBeGreaterThanOrEqual(0.1); + }); + + it('annotation is distinct from step-store', () => { + expect( + deltaE(color('annotation', theme), color('step-store', theme)), + ).toBeGreaterThanOrEqual(0.1); + }); + + it('every status is distinct from every step, platform and event colour', () => { + const pairs = STATUS.flatMap((status) => + [...STEP_COLOURS, ...EVENTS].map((other) => ({ + key: `${theme} ${status}/${other}`, + d: deltaE(color(status, theme), color(other, theme)), + })), + ); + const exempt = (key: string): boolean => SPEC_FIXED_PAIRS.includes(key); + const close = pairs + .filter(({ key, d }) => d < 0.1 && !exempt(key)) + .map(({ key, d }) => `${key} ${d.toFixed(3)}`); + const stale = pairs + .filter(({ key, d }) => d >= 0.1 && exempt(key)) + .map(({ key }) => key); + expect({ close, stale }).toEqual({ close: [], stale: [] }); + }); + + it('chart series stay apart in normal, protan and deutan vision', () => { + const close: string[] = []; + for (const vision of ['normal', 'protan', 'deutan'] as const) { + CHARTS.forEach((a, i) => + CHARTS.slice(i + 1).forEach((b) => { + const d = deltaE(color(a, theme), color(b, theme), vision); + if (d < 0.07) close.push(`${a}/${b} ${vision} ${d.toFixed(3)}`); + }), + ); + } + expect(close).toEqual([]); + }); +}); + +it('event-user and event-consent are distinct from the other event parts, step-source and annotation', () => { + // Event colours sit on dark grounds only (F9), so the dark values decide; the + // status distances are covered by the status test above. + const close = (['event-user', 'event-consent'] as const).flatMap((kind) => + [...EVENTS, 'step-source', 'annotation'] + .filter((other) => other !== kind) + .map((other) => ({ + pair: `${kind}/${other}`, + d: deltaE(color(kind, 'dark'), color(other, 'dark')), + })) + .filter(({ d }) => d < 0.1) + .map(({ pair, d }) => `${pair} ${d.toFixed(3)}`), + ); + expect(close).toEqual([]); +}); + +/** + * Colours no contrast row measures, each with its reason: `decorative` (marks + * nothing a reader needs), `atmosphere` (the glow), `scrim` (under a surface), + * `chrome` (a frame ground with no documented text on it), `palette` (the + * brand scale, not a role) and `open-item` (`viz-comment`, under review). A new + * colour joins a row above or this list, so none ships without a decision. + */ +const UNPAIRED = new Map([ + ['border', 'decorative'], + ['glow', 'atmosphere'], + ['code-bar', 'chrome'], + ['code-border', 'decorative'], + ['code-line-number', 'decorative'], + ['viz-border', 'decorative'], + ['viz-surface', 'chrome'], + ['viz-line-number', 'decorative'], + ['viz-comment', 'open-item'], + ['backdrop', 'scrim'], + ['elbwalker-100', 'palette'], + ['elbwalker-200', 'palette'], + ['elbwalker-300', 'palette'], + ['elbwalker-400', 'palette'], + ['elbwalker-500', 'palette'], + ['elbwalker-700', 'palette'], + ['elbwalker-800', 'palette'], + ['elbwalker-900', 'palette'], +]); + +it('every colour token is in a contrast row or listed as unpaired', () => { + const paired = new Set( + PAIRS.flatMap((pair) => [pair.fg, ...pair.on, ...(pair.over ?? [])]), + ); + const names = tokens.colors.map((token) => token.name); + expect({ + unproven: names.filter((name) => !paired.has(name) && !UNPAIRED.has(name)), + stale: [...UNPAIRED.keys()].filter( + (name) => paired.has(name) || !names.includes(name), + ), + }).toEqual({ unproven: [], stale: [] }); +}); diff --git a/apps/explorer/src/design/__tests__/entry.test.ts b/apps/explorer/src/design/__tests__/entry.test.ts new file mode 100644 index 000000000..5d846c067 --- /dev/null +++ b/apps/explorer/src/design/__tests__/entry.test.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { forEachMatch } from '../names'; +import { isRecord } from '../tokens'; + +/** + * The constants entry (@walkeros/explorer/design) is imported by server code + * (an email builder) and by an esbuild-bundled page stub: it must import + * nothing, carry no "use client" banner and stay out of the root entry, and + * every declared design export must point at a file the build produced. + * Runs against dist: explorer's turbo test task depends on its build. + */ +const packageDir = resolve(__dirname, '../../..'); +const read = (path: string): string => + readFileSync(resolve(packageDir, path), 'utf8'); + +function packageJson(): Record { + const json: unknown = JSON.parse(read('package.json')); + return isRecord(json) ? json : {}; +} + +/** The targets of every `./design*` key in the package exports. */ +function designExports(): unknown[] { + const exportsMap = packageJson().exports; + if (!isRecord(exportsMap)) return []; + return Object.entries(exportsMap) + .filter(([key]) => key.startsWith('./design')) + .map(([, target]) => target); +} + +function designTargets(): string[] { + return designExports().flatMap((target) => + typeof target === 'string' + ? [target] + : isRecord(target) + ? Object.values(target).filter( + (value): value is string => typeof value === 'string', + ) + : [], + ); +} + +function designRequireTargets(): unknown[] { + return designExports().flatMap((target) => + isRecord(target) && target.require !== undefined ? [target.require] : [], + ); +} + +it('the constants entry imports nothing', () => { + expect(read('src/design/index.ts')).not.toMatch( + /^\s*(?:import\b|export\s+(?:\*|\{[^}]*\})\s+from\b)|\brequire\(/m, + ); +}); + +it('the root entry does not re-export the design entry', () => { + expect(read('src/index.ts')).not.toMatch(/['"]\.\/design['"/]/); +}); + +it('declares the design subpaths, each pointing at a built file', () => { + const targets = designTargets(); + expect(targets).toEqual( + expect.arrayContaining([ + './dist/design/index.d.ts', + './dist/design/index.mjs', + './dist/design/index.cjs', + './dist/design/tokens.css', + './dist/design/base.css', + './dist/design/tailwind.css', + ]), + ); + expect( + targets.filter((target) => !existsSync(resolve(packageDir, target))), + ).toEqual([]); +}); + +it('every design require target is a .cjs file (explorer is "type": "module")', () => { + const targets = designRequireTargets(); + expect(targets.length).toBeGreaterThan(0); + expect( + targets.filter( + (target) => typeof target !== 'string' || !target.endsWith('.cjs'), + ), + ).toEqual([]); +}); + +it.each(['dist/design/index.mjs', 'dist/design/index.cjs'])( + '%s carries no "use client" and no import', + (file) => { + const code = read(file); + expect(code).not.toMatch(/["']use client["']/); + expect(code).not.toMatch(/\bimport\s|\brequire\(/); + }, +); + +it('ships walkeros-design-check as an executable Node script', () => { + const bin = packageJson().bin; + expect(isRecord(bin) ? bin['walkeros-design-check'] : undefined).toBe( + './dist/design/check.mjs', + ); + expect(read('dist/design/check.mjs').startsWith('#!/usr/bin/env node')).toBe( + true, + ); + expect( + statSync(resolve(packageDir, 'dist/design/check.mjs')).mode & 0o111, + ).not.toBe(0); +}); + +it('walkeros-design-check imports only node: built-ins', () => { + const specifiers: string[] = []; + forEachMatch( + /(?:\bfrom|\bimport|\brequire)\s*\(?\s*["']([^"']+)["']/g, + read('dist/design/check.mjs'), + (match) => specifiers.push(match[1]), + ); + expect(specifiers.length).toBeGreaterThan(0); + expect( + specifiers.filter((specifier) => !specifier.startsWith('node:')), + ).toEqual([]); +}); diff --git a/apps/explorer/src/design/__tests__/fixture.ts b/apps/explorer/src/design/__tests__/fixture.ts new file mode 100644 index 000000000..ba04a7be9 --- /dev/null +++ b/apps/explorer/src/design/__tests__/fixture.ts @@ -0,0 +1,157 @@ +/** + * A small token file in the artifact grammar that exercises every generator + * branch: themed and single colours, a single alias of a themed colour, a + * per-theme alias, an invariant rgba(), a single alias of a single literal, + * a themed and an invariant shadow, spacing, radius, zIndex, two families and + * a style with its own family. A fresh object per call, typed loosely enough + * for the guard tests to break it. + */ +export interface FixtureTokens { + name: string; + version: number; + meta?: unknown; + extra?: unknown; + color: { + themes: { id: string; name: string }[]; + tokens: { + name: string; + value: string | { dark?: string; light?: string }; + usage: string; + }[]; + }; + type: { + fonts: unknown[]; + families: Record; + groups: { + name: string; + family: string; + styles: { + name: string; + family?: string; + fontSize: string; + lineHeight: number | string; + fontWeight: number; + letterSpacing?: string; + usage: string; + }[]; + }[]; + }; + spacing: { tokens: { name: string; value: string; usage: string }[] }; + radius: { tokens: { name: string; value: string; usage: string }[] }; + shadow: { + tokens: { + name: string; + value: string | { dark: string; light: string }; + usage: string; + }[]; + }; + zIndex: { tokens: { name: string; value: string; usage: string }[] }; +} + +export function fixture(): FixtureTokens { + return { + name: 'Fixture', + version: 1, + color: { + themes: [ + { id: 'dark', name: 'Dark' }, + { id: 'light', name: 'Light' }, + ], + tokens: [ + { + name: 'bg', + value: { dark: '#111827', light: '#ffffff' }, + usage: 'Page ground.', + }, + { name: 'primary', value: '#01b5e2', usage: 'Brand fill.' }, + { + name: 'link', + value: { dark: '#4fb3e0', light: '#0076a0' }, + usage: 'Links.', + }, + { + name: 'info', + value: '{link}', + usage: 'A single alias of a themed colour.', + }, + { + name: 'focus', + value: { dark: '{primary}', light: '{link}' }, + usage: 'A per-theme alias.', + }, + { + name: 'code-bg', + value: 'rgba(41, 45, 62, 1)', + usage: 'An invariant rgba() colour.', + }, + { + name: 'accent', + value: '{primary}', + usage: 'A single alias of a single literal.', + }, + ], + }, + type: { + fonts: [], + families: { + sans: "system-ui, 'Segoe UI', sans-serif", + mono: 'ui-monospace, monospace', + }, + groups: [ + { + name: 'Text', + family: 'sans', + styles: [ + { + name: 'body', + fontSize: '16px', + lineHeight: 1.65, + fontWeight: 400, + usage: 'Body.', + }, + { + name: 'eyebrow', + fontSize: '13px', + lineHeight: 1.5, + fontWeight: 600, + letterSpacing: '0.12em', + usage: 'Eyebrow.', + }, + { + name: 'code', + family: 'mono', + fontSize: '0.88em', + lineHeight: 1.5, + fontWeight: 400, + usage: 'Its own family.', + }, + ], + }, + ], + }, + spacing: { + tokens: [{ name: 'gutter', value: '24px', usage: 'Side padding.' }], + }, + radius: { + tokens: [{ name: 'radius-md', value: '8px', usage: 'Buttons.' }], + }, + shadow: { + tokens: [ + { + name: 'ring', + value: { + dark: '0 0 0 4px rgba(1, 181, 226, 0.16)', + light: '0 0 0 4px rgba(1, 181, 226, 0.14)', + }, + usage: 'Highlight ring.', + }, + { + name: 'edge', + value: '0 0 0 1px rgba(0, 0, 0, 0.2)', + usage: 'An invariant shadow.', + }, + ], + }, + zIndex: { tokens: [{ name: 'z-modal', value: '50', usage: 'Dialogs.' }] }, + }; +} diff --git a/apps/explorer/src/design/__tests__/generate.test.ts b/apps/explorer/src/design/__tests__/generate.test.ts new file mode 100644 index 000000000..599dcc419 --- /dev/null +++ b/apps/explorer/src/design/__tests__/generate.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + loadDesignTokens, + renderConstants, + renderTailwindCss, + renderTokensCss, +} from '../generate'; +import { collectDeclaredNames } from '../names'; +import { parseDesignTokens, THEMES } from '../tokens'; +import { fixture } from './fixture'; + +const packageDir = resolve(__dirname, '../../..'); + +it('renders tokens.css, tailwind.css and the constants for the fixture', () => { + const tokens = parseDesignTokens(fixture()); + expect( + [ + renderTokensCss(tokens), + renderTailwindCss(tokens), + renderConstants(tokens), + ].join('\n/* ---- */\n'), + ).toMatchSnapshot(); +}); + +it('declares every colour and shadow of design/tokens.json in both themes', () => { + const tokens = loadDesignTokens(packageDir); + // header, invariant block, then one block per theme + const [, invariant, ...themeBlocks] = renderTokensCss(tokens) + .trimEnd() + .split('\n\n'); + const invariantNames = collectDeclaredNames(invariant); + const missing = THEMES.flatMap((theme, i) => { + const declared = collectDeclaredNames(themeBlocks[i]); + return [...tokens.colors, ...tokens.shadows] + .map((token) => token.name) + .filter((name) => !declared.has(name) && !invariantNames.has(name)) + .map((name) => `${theme}: ${name}`); + }); + expect(themeBlocks).toHaveLength(THEMES.length); + expect(missing).toEqual([]); +}); + +it('src/design/index.ts is the generator output for design/tokens.json (run `npm run build` in apps/explorer to refresh it)', () => { + expect(readFileSync(resolve(packageDir, 'src/design/index.ts'), 'utf8')).toBe( + renderConstants(loadDesignTokens(packageDir)), + ); +}); diff --git a/apps/explorer/src/design/__tests__/tokens.test.ts b/apps/explorer/src/design/__tests__/tokens.test.ts new file mode 100644 index 000000000..15ac7e877 --- /dev/null +++ b/apps/explorer/src/design/__tests__/tokens.test.ts @@ -0,0 +1,248 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseDesignTokens } from '../tokens'; +import { fixture, type FixtureTokens } from './fixture'; + +const packageDir = resolve(__dirname, '../../..'); +const realTokens = (): unknown => { + const json: unknown = JSON.parse( + readFileSync(resolve(packageDir, 'design/tokens.json'), 'utf8'), + ); + return json; +}; + +it('accepts the fixture and design/tokens.json', () => { + expect(parseDesignTokens(fixture()).colors).toHaveLength(7); + expect(parseDesignTokens(realTokens()).colors.length).toBeGreaterThan(40); +}); + +it.each void, string]>([ + [ + 'a meta block', + (t) => { + t.meta = {}; + }, + 'meta', + ], + [ + 'an unknown top-level key', + (t) => { + t.extra = 1; + }, + 'extra', + ], + [ + 'themes other than dark, light', + (t) => { + t.color.themes[1].id = 'dim'; + }, + 'color.themes', + ], + [ + 'an uppercase hex', + (t) => { + t.color.tokens[1].value = '#01B5E2'; + }, + 'color.tokens[1].value', + ], + [ + 'a var() colour', + (t) => { + t.color.tokens[1].value = 'var(--x)'; + }, + 'color.tokens[1].value', + ], + [ + 'an oklch() colour', + (t) => { + t.color.tokens[1].value = 'oklch(0.7 0.1 230)'; + }, + 'color.tokens[1].value', + ], + [ + 'an rgb() channel over 255', + (t) => { + t.color.tokens[5].value = 'rgba(41, 45, 620, 1)'; + }, + 'color.tokens[5].value', + ], + [ + 'an alias of a missing token', + (t) => { + t.color.tokens[3].value = '{nowhere}'; + }, + 'color.tokens[3].value', + ], + [ + 'an alias cycle', + (t) => { + t.color.tokens[1].value = '{focus}'; + }, + 'color.tokens[1].value', + ], + [ + 'a per-theme value without light', + (t) => { + t.color.tokens[0].value = { dark: '#111827' }; + }, + 'color.tokens[0].value.light', + ], + [ + 'a spacing value without a unit', + (t) => { + t.spacing.tokens[0].value = '24'; + }, + 'spacing.tokens[0].value', + ], + [ + 'a radius name outside radius-*', + (t) => { + t.radius.tokens[0].name = 'corner-md'; + }, + 'radius.tokens[0].name', + ], + [ + 'a fractional z-index', + (t) => { + t.zIndex.tokens[0].value = '5.5'; + }, + 'zIndex.tokens[0].value', + ], + [ + 'a shadow with var()', + (t) => { + t.shadow.tokens[0].value = '0 0 0 4px var(--x)'; + }, + 'shadow.tokens[0].value', + ], + [ + 'a font file', + (t) => { + t.type.fonts = [{ family: 'Geist', file: 'Geist.woff2' }]; + }, + 'type.fonts', + ], + [ + 'a family stack with a function', + (t) => { + t.type.families.sans = 'var(--x)'; + }, + 'type.families.sans', + ], + [ + 'a style family that does not exist', + (t) => { + t.type.groups[0].styles[2].family = 'serif'; + }, + 'type.groups[0].styles[2].family', + ], + [ + 'a style family inherited from Object.prototype', + (t) => { + t.type.groups[0].styles[2].family = 'constructor'; + }, + 'type.groups[0].styles[2].family', + ], + [ + 'a style named like a colour', + (t) => { + t.type.groups[0].styles[0].name = 'bg'; + }, + 'type.groups[0].styles[0].name', + ], + [ + 'a name declared in two families', + (t) => { + t.spacing.tokens[0].name = 'bg'; + }, + 'spacing.tokens[0].name', + ], + [ + 'a name with a trailing hyphen', + (t) => { + t.color.tokens[1].name = 'primary-'; + }, + 'color.tokens[1].name', + ], + [ + 'a name with a double hyphen', + (t) => { + t.spacing.tokens[0].name = 'gutter--x'; + }, + 'spacing.tokens[0].name', + ], + [ + 'a name longer than 64 characters', + (t) => { + t.spacing.tokens[0].name = 'g'.repeat(65); + }, + 'spacing.tokens[0].name', + ], + [ + 'a colour whose constant is a reserved word', + (t) => { + t.color.tokens.push({ name: 'default', value: '#000000', usage: 'x' }); + }, + 'color.tokens[7].name', + ], + [ + 'two colours with one constant name', + (t) => { + t.color.tokens.push( + { name: 'bg-2', value: '#000000', usage: 'x' }, + { name: 'bg2', value: '#000000', usage: 'x' }, + ); + }, + 'color.tokens[8].name', + ], + [ + 'a name inside a Tailwind theme namespace', + (t) => { + t.spacing.tokens[0].name = 'container-xl'; + }, + 'spacing.tokens[0].name', + ], + [ + 'a font family inside a narrower Tailwind namespace', + (t) => { + t.type.families['weight-bold'] = 'system-ui'; + }, + 'type.families.weight-bold', + ], + [ + 'a type style inside a Tailwind namespace nested under --text-*', + (t) => { + t.type.groups[0].styles[0].name = 'shadow-x'; + }, + 'type.groups[0].styles[0].name', + ], + [ + 'a type style named like a key Tailwind keeps apart from --text-* sizes', + (t) => { + t.type.groups[0].styles[0].name = 'indent'; + }, + 'type.groups[0].styles[0].name', + ], +])('rejects %s with its path', (_label, mutate, path) => { + const tokens = fixture(); + mutate(tokens); + expect(() => parseDesignTokens(tokens)).toThrow( + `design/tokens.json ${path}:`, + ); +}); + +it('says a name must start with a lowercase letter', () => { + const tokens = fixture(); + tokens.spacing.tokens[0].name = '2-gutter'; + expect(() => parseDesignTokens(tokens)).toThrow( + 'spacing.tokens[0].name: "2-gutter" is not a token name: a name must start with a lowercase letter', + ); +}); + +it('z-index tokens ascend in declared order', () => { + const values = parseDesignTokens(realTokens()).zIndex.map((token) => + Number(token.value), + ); + expect(values).toEqual([...values].sort((a, b) => a - b)); + expect(new Set(values).size).toBe(values.length); +}); diff --git a/apps/explorer/src/design/base.css b/apps/explorer/src/design/base.css new file mode 100644 index 000000000..05983e1cb --- /dev/null +++ b/apps/explorer/src/design/base.css @@ -0,0 +1,55 @@ +/* + * walkerOS base rules. Opt-in and global: import once per page after + * tokens.css. Explorer's own reset stays scoped to .elb-explorer. + */ +@layer base { + :root, + :host, + [data-theme] { + --motion: 180ms; + --ease: cubic-bezier(0.2, 0, 0, 1); + } + + /* The one media query of the design system. */ + @media (prefers-reduced-motion: reduce) { + :root, + :host, + [data-theme] { + --motion: 0ms; + } + } + + :root, + :host, + [data-theme='dark'] { + color-scheme: dark; + } + + [data-theme='light'] { + color-scheme: light; + } + + /* Islands recompute these from their own theme's tokens. */ + html, + :host, + [data-theme] { + font-family: var(--font-sans); + color: var(--fg); + scrollbar-color: var(--border-strong) transparent; + } + + /* Only the page paints a ground; a background on :host would cover the customer page. */ + html { + background-color: var(--bg); + } + + :focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; + } + + ::selection { + background-color: var(--primary); + color: var(--on-primary); + } +} diff --git a/apps/explorer/src/design/check/bin.ts b/apps/explorer/src/design/check/bin.ts new file mode 100644 index 000000000..8fc01fcb9 --- /dev/null +++ b/apps/explorer/src/design/check/bin.ts @@ -0,0 +1,25 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { main } from './cli'; + +const write = + (stream: NodeJS.WriteStream) => + (line: string): void => { + stream.write(`${line}\n`); + }; + +try { + process.exitCode = main(process.argv.slice(2), { + cwd: process.cwd(), + // dist/design: the bin ships beside tokens.css, tailwind.css and base.css. + designDir: dirname(fileURLToPath(import.meta.url)), + out: write(process.stdout), + err: write(process.stderr), + }); +} catch (error) { + // An unexpected failure is a misconfiguration (exit 2), never "findings" (1) or clean (0). + process.stderr.write( + `walkeros-design-check: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 2; +} diff --git a/apps/explorer/src/design/check/cli.ts b/apps/explorer/src/design/check/cli.ts new file mode 100644 index 000000000..6bb448ca8 --- /dev/null +++ b/apps/explorer/src/design/check/cli.ts @@ -0,0 +1,180 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join, relative, resolve, sep } from 'node:path'; +import { collectDeclaredNames } from '../names'; +import { globToRegExp } from './glob'; +import { RULES, scanFile, type Finding, type RuleId } from './rules'; + +export interface CliIo { + readonly cwd: string; + /** The directory holding the shipped tokens.css, tailwind.css and base.css. */ + readonly designDir: string; + readonly out: (line: string) => void; + readonly err: (line: string) => void; +} + +interface Allow { + /** Set for `--allow :`: only that rule's findings are dropped. */ + readonly rule?: RuleId; + readonly pattern: RegExp; +} + +const SCANNED = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.css', + '.scss', + '.mdx', + '.html', + '.svg', +]); +const SKIPPED_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + 'storybook-static', +]); +const DESIGN_CSS = ['tokens.css', 'tailwind.css', 'base.css']; +const USAGE = + 'usage: walkeros-design-check [--allow [:]]... [--allow-var ]... ...'; +const SCOPED = /^([a-z-]+):(.+)$/; + +const isRule = (value: string): value is RuleId => + RULES.some((rule) => rule === value); + +function parseAllow(value: string): Allow | string { + const scoped = SCOPED.exec(value); + if (scoped !== null) { + const rule = scoped[1]; + if (!isRule(rule)) return `unknown rule "${rule}" in --allow ${value}`; + const pattern = globToRegExp(scoped[2]); + return pattern === undefined + ? `invalid glob in --allow ${value}` + : { rule, pattern }; + } + const pattern = globToRegExp(value); + return pattern === undefined + ? `invalid glob in --allow ${value}` + : { pattern }; +} + +function usage(io: CliIo, problem: string): number { + io.err(`walkeros-design-check: ${problem}`); + io.err(USAGE); + return 2; +} + +function walk(path: string, into: string[]): void { + if (statSync(path).isFile()) { + if (SCANNED.has(extname(path))) into.push(path); + return; + } + const entries = readdirSync(path, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + ); + for (const entry of entries) { + if (entry.name.startsWith('.') || SKIPPED_DIRS.has(entry.name)) continue; + const child = join(path, entry.name); + if (entry.isDirectory()) walk(child, into); + else if (entry.isFile() && SCANNED.has(extname(entry.name))) + into.push(child); + } +} + +/** Exit 0: clean. 1: findings. 2: usage error, missing path, nothing scanned, or explorer not built. */ +export function main(argv: readonly string[], io: CliIo): number { + const allows: Allow[] = []; + const allowVarPrefixes: string[] = []; + const roots: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--allow' || arg === '--allow-var') { + const value = argv[i + 1]; + i++; + if (value === undefined || value === '' || value.startsWith('--')) + return usage(io, `${arg} needs a value`); + if (arg === '--allow-var') { + allowVarPrefixes.push(value); + continue; + } + const allow = parseAllow(value); + if (typeof allow === 'string') return usage(io, allow); + allows.push(allow); + } else if (arg.startsWith('--')) { + return usage(io, `unknown option ${arg}`); + } else { + roots.push(arg); + } + } + if (roots.length === 0) return usage(io, 'no path given'); + + const missing = DESIGN_CSS.filter( + (file) => !existsSync(join(io.designDir, file)), + ); + if (missing.length > 0) { + io.err( + `walkeros-design-check: ${missing.join(', ')} missing in ${io.designDir}; build @walkeros/explorer first`, + ); + return 2; + } + const designNames = collectDeclaredNames( + DESIGN_CSS.map((file) => + readFileSync(join(io.designDir, file), 'utf8'), + ).join('\n'), + ); + + const files: string[] = []; + for (const root of roots) { + const path = resolve(io.cwd, root); + if (!existsSync(path)) { + io.err(`walkeros-design-check: ${root} does not exist`); + return 2; + } + walk(path, files); + } + const sources = files.map((path) => ({ + file: relative(io.cwd, path).split(sep).join('/'), + text: readFileSync(path, 'utf8'), + })); + const fileAllowed = (file: string): boolean => + allows.some( + (allow) => allow.rule === undefined && allow.pattern.test(file), + ); + const ruleAllowed = (finding: Finding): boolean => + allows.some( + (allow) => + allow.rule === finding.rule && allow.pattern.test(finding.file), + ); + const scanned = sources.filter((source) => !fileAllowed(source.file)); + if (scanned.length === 0) { + io.err( + 'walkeros-design-check: no file scanned; check the paths and --allow globs', + ); + return 2; + } + + // A variable declared anywhere under the paths counts, allowed files included. + const context = { + designNames, + declaredNames: collectDeclaredNames( + sources.map((source) => source.text).join('\n'), + ), + allowVarPrefixes, + }; + const findings = scanned + .flatMap((source) => scanFile(source.file, source.text, context)) + .filter((finding) => !ruleAllowed(finding)); + for (const finding of findings) { + io.out( + `${finding.file}:${finding.line}:${finding.column} ${finding.rule} ${finding.match}`, + ); + } + io.err( + `walkeros-design-check: ${findings.length} finding(s) in ${scanned.length} file(s)`, + ); + return findings.length > 0 ? 1 : 0; +} diff --git a/apps/explorer/src/design/check/glob.ts b/apps/explorer/src/design/check/glob.ts new file mode 100644 index 000000000..c1fb94a44 --- /dev/null +++ b/apps/explorer/src/design/check/glob.ts @@ -0,0 +1,34 @@ +const escape = (text: string): string => + text.replace(/[.+^$()|[\]\\{}*?]/g, '\\$&'); + +/** + * Translate a consumer glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp + * over `/`-separated relative paths; undefined for a malformed glob. + */ +export function globToRegExp(glob: string): RegExp | undefined { + let source = ''; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === '*' && glob[i + 1] === '*') { + const slash = glob[i + 2] === '/'; + source += slash ? '(?:.*/)?' : '.*'; + i += slash ? 2 : 1; + } else if (char === '*') { + source += '[^/]*'; + } else if (char === '?') { + source += '[^/]'; + } else if (char === '{') { + const end = glob.indexOf('}', i); + if (end === -1) return undefined; + source += `(?:${glob + .slice(i + 1, end) + .split(',') + .map(escape) + .join('|')})`; + i = end; + } else { + source += escape(char); + } + } + return new RegExp(`^${source}$`); +} diff --git a/apps/explorer/src/design/check/rules.ts b/apps/explorer/src/design/check/rules.ts new file mode 100644 index 000000000..c906763b5 --- /dev/null +++ b/apps/explorer/src/design/check/rules.ts @@ -0,0 +1,240 @@ +/** + * Rules of walkeros-design-check: pure functions over one file's text. The CLI + * walks the files and applies the consumer's allowlists. + */ +import { forEachMatch } from '../names'; +import { TAILWIND_VARIABLES } from '../tailwind-namespaces'; + +export const RULES = [ + 'color-literal', + 'named-color', + 'palette-class', + 'white-black-class', + 'dark-variant', + 'shadow-class', + 'z-class', + 'text-size-class', + 'radius-class', + 'var-fallback', + 'undeclared-var', + 'color-scheme', + 'z-index', +] as const; +export type RuleId = (typeof RULES)[number]; + +export interface Finding { + readonly file: string; + readonly line: number; + readonly column: number; + readonly rule: RuleId; + readonly match: string; +} + +export interface ScanContext { + /** Custom properties the design system declares (tokens.css, tailwind.css, base.css). */ + readonly designNames: ReadonlySet; + /** Custom properties declared anywhere under the scanned paths. */ + readonly declaredNames: ReadonlySet; + /** Consumer-supplied prefixes of third-party variables, such as `ifm-`. */ + readonly allowVarPrefixes: readonly string[]; +} + +const NAMED_COLORS = new Set( + `aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown + burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan + darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid + darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet + deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro + ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki + lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow + lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray + lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine + mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise + mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab + orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru + pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown + seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan + teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`.split( + /\s+/, + ), +); + +/** Tailwind 4.3's palettes; the bridge resets all of them. */ +const PALETTE = + 'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|mauve|olive|mist|taupe'; +const COLOR_UTILITY = + 'bg|text|border(?:-[xytrblse])?|outline|ring(?:-offset)?|divide|fill|stroke|from|via|to|placeholder|caret|accent|decoration|shadow|inset-shadow|drop-shadow|text-shadow'; +const START = '(? = [ + [ + 'palette-class', + new RegExp( + `${START}(?:${COLOR_UTILITY})-(?:${PALETTE})-(?:50|[1-9]00|950)(?:/\\d+)?${END}`, + 'g', + ), + ], + [ + 'white-black-class', + new RegExp( + `${START}(?:${COLOR_UTILITY})-(?:white|black)(?:/\\d+)?${END}`, + 'g', + ), + ], + ['dark-variant', new RegExp(`${START}dark:(?=[A-Za-z0-9_\\[!(-])`, 'g')], + [ + 'shadow-class', + // Bare drop-shadow is flagged too (the reset removes it), but not a CSS + // drop-shadow() call. Bare shadow is not: "shadow root" prose would fire. + new RegExp( + `${START}(?:(?:inset-|drop-|text-)?shadow-(?:2xs|xs|sm|md|lg|xl|2xl|inner)${END}|drop-shadow(?![A-Za-z0-9_(-]))`, + 'g', + ), + ], + ['z-class', new RegExp(`${START}-?z-(?:\\d+|\\[-?\\d+\\])${END}`, 'g')], + [ + 'text-size-class', + new RegExp(`${START}text-(?:xs|sm|base|lg|xl|[2-9]xl)${END}`, 'g'), + ], + // An arbitrary value that reads a design radius (`rounded-[calc(var(--radius-md)-1px)]`) is not flagged. + [ + 'radius-class', + new RegExp( + `${START}rounded${SIDE}(?:-(?:2xl|3xl|4xl|\\[(?![^\\]]*var\\(--radius-)[^\\]\\s]+\\]))?${END}`, + 'g', + ), + ], +]; + +const STYLE_FILE = /\.(?:css|scss)$/; +const HEX = + /(?`, in style files and inside script strings (a stub's CSS template). */ +const Z_INDEX_CSS = /(?`. */ +const Z_INDEX_SCRIPT = /(? + regions.push({ offset: match.index, text: match[0] }), + ); + return regions; +} + +function positions( + text: string, +): (index: number) => { line: number; column: number } { + const starts = [0]; + for (let i = 0; i < text.length; i++) + if (text[i] === '\n') starts.push(i + 1); + return (index) => { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (starts[mid] <= index) low = mid; + else high = mid - 1; + } + return { line: low + 1, column: index - starts[low] + 1 }; + }; +} + +export function scanFile( + file: string, + text: string, + context: ScanContext, +): Finding[] { + const style = STYLE_FILE.test(file); + const position = positions(text); + const findings: Finding[] = []; + const add = (rule: RuleId, index: number, match: string): void => { + findings.push({ file, ...position(index), rule, match }); + }; + + forEachMatch(HEX, text, (match) => + add('color-literal', match.index, match[0]), + ); + forEachMatch(COLOR_FUNCTION, text, (match) => + add('color-literal', match.index, match[0]), + ); + if (style) { + forEachMatch(CSS_COLOR_FUNCTION, text, (match) => + add('color-literal', match.index, match[0]), + ); + forEachMatch(STYLE_DECLARATION, text, (match) => { + forEachMatch(WORD, match[2].replace(URL_FUNCTION, ' '), (word) => { + if (NAMED_COLORS.has(word[0].toLowerCase())) + add('named-color', match.index, word[0]); + }); + }); + } else { + forEachMatch(SCRIPT_COLOR_PROPERTY, text, (match) => { + if (NAMED_COLORS.has(match[1].toLowerCase())) + add('named-color', match.index, match[1]); + }); + } + + for (const region of classRegions(style, text)) { + for (const [rule, pattern] of CLASS_RULES) { + forEachMatch(pattern, region.text, (match) => + add(rule, region.offset + match.index, match[0]), + ); + } + } + + const declared = (name: string): boolean => + context.declaredNames.has(name) || + TAILWIND_VARIABLES.some((prefix) => name.startsWith(prefix)) || + context.allowVarPrefixes.some((prefix) => name.startsWith(prefix)); + forEachMatch(VAR_REFERENCE, text, (match) => { + const name = match[1]; + if (context.designNames.has(name)) { + if (match[2] === ',') add('var-fallback', match.index, match[0]); + } else if (!declared(name)) { + add('undeclared-var', match.index, match[0]); + } + }); + forEachMatch(TAILWIND_VAR, text, (match) => { + if (!context.designNames.has(match[1]) && !declared(match[1])) { + add('undeclared-var', match.index + 1, match[0].slice(1)); + } + }); + forEachMatch(COLOR_SCHEME, text, (match) => + add('color-scheme', match.index, match[0]), + ); + forEachMatch(Z_INDEX_CSS, text, (match) => + add('z-index', match.index, match[0]), + ); + if (!style) + forEachMatch(Z_INDEX_SCRIPT, text, (match) => + add('z-index', match.index, match[0]), + ); + + return findings.sort( + (a, b) => + a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule), + ); +} diff --git a/apps/explorer/src/design/generate.ts b/apps/explorer/src/design/generate.ts new file mode 100644 index 000000000..216bcc519 --- /dev/null +++ b/apps/explorer/src/design/generate.ts @@ -0,0 +1,178 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + aliasTarget, + camelName, + DesignTokenError, + isThemedColor, + parseDesignTokens, + resolveColor, + THEMES, + type DesignTokens, + type ThemedValue, + type ThemeId, +} from './tokens'; +import { TAILWIND_RESET_NAMESPACES } from './tailwind-namespaces'; + +const CSS_HEADER = + '/* walkerOS design tokens, generated from design/tokens.json by src/design/generate.ts. Edit the JSON, never this file. */'; +const TS_HEADER = + '/* walkerOS design constants, generated from design/tokens.json by src/design/generate.ts. Edit the JSON and run the explorer build, never this file. */'; + +const declaration = (name: string, value: string): string => + `--${name}: ${value};`; + +function block(selector: string, declarations: readonly string[]): string { + return `${selector} {\n${declarations.map((line) => ` ${line}`).join('\n')}\n}`; +} + +function themeValue(value: string | ThemedValue, theme: ThemeId): string { + const v = typeof value === 'string' ? value : value[theme]; + const target = aliasTarget(v); + return target === undefined ? v : `var(--${target})`; +} + +/** The literal of a single-valued, non-alias token: declared once, the same in every theme and island. */ +function invariantValue(value: string | ThemedValue): string | undefined { + return typeof value === 'string' && aliasTarget(value) === undefined + ? value + : undefined; +} + +export function loadDesignTokens(packageDir: string): DesignTokens { + const file = join(packageDir, 'design', 'tokens.json'); + let json: unknown; + try { + json = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + throw new DesignTokenError( + '(file)', + error instanceof Error ? error.message : String(error), + ); + } + return parseDesignTokens(json); +} + +export function renderTokensCss(tokens: DesignTokens): string { + const invariant: string[] = []; + const themed: Record = { dark: [], light: [] }; + for (const token of [...tokens.colors, ...tokens.shadows]) { + const literal = invariantValue(token.value); + if (literal !== undefined) { + invariant.push(declaration(token.name, literal)); + } else { + // Aliases are re-declared per theme: var() resolves where it is declared, + // so an island of the other theme must re-declare them to follow it. + for (const theme of THEMES) + themed[theme].push( + declaration(token.name, themeValue(token.value, theme)), + ); + } + } + for (const token of [...tokens.spacing, ...tokens.radius, ...tokens.zIndex]) { + invariant.push(declaration(token.name, token.value)); + } + for (const [key, stack] of Object.entries(tokens.families)) + invariant.push(declaration(`font-${key}`, stack)); + const themeBlocks = THEMES.map((theme, index) => + block( + index === 0 + ? `:root, :host, [data-theme="${theme}"]` + : `[data-theme="${theme}"]`, + themed[theme], + ), + ); + return `${[CSS_HEADER, block(':root, :host, [data-theme]', invariant), ...themeBlocks].join('\n\n')}\n`; +} + +export function renderTailwindCss(tokens: DesignTokens): string { + const theme = [ + ...TAILWIND_RESET_NAMESPACES.map( + (namespace) => `--${namespace}-*: initial;`, + ), + '--spacing: 4px;', + ]; + for (const token of tokens.radius) + theme.push(declaration(token.name, token.value)); + for (const [key, stack] of Object.entries(tokens.families)) + theme.push(declaration(`font-${key}`, stack)); + for (const style of tokens.groups.flatMap((group) => group.styles)) { + theme.push(declaration(`text-${style.name}`, style.fontSize)); + theme.push( + declaration(`text-${style.name}--line-height`, String(style.lineHeight)), + ); + theme.push( + declaration(`text-${style.name}--font-weight`, String(style.fontWeight)), + ); + if (style.letterSpacing !== undefined) { + theme.push( + declaration(`text-${style.name}--letter-spacing`, style.letterSpacing), + ); + } + } + const inline = tokens.colors.map((token) => + declaration(`color-${token.name}`, `var(--${token.name})`), + ); + for (const shadow of tokens.shadows) { + if (typeof shadow.value === 'string') + theme.push(declaration(`shadow-${shadow.name}`, shadow.value)); + else + inline.push( + declaration(`shadow-${shadow.name}`, `var(--${shadow.name})`), + ); + } + return `${[CSS_HEADER, '@import "./tokens.css";', block('@theme', theme), block('@theme inline', inline)].join('\n\n')}\n`; +} + +const comment = (usage: string): string => + usage.replace(/\s+/g, ' ').replace(/\*\//g, '* /'); + +export function renderConstants(tokens: DesignTokens): string { + const lines = [ + TS_HEADER, + '', + `export type Theme = ${THEMES.map((theme) => `'${theme}'`).join(' | ')};`, + ]; + for (const token of tokens.colors) { + const value = isThemedColor(tokens, token.name) + ? `{ ${THEMES.map((theme) => `${theme}: ${JSON.stringify(resolveColor(tokens, token.name, theme))}`).join(', ')} } as const` + : JSON.stringify(resolveColor(tokens, token.name, THEMES[0])); + lines.push( + '', + `/** ${comment(token.usage)} */`, + `export const ${camelName(token.name)} = ${value};`, + ); + } + for (const [key, stack] of Object.entries(tokens.families)) { + lines.push( + '', + `/** Font family \`${key}\`. */`, + `export const ${camelName(`font-${key}`)} = ${JSON.stringify(stack)};`, + ); + } + return `${lines.join('\n')}\n`; +} + +/** + * Parse design/tokens.json and write every output. Renders everything before + * writing anything, so a failure leaves no partial output; rewrites a file only + * when its content changed. + */ +export function writeDesign(packageDir: string): void { + const tokens = loadDesignTokens(packageDir); + const dist = join(packageDir, 'dist', 'design'); + const outputs: ReadonlyArray = [ + [join(dist, 'tokens.css'), renderTokensCss(tokens)], + [join(dist, 'tailwind.css'), renderTailwindCss(tokens)], + [ + join(dist, 'base.css'), + readFileSync(join(packageDir, 'src', 'design', 'base.css'), 'utf8'), + ], + [join(packageDir, 'src', 'design', 'index.ts'), renderConstants(tokens)], + ]; + mkdirSync(dist, { recursive: true }); + for (const [file, content] of outputs) { + if (!existsSync(file) || readFileSync(file, 'utf8') !== content) + writeFileSync(file, content); + } +} diff --git a/apps/explorer/src/design/index.ts b/apps/explorer/src/design/index.ts new file mode 100644 index 000000000..252aa4154 --- /dev/null +++ b/apps/explorer/src/design/index.ts @@ -0,0 +1,267 @@ +/* walkerOS design constants, generated from design/tokens.json by src/design/generate.ts. Edit the JSON and run the explorer build, never this file. */ + +export type Theme = 'dark' | 'light'; + +/** Page background (hero, features, FAQ, closing CTA). Default ground for `fg`, `fg-2`, `link`. */ +export const bg = { dark: "#111827", light: "#ffffff" } as const; + +/** Alternating section band (Why, More features, Plans) and footer. Switch between `bg` and `bg-2` to separate sections, always with a top `border` rule. */ +export const bg2 = { dark: "#151e2e", light: "#f6f7f9" } as const; + +/** Cards, plan tiers, FAQ items, the install-command chip and secondary buttons. */ +export const surface = { dark: "#1a2232", light: "#ffffff" } as const; + +/** Inline code background, icon-button hover. */ +export const surface2 = { dark: "#222b3c", light: "#f1f3f6" } as const; + +/** Hairline for section rules, header underline, card and input outlines. Decorative only; it does not meet 3:1, so never rely on it alone to mark a control. */ +export const border = { dark: "#2f3a4d", light: "#e3e6eb" } as const; + +/** Headings and body copy on `bg`, `bg-2`, `surface`, `surface-2` (14:1+ both themes). */ +export const fg = { dark: "#ffffff", light: "#111827" } as const; + +/** Secondary copy: leads, card descriptions, list items, footer links. 5.5:1+ on every ground. */ +export const fg2 = { dark: "#9aa3b2", light: "#4b5563" } as const; + +/** Tertiary text: tier labels, `$` prompt, FAQ `+`, copyright, input placeholders. 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes (dark 4.6:1+, light 4.7:1+). */ +export const fg3 = { dark: "#8c93a2", light: "#656d7a" } as const; + +/** elbwalker blue. Primary button fill, check icons, highlight word in the H1, focus ring, announcement bar. Light theme: 2.4:1 on white — fine as a fill under `on-primary`, too faint for text or as the only marker (flagged, kept from source). */ +export const primary = { dark: "#01b5e2", light: "#01b5e2" } as const; + +/** Text and icons on `primary` fills (6.9:1). Never put white on `primary` (2.4:1) — the current walkeros.io buttons do; the relaunch fixes it. */ +export const onPrimary = { dark: "#04222b", light: "#04222b" } as const; + +/** Links, eyebrows, mono step numbers; `info` and the light `focus` ring use it. 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes (dark 6.0:1+, light 4.6:1+). Underline on hover only. */ +export const link = { dark: "#4fb3e0", light: "#0076a0" } as const; + +/** Soft primary halo: hero radial glow and the 4px ring around the highlighted plan. */ +export const glow = { dark: "rgba(1, 181, 226, 0.16)", light: "rgba(1, 181, 226, 0.14)" } as const; + +/** Code-block panel (Palenight family), both themes. */ +export const codeBg = "#292d3e"; + +/** Code-block title bar / tab strip. */ +export const codeBar = "#232636"; + +/** Code-block outline. */ +export const codeBorder = "#444a5e"; + +/** Default code text on `code-bg` (8:1). */ +export const codeFg = "#bfc7d5"; + +/** Product visualisation panel (tagging and mapping demos). Visualisations stay dark in both themes. */ +export const vizBg = "#1a2232"; + +/** Code pane inside a visualisation. */ +export const vizCodeBg = "#131c2b"; + +/** Outlines inside visualisations. */ +export const vizBorder = "#334055"; + +/** Primary text inside visualisations. */ +export const vizFg = "#e8eaf0"; + +/** Legend and meta text inside visualisations (5.4:1 on `viz-code-bg`). */ +export const vizFg2 = "#8e96aa"; + +/** Code comments and file kinds inside visualisations. */ +export const vizComment = "#6c7590"; + +/** Gutter line numbers. Decorative, below 3:1. */ +export const vizLineNumber = "#4a5165"; + +/** Raised surfaces inside visualisations: the rendered teaser card, table rules. */ +export const vizSurface = "#2b3649"; + +/** Syntax: HTML/JSX tag names in visualisation code (6:1+ on `viz-code-bg`). */ +export const vizTag = "#e38fd6"; + +/** Syntax: string values in visualisation code and JSON cells. */ +export const vizString = "#e5c07b"; + +/** Syntax: numbers and booleans in mapping code. */ +export const vizNumber = "#f78c6c"; + +/** Syntax: punctuation and plain code text; visualisation captions. */ +export const vizPunct = "#9aa3b8"; + +/** Event part: entity (e.g. `product`). Used for syntax highlight, legend chip and the highlighted DOM region in tagging demos. */ +export const eventEntity = "#c5e478"; + +/** Event part: action (e.g. `view`, `add`). */ +export const eventAction = "#c792ea"; + +/** Event part: entity property (`data-elb-product="name"`). */ +export const eventProperty = "#f0717f"; + +/** Event part: context. */ +export const eventContext = "#f2a65a"; + +/** Event part: globals. */ +export const eventGlobals = "#7fc4ea"; + +/** Tag kind: user (who the visitor is). Like the five event parts it sits only on dark grounds (`viz-bg`, legends, chips, mark layers) and always pairs with its word. 5.2:1 on `viz-bg`; kept apart from `event-property`, `event-action` and `danger`. */ +export const eventUser = "#e65fc4"; + +/** Tag kind: consent (consent states and requirements). Dark grounds only, always with its word. 10.7:1 on `viz-bg`. */ +export const eventConsent = "#cbd5e1"; + +/** Brand scale (walkeros.io docs). Light-theme badge and admonition background. */ +export const elbwalker100 = "#edfeff"; + +/** Brand scale. Admonition highlight (light). */ +export const elbwalker200 = "#abf8ff"; + +/** Brand scale. Admonition text (dark). */ +export const elbwalker300 = "#69ecfe"; + +/** Brand scale. */ +export const elbwalker400 = "#27dbfe"; + +/** Brand scale anchor = `primary`. Admonition border. */ +export const elbwalker500 = "#01b5e2"; + +/** Brand scale. Badge text (light), badge fill (dark). */ +export const elbwalker700 = "#015372"; + +/** Brand scale. Admonition background (dark). */ +export const elbwalker800 = "#00283a"; + +/** Brand scale, darkest step. */ +export const elbwalker900 = "#000203"; + +/** Borders that mark a control: input, select, checkbox and switch outlines, the 1px edge of menus, popovers and dialogs, scrollbar thumbs, flow edges. 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes; `border` stays decorative. */ +export const borderStrong = { dark: "#6b778d", light: "#818a9a" } as const; + +/** Focus ring, 2px solid with a 3px offset, on every interactive element: `primary` in dark, `link` in light, where `primary` is 2.2:1. 3:1 or more on every ground. */ +export const focus = { dark: "#01b5e2", light: "#0076a0" } as const; + +/** Scrim behind modals and drawers. The layer above it is `surface` with a 1px `border-strong` edge, never a shadow. */ +export const backdrop = { dark: "rgba(3, 7, 18, 0.72)", light: "rgba(17, 24, 39, 0.4)" } as const; + +/** Errors, destructive actions and invalid fields, always with an icon and a word. Text at 4.5:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. Kept apart from every event and step colour, `event-property` included. */ +export const danger = { dark: "#ffa4ac", light: "#b91c1c" } as const; + +/** Text and icons on a `danger` fill such as a destructive button (dark 8.6:1, light 6.5:1). */ +export const onDanger = { dark: "#450a0a", light: "#ffffff" } as const; + +/** Success and healthy states, always with an icon and a word. Sits on the blue side of green, so it stays apart from `danger` with red-green colour blindness, and apart from every step and event colour. Text at 4.5:1 or more on every ground. */ +export const success = { dark: "#8cf9ff", light: "#07675d" } as const; + +/** Warnings and states that need attention, always with an icon and a word. Kept apart from `step-transformer` and `event-context`. Text at 4.5:1 or more on every ground. */ +export const warning = { dark: "#cabb00", light: "#886000" } as const; + +/** Neutral information, always with an icon and a word. The `link` colour in both themes. */ +export const info = { dark: "#4fb3e0", light: "#0076a0" } as const; + +/** Tinted fill for danger banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint). */ +export const dangerBg = { dark: "rgba(255, 164, 172, 0.16)", light: "rgba(185, 28, 28, 0.1)" } as const; + +/** Tinted fill for success banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint). */ +export const successBg = { dark: "rgba(140, 249, 255, 0.16)", light: "rgba(7, 103, 93, 0.1)" } as const; + +/** Tinted fill for warning banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint). */ +export const warningBg = { dark: "rgba(202, 187, 0, 0.16)", light: "rgba(136, 96, 0, 0.1)" } as const; + +/** Tinted fill for information banners and badges. Text on it is `fg`; the status colour marks the icon and the border (3:1 or more on the tint). */ +export const infoBg = { dark: "rgba(79, 179, 224, 0.16)", light: "rgba(0, 118, 160, 0.1)" } as const; + +/** Flow step colour: sources. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. */ +export const stepSource = { dark: "#6ee7b7", light: "#428569" } as const; + +/** Flow step colour: transformers. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. */ +export const stepTransformer = { dark: "#fcd34d", light: "#bb7000" } as const; + +/** Flow step colour: the collector. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. */ +export const stepCollector = { dark: "#7dd3fc", light: "#0c529b" } as const; + +/** Flow step colour: destinations. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. */ +export const stepDestination = { dark: "#c4b5fd", light: "#6d1fd8" } as const; + +/** Flow step colour: stores. Badges, node accents and edges, always beside the step word; 3:1 or more on `bg`, `bg-2`, `surface` and `surface-2` in both themes. */ +export const stepStore = { dark: "#a5b4fc", light: "#4138c9" } as const; + +/** Platform badge: web, always with the word; 3:1 or more on every ground. */ +export const platformWeb = { dark: "#0ea5e9", light: "#008ddd" } as const; + +/** Platform badge: server, always with the word; 3:1 or more on every ground. */ +export const platformServer = { dark: "#8b5cf6", light: "#8043f6" } as const; + +/** First chart series: `primary` in dark, `link` in light. Series take `chart-1` to `chart-6` in order; the six stay apart in normal vision and with red-green colour blindness, and reach 3:1 on every ground. Label every series. */ +export const chart1 = { dark: "#01b5e2", light: "#0076a0" } as const; + +/** Second chart series (orange). */ +export const chart2 = { dark: "#e69f00", light: "#ce710c" } as const; + +/** Third chart series (bluish green). */ +export const chart3 = { dark: "#009563", light: "#217b58" } as const; + +/** Fourth chart series (vermilion). */ +export const chart4 = { dark: "#e0520a", light: "#d32f4d" } as const; + +/** Fifth chart series (reddish purple). */ +export const chart5 = { dark: "#ff6fad", light: "#b87889" } as const; + +/** Sixth chart series (yellow in dark, olive in light). */ +export const chart6 = { dark: "#f0e442", light: "#546800" } as const; + +/** Human notes and comment pins over visualisations, tag plans and frames. Kept apart from every step and status colour; 3:1 or more on every ground. */ +export const annotation = { dark: "#22c55e", light: "#409116" } as const; + +/** Editor syntax (Monaco, Shiki, Prism): comments, 4.6:1 on `code-bg`. */ +export const syntaxComment = "#8c94be"; + +/** Editor syntax (Monaco, Shiki, Prism): strings, attribute names and inserted text, 9.9:1 on `code-bg`. */ +export const syntaxString = "#c3e88d"; + +/** Editor syntax (Monaco, Shiki, Prism): numbers, 5.8:1 on `code-bg`. */ +export const syntaxNumber = "#f78c6c"; + +/** Editor syntax (Monaco, Shiki, Prism): booleans, null and constants, 4.6:1 on `code-bg`. */ +export const syntaxConstant = "#ff5d78"; + +/** Editor syntax (Monaco, Shiki, Prism): keywords and storage modifiers, 5.2:1 on `code-bg`. */ +export const syntaxKeyword = "#c084fc"; + +/** Editor syntax (Monaco, Shiki, Prism): functions and built-ins, 5.9:1 on `code-bg`. */ +export const syntaxFunction = "#82aaff"; + +/** Editor syntax (Monaco, Shiki, Prism): types and class names, 9.1:1 on `code-bg`. */ +export const syntaxType = "#ffcb6b"; + +/** Editor syntax (Monaco, Shiki, Prism): operators and regular expressions, 9.0:1 on `code-bg`. */ +export const syntaxOperator = "#89ddff"; + +/** Editor syntax (Monaco, Shiki, Prism): punctuation and delimiters, 5.7:1 on `code-bg`. */ +export const syntaxPunct = "#c792ea"; + +/** Editor syntax (Monaco, Shiki, Prism): tags, invalid and deleted tokens, 4.6:1 on `code-bg`. */ +export const syntaxTag = "#ff5d79"; + +/** Editor syntax (Monaco, Shiki, Prism): namespaces, 8.1:1 on `code-bg`. */ +export const syntaxNamespace = "#b2ccd6"; + +/** Editor gutter line numbers on `code-bg`. Decorative, below 3:1. */ +export const codeLineNumber = "#676e95"; + +/** Editor selection over `code-bg`; `code-fg` stays at 5.4:1 on it. */ +export const codeSelection = "#717cb450"; + +/** Inserted lines in diff views over `code-bg`; `code-fg` stays at 5.4:1. */ +export const codeInsertedBg = "#c3e88d26"; + +/** Deleted lines in diff views over `code-bg`; `code-fg` stays at 6.6:1. */ +export const codeDeletedBg = "#ff5d7926"; + +/** Font family `sans`. */ +export const fontSans = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif"; + +/** Font family `mono`. */ +export const fontMono = "SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"; + +/** Font family `viz`. */ +export const fontViz = "'Geist', system-ui, sans-serif"; + +/** Font family `viz-mono`. */ +export const fontVizMono = "'Geist Mono', monospace"; diff --git a/apps/explorer/src/design/names.ts b/apps/explorer/src/design/names.ts new file mode 100644 index 000000000..f0b243286 --- /dev/null +++ b/apps/explorer/src/design/names.ts @@ -0,0 +1,30 @@ +/** + * Text scanning shared by the generator tests and walkeros-design-check: one + * definition of "a custom property is declared here". + */ + +/** `--x: v` (CSS), `'--x': v` (TS style keys), `[--x:v]` (Tailwind arbitrary properties). */ +const DECLARATION = /(? void, +): void { + const flags = pattern.flags.includes('g') + ? pattern.flags + : `${pattern.flags}g`; + const re = new RegExp(pattern.source, flags); + for (let match = re.exec(text); match !== null; match = re.exec(text)) + visit(match); +} + +export function collectDeclaredNames(text: string): Set { + const names = new Set(); + forEachMatch(DECLARATION, text, (match) => names.add(match[1])); + forEachMatch(SET_PROPERTY, text, (match) => names.add(match[1])); + return names; +} diff --git a/apps/explorer/src/design/tailwind-namespaces.ts b/apps/explorer/src/design/tailwind-namespaces.ts new file mode 100644 index 000000000..9c0055f2f --- /dev/null +++ b/apps/explorer/src/design/tailwind-namespaces.ts @@ -0,0 +1,99 @@ +/** + * Tailwind 4.3's own custom properties (tailwindcss/theme.css), listed once + * for the bridge (design/tailwind.css), the token guard and + * walkeros-design-check. + */ + +/** Theme namespaces the bridge empties, so only design tokens produce these utilities. */ +export const TAILWIND_RESET_NAMESPACES = [ + 'color', + 'radius', + 'font', + 'text', + 'shadow', + 'inset-shadow', + 'drop-shadow', + 'text-shadow', +] as const; + +/** Theme namespaces the bridge keeps with Tailwind's own values. */ +export const TAILWIND_KEPT_NAMESPACES = [ + 'animate', + 'aspect', + 'blur', + 'breakpoint', + 'container', + 'default', + 'ease', + 'font-weight', + 'leading', + 'max-width', + 'perspective', + 'tracking', +] as const; + +/** Tailwind's runtime `--tw-*` variables and its `--spacing-*` scale. */ +const TAILWIND_OTHER_NAMESPACES = ['tw', 'spacing'] as const; + +/** + * Keys Tailwind keeps apart from the `--text-*` sizes and `--font-*` families + * (tailwindcss dist/lib.js), beside `text-shadow` and `font-weight` above. + */ +const TAILWIND_NESTED_NAMESPACES = [ + 'text-color', + 'text-decoration-color', + 'text-decoration-thickness', + 'text-indent', + 'text-underline-offset', + 'font-size', +] as const; + +const TAILWIND_NAMESPACES: readonly string[] = [ + ...TAILWIND_RESET_NAMESPACES, + ...TAILWIND_KEPT_NAMESPACES, + ...TAILWIND_OTHER_NAMESPACES, + ...TAILWIND_NESTED_NAMESPACES, +]; + +/** Single variables: `--spacing` (the bridge sets it to 4px) and the deprecated `--blur`, `--shadow`, `--drop-shadow`, `--radius`. */ +const TAILWIND_BARE_VARIABLES: readonly string[] = [ + 'spacing', + 'blur', + 'shadow', + 'drop-shadow', + 'radius', +]; + +/** + * Variable prefixes walkeros-design-check accepts as Tailwind's own, beside + * the design names: `tw-` and every namespace the bridge keeps. + */ +export const TAILWIND_VARIABLES: readonly string[] = [ + 'tw-', + ...TAILWIND_KEPT_NAMESPACES.map((namespace) => `${namespace}-`), +]; + +/** A namespace nested in another (`text-indent` in `text`): Tailwind keeps its own name apart too. */ +const isNested = (namespace: string): boolean => + TAILWIND_NAMESPACES.some( + (parent) => parent !== namespace && namespace.startsWith(`${parent}-`), + ); + +/** + * The Tailwind namespace a custom property `--` falls in, the most + * specific one (`font-weight` before `font`); undefined when Tailwind does + * not use the name. + */ +export function tailwindNamespace(name: string): string | undefined { + let found: string | undefined; + for (const namespace of TAILWIND_NAMESPACES) { + const inside = + name.startsWith(`${namespace}-`) || + (name === namespace && + (TAILWIND_BARE_VARIABLES.includes(namespace) || isNested(namespace))); + if (inside && (found === undefined || namespace.length > found.length)) { + found = namespace; + } + } + return found; +} diff --git a/apps/explorer/src/design/tokens.ts b/apps/explorer/src/design/tokens.ts new file mode 100644 index 000000000..3007dbb1f --- /dev/null +++ b/apps/explorer/src/design/tokens.ts @@ -0,0 +1,648 @@ +/** + * The walkerOS design tokens: the types of design/tokens.json and the guard + * that parses it. The guard accepts a strict subset of the Claude Design token + * grammar (lowercase names, hex or rgb()/rgba() colours, {alias} references, + * exactly the themes dark and light, no font files, no meta). Anything else + * throws a DesignTokenError naming its JSON path, so the explorer build fails + * instead of shipping a broken or silently dropped token. + */ +import { tailwindNamespace } from './tailwind-namespaces'; + +export const THEMES = ['dark', 'light'] as const; +export type ThemeId = (typeof THEMES)[number]; + +export interface ThemedValue { + readonly dark: string; + readonly light: string; +} + +export interface ColorToken { + readonly name: string; + /** A literal colour or an {alias}, one value or one per theme. */ + readonly value: string | ThemedValue; + readonly usage: string; +} + +export interface ShadowToken { + readonly name: string; + readonly value: string | ThemedValue; + readonly usage: string; +} + +export interface ValueToken { + readonly name: string; + readonly value: string; + readonly usage: string; +} + +export interface TypeStyle { + readonly name: string; + /** The family key: the style's own, else its group's. */ + readonly family: string; + readonly fontSize: string; + readonly lineHeight: number | string; + readonly fontWeight: number; + readonly letterSpacing?: string; + readonly usage: string; +} + +export interface TypeGroup { + readonly name: string; + readonly styles: readonly TypeStyle[]; +} + +export interface DesignTokens { + readonly name: string; + readonly version: number; + readonly colors: readonly ColorToken[]; + readonly families: Readonly>; + readonly groups: readonly TypeGroup[]; + readonly spacing: readonly ValueToken[]; + readonly radius: readonly ValueToken[]; + readonly shadows: readonly ShadowToken[]; + readonly zIndex: readonly ValueToken[]; +} + +export interface Rgba { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a: number; +} + +export class DesignTokenError extends Error { + readonly path: string; + + constructor(path: string, message: string) { + super(`design/tokens.json ${path}: ${message}`); + this.name = 'DesignTokenError'; + this.path = path; + } +} + +type JsonObject = Record; + +interface Entry { + readonly entry: JsonObject; + readonly path: string; +} + +/** Lowercase words of letters and digits joined by single hyphens: `bg-2`, `on-primary`, `space-3-5`. */ +const NAME_PATTERN = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'; +const NAME = new RegExp(`^${NAME_PATTERN}$`); +const MAX_NAME_LENGTH = 64; +const NAME_RULE = `must start with a lowercase letter and use only lowercase letters, digits and single hyphens between words, at most ${MAX_NAME_LENGTH} characters`; +const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/; +const RGB = + /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0|1|0?\.\d+)\s*)?\)$/; +const ALIAS = new RegExp(`^\\{(${NAME_PATTERN})\\}$`); +const LENGTH = /^(?:0|-?\d+(?:\.\d+)?(?:px|rem|em|%))$/; +const INTEGER = /^-?\d+$/; +const SHADOW = /^[A-Za-z0-9 #%(),./+-]{1,400}$/; +const FONT_STACK_FORBIDDEN = /[;{}<>\\()]/; +const TOP_LEVEL = [ + 'name', + 'version', + 'color', + 'type', + 'spacing', + 'radius', + 'shadow', + 'zIndex', +]; +const MAX_ALIAS_DEPTH = 16; +/** Identifiers a generated `export const` cannot take: reserved words, strict-mode bindings, undefined. */ +const RESERVED = new Set([ + 'arguments', + 'await', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'enum', + 'eval', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'implements', + 'import', + 'in', + 'instanceof', + 'interface', + 'let', + 'new', + 'null', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'static', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'undefined', + 'var', + 'void', + 'while', + 'with', + 'yield', +]); + +export function isRecord(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function fail(path: string, message: string): never { + throw new DesignTokenError(path, message); +} + +function object(value: unknown, path: string): JsonObject { + return isRecord(value) ? value : fail(path, 'must be an object'); +} + +function array(value: unknown, path: string): readonly unknown[] { + return Array.isArray(value) ? value : fail(path, 'must be an array'); +} + +function text(value: unknown, path: string): string { + return typeof value === 'string' && value.length > 0 + ? value + : fail(path, 'must be a non-empty string'); +} + +function matching( + value: unknown, + path: string, + pattern: RegExp, + what: string, +): string { + const v = text(value, path); + return pattern.test(v) ? v : fail(path, `"${v}" is not ${what}`); +} + +function onlyKeys( + value: JsonObject, + allowed: readonly string[], + path: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) + fail(path ? `${path}.${key}` : key, 'is not part of the token grammar'); + } +} + +function isTokenName(value: string): boolean { + return value.length <= MAX_NAME_LENGTH && NAME.test(value); +} + +function tokenName(value: unknown, path: string): string { + const name = text(value, path); + return isTokenName(name) + ? name + : fail(path, `"${name}" is not a token name: a name ${NAME_RULE}`); +} + +/** Parse a literal colour (lowercase hex, rgb() or rgba()); undefined for anything else. */ +export function parseColor(value: string): Rgba | undefined { + if (HEX.test(value)) { + const hex = value.slice(1); + const full = + hex.length <= 4 + ? hex + .split('') + .map((digit) => digit + digit) + .join('') + : hex; + const byte = (index: number): number => + parseInt(full.slice(index, index + 2), 16); + return { + r: byte(0), + g: byte(2), + b: byte(4), + a: full.length === 8 ? byte(6) / 255 : 1, + }; + } + const match = RGB.exec(value); + if (match === null) return undefined; + const [r, g, b] = [Number(match[1]), Number(match[2]), Number(match[3])]; + if (r > 255 || g > 255 || b > 255) return undefined; + return { r, g, b, a: match[4] === undefined ? 1 : Number(match[4]) }; +} + +/** The token an `{alias}` value points at; undefined for a literal. */ +export function aliasTarget(value: string): string | undefined { + const match = ALIAS.exec(value); + return match === null ? undefined : match[1]; +} + +/** `bg-2` -> `bg2`, `on-primary` -> `onPrimary`: the constant name of a token. */ +export function camelName(name: string): string { + return name.replace(/-([a-z0-9])/g, (_match: string, next: string) => + next.toUpperCase(), + ); +} + +function colorValue(value: unknown, path: string): string { + const v = text(value, path); + if (aliasTarget(v) !== undefined || parseColor(v) !== undefined) return v; + return fail( + path, + `"${v}" is not a lowercase hex, rgb() or rgba() colour, or an {alias}`, + ); +} + +function balanced(value: string): boolean { + let depth = 0; + for (const char of value) { + if (char === '(') depth++; + if (char === ')') depth--; + if (depth < 0) return false; + } + return depth === 0; +} + +function shadowValue(value: unknown, path: string): string { + const v = text(value, path); + if (SHADOW.test(v) && !/\b(?:var|url)\(/.test(v) && balanced(v)) return v; + return fail(path, `"${v}" is not a box-shadow without var() or url()`); +} + +function perTheme( + value: unknown, + path: string, + leaf: (value: unknown, path: string) => string, +): string | ThemedValue { + if (typeof value === 'string') return leaf(value, path); + const themed = object(value, path); + onlyKeys(themed, THEMES, path); + return { + dark: leaf(themed.dark, `${path}.dark`), + light: leaf(themed.light, `${path}.light`), + }; +} + +function tokenEntries(value: unknown, path: string): Entry[] { + return array(value, path).map((item, index) => { + const itemPath = `${path}[${index}]`; + const entry = object(item, itemPath); + onlyKeys(entry, ['name', 'value', 'usage'], itemPath); + return { entry, path: itemPath }; + }); +} + +function familyEntries(value: unknown, path: string): Entry[] { + if (value === undefined) return []; + const family = object(value, path); + onlyKeys(family, ['note', 'tokens'], path); + if (family.note !== undefined) text(family.note, `${path}.note`); + return tokenEntries(family.tokens, `${path}.tokens`); +} + +function valueTokens( + value: unknown, + path: string, + pattern: RegExp, + what: string, +): ValueToken[] { + return familyEntries(value, path).map(({ entry, path: itemPath }) => ({ + name: tokenName(entry.name, `${itemPath}.name`), + value: matching(entry.value, `${itemPath}.value`, pattern, what), + usage: text(entry.usage, `${itemPath}.usage`), + })); +} + +function fontStack(value: unknown, path: string): string { + const stack = text(value, path); + const count = (quote: string): number => stack.split(quote).length - 1; + if (stack.length > 200 || FONT_STACK_FORBIDDEN.test(stack)) { + return fail( + path, + 'must be a font stack of at most 200 characters without ; { } < > \\ ( )', + ); + } + if (count("'") % 2 !== 0 || count('"') % 2 !== 0) + return fail(path, 'has unbalanced quotes'); + return stack; +} + +function typeStyle( + value: unknown, + path: string, + groupFamily: string, + family: (value: unknown, path: string) => string, +): TypeStyle { + const style = object(value, path); + onlyKeys( + style, + [ + 'name', + 'family', + 'fontSize', + 'lineHeight', + 'fontWeight', + 'letterSpacing', + 'usage', + 'sample', + ], + path, + ); + if (style.sample !== undefined) text(style.sample, `${path}.sample`); + const fontWeight = style.fontWeight; + if ( + typeof fontWeight !== 'number' || + !Number.isInteger(fontWeight) || + fontWeight < 1 || + fontWeight > 1000 + ) { + fail(`${path}.fontWeight`, 'must be an integer from 1 to 1000'); + } + const lineHeight = style.lineHeight; + const parsed: TypeStyle = { + name: tokenName(style.name, `${path}.name`), + family: + style.family === undefined + ? groupFamily + : family(style.family, `${path}.family`), + fontSize: matching(style.fontSize, `${path}.fontSize`, LENGTH, 'a length'), + lineHeight: + typeof lineHeight === 'number' && lineHeight > 0 && lineHeight < 10 + ? lineHeight + : matching( + lineHeight, + `${path}.lineHeight`, + LENGTH, + 'a unitless number below 10 or a length', + ), + fontWeight, + usage: text(style.usage, `${path}.usage`), + }; + return style.letterSpacing === undefined + ? parsed + : { + ...parsed, + letterSpacing: matching( + style.letterSpacing, + `${path}.letterSpacing`, + LENGTH, + 'a length', + ), + }; +} + +function parseType(value: unknown): Pick { + const type = object(value, 'type'); + onlyKeys(type, ['fonts', 'families', 'groups'], 'type'); + if (array(type.fonts, 'type.fonts').length > 0) { + fail( + 'type.fonts', + 'font files need @font-face output in src/design/generate.ts first', + ); + } + const families: Record = {}; + for (const [key, stack] of Object.entries( + object(type.families, 'type.families'), + )) { + if (!isTokenName(key)) + fail( + `type.families.${key}`, + `"${key}" is not a family key: a key ${NAME_RULE}`, + ); + families[key] = fontStack(stack, `type.families.${key}`); + } + const family = (v: unknown, path: string): string => { + const key = text(v, path); + return Object.prototype.hasOwnProperty.call(families, key) + ? key + : fail(path, `"${key}" is not a key of type.families`); + }; + const groups = array(type.groups, 'type.groups').map((item, i): TypeGroup => { + const path = `type.groups[${i}]`; + const group = object(item, path); + onlyKeys(group, ['name', 'family', 'styles'], path); + const groupFamily = family(group.family, `${path}.family`); + return { + name: text(group.name, `${path}.name`), + styles: array(group.styles, `${path}.styles`).map((style, j) => + typeStyle(style, `${path}.styles[${j}]`, groupFamily, family), + ), + }; + }); + return { families, groups }; +} + +/** Follow an alias chain to its literal for one theme; `themed` is true when any link is declared per theme. */ +function follow( + colors: readonly ColorToken[], + start: string, + theme: ThemeId, + path: string, +): { readonly value: string; readonly themed: boolean } { + const chain = [start]; + let themed = false; + for (;;) { + const current = chain[chain.length - 1]; + const token = colors.find((color) => color.name === current); + if (token === undefined) + return fail( + path, + `"${current}" is not a colour token (${chain.join(' -> ')})`, + ); + themed = themed || typeof token.value !== 'string'; + const value = + typeof token.value === 'string' ? token.value : token.value[theme]; + const target = aliasTarget(value); + if (target === undefined) return { value, themed }; + if (chain.includes(target)) + return fail(path, `alias cycle ${[...chain, target].join(' -> ')}`); + if (chain.length > MAX_ALIAS_DEPTH) + return fail(path, `alias chain deeper than ${MAX_ALIAS_DEPTH}`); + chain.push(target); + } +} + +/** The literal colour a token resolves to in one theme. */ +export function resolveColor( + tokens: Pick, + name: string, + theme: ThemeId, +): string { + return follow(tokens.colors, name, theme, `color "${name}"`).value; +} + +/** True when the token is declared per theme, or is an alias whose chain reaches one. */ +export function isThemedColor( + tokens: Pick, + name: string, +): boolean { + return follow(tokens.colors, name, THEMES[0], `color "${name}"`).themed; +} + +function checkNamespace(tokens: DesignTokens): void { + const claim = ( + scope: Map, + key: string, + path: string, + what: string, + ): void => { + const previous = scope.get(key); + if (previous !== undefined) + fail(path, `${what} "${key}" is already declared at ${previous}`); + scope.set(key, path); + }; + // Every family but type shares one --name namespace (format.md), outside + // Tailwind's own: tokens.css is unlayered, so a token such as container-xl + // would override Tailwind's --container-xl and re-size max-w-xl. Radius + // tokens and font families sit in --radius-* and --font-* on purpose. + const names = new Map(); + const claimName = (name: string, path: string, own?: string): void => { + const namespace = tailwindNamespace(name); + if (namespace !== undefined && namespace !== own) { + fail( + path, + `"${name}" collides with Tailwind's own --${namespace} variables`, + ); + } + claim(names, name, path, 'name'); + }; + tokens.colors.forEach((t, i) => claimName(t.name, `color.tokens[${i}].name`)); + tokens.shadows.forEach((t, i) => + claimName(t.name, `shadow.tokens[${i}].name`), + ); + tokens.spacing.forEach((t, i) => + claimName(t.name, `spacing.tokens[${i}].name`), + ); + tokens.radius.forEach((t, i) => + claimName(t.name, `radius.tokens[${i}].name`, 'radius'), + ); + tokens.zIndex.forEach((t, i) => + claimName(t.name, `zIndex.tokens[${i}].name`), + ); + Object.keys(tokens.families).forEach((key) => + claimName(`font-${key}`, `type.families.${key}`, 'font'), + ); + // The constants module needs one valid identifier per colour and family. + const constants = new Map(); + tokens.colors.forEach((t, i) => { + const path = `color.tokens[${i}].name`; + const constant = camelName(t.name); + if (RESERVED.has(constant)) + fail(path, `"${t.name}" would export the reserved word ${constant}`); + claim(constants, constant, path, 'constant'); + }); + Object.keys(tokens.families).forEach((key) => + claim( + constants, + camelName(`font-${key}`), + `type.families.${key}`, + 'constant', + ), + ); + // Tailwind serves text- + ${htmlRef.current} @@ -436,6 +452,7 @@ export function Preview({ ) : null } + theme="dark" > {/* The iframe stays mounted to preserve the live source binding; editor tabs sit on top and the preview is hidden, not unmounted. */} diff --git a/apps/explorer/src/components/molecules/step-example.stories.tsx b/apps/explorer/src/components/molecules/step-example.stories.tsx index 2824dba4b..bd4e92321 100644 --- a/apps/explorer/src/components/molecules/step-example.stories.tsx +++ b/apps/explorer/src/components/molecules/step-example.stories.tsx @@ -27,7 +27,7 @@ export const WithMapping: Story = { data: { id: 'ers', name: 'Everyday Ruck Snack', - color: 'black', + taste: 'salty', size: 'l', price: 420, }, diff --git a/apps/explorer/src/components/organisms/browser-box.stories.tsx b/apps/explorer/src/components/organisms/browser-box.stories.tsx index e5157bca6..42f71456b 100644 --- a/apps/explorer/src/components/organisms/browser-box.stories.tsx +++ b/apps/explorer/src/components/organisms/browser-box.stories.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { border, fg, onPrimary, primary, surface } from '../../design'; import { BrowserBox } from './browser-box'; /** @@ -38,9 +39,9 @@ const sampleCss = ` .product-card { width: 300px; padding: 20px; - background: white; + background: ${surface.light}; + border: 1px solid ${border.light}; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); font-family: -apple-system, sans-serif; } @@ -54,12 +55,12 @@ const sampleCss = ` .product-title { margin: 0; font-size: 1.25rem; - color: #1f2937; + color: ${fg.light}; } .product-badge { - background: #3b82f6; - color: white; + background: ${primary.light}; + color: ${onPrimary.light}; padding: 4px 12px; border-radius: 12px; font-size: 0.75rem; @@ -69,15 +70,15 @@ const sampleCss = ` .product-price { font-size: 1.5rem; font-weight: 700; - color: #1f2937; + color: ${fg.light}; margin: 0 0 16px 0; } .product-button { width: 100%; padding: 12px; - background: #3b82f6; - color: white; + background: ${primary.light}; + color: ${onPrimary.light}; border: none; border-radius: 6px; font-weight: 600; @@ -86,7 +87,7 @@ const sampleCss = ` } .product-button:hover { - background: #2563eb; + filter: brightness(1.08); } `; diff --git a/apps/explorer/src/components/organisms/browser-box.tsx b/apps/explorer/src/components/organisms/browser-box.tsx index 5c7ff4741..1db1701e2 100644 --- a/apps/explorer/src/components/organisms/browser-box.tsx +++ b/apps/explorer/src/components/organisms/browser-box.tsx @@ -120,7 +120,8 @@ export function BrowserBox({ /> ) : null } - className={className} + className={`elb-explorer-box--code ${className}`.trim()} + theme="dark" > {activeTab === 'preview' ? ( diff --git a/apps/explorer/src/design/components/layout/CardGrid.stories.tsx b/apps/explorer/src/design/components/layout/CardGrid.stories.tsx new file mode 100644 index 000000000..5a4ae5b7a --- /dev/null +++ b/apps/explorer/src/design/components/layout/CardGrid.stories.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Card } from '../atoms/Card'; +import { CardGrid } from './CardGrid'; + +const meta: Meta = { + title: 'Design/Layout/CardGrid', + component: CardGrid, + tags: ['autodocs'], + args: { + min: 280, + children: ['01', '02', '03'].map((label) => ( + {label} + )), + }, +}; +export default meta; + +export const Default: StoryObj = {}; diff --git a/apps/explorer/src/design/components/viz/ArticleTeaserTracking.tsx b/apps/explorer/src/design/components/viz/ArticleTeaserTracking.tsx index 442828956..f26194d99 100644 --- a/apps/explorer/src/design/components/viz/ArticleTeaserTracking.tsx +++ b/apps/explorer/src/design/components/viz/ArticleTeaserTracking.tsx @@ -40,16 +40,19 @@ function TeaserCard({ article, position, seen, + ahead, }: { article: TeaserArticle; position: number; seen: ReadonlySet; + ahead: boolean; }) { return (

@@ -98,20 +101,32 @@ function TeaserCard({ ); } +/** + * A list and its cards are reached at the list's step. The lead list's first + * card stands for the teaser the atom and molecule steps tag, so it and its + * list are reached from step 1. Narrow frames hide what is not reached yet + * instead of stacking empty boxes. + */ function TeaserGroup({ page, seen, + current, + lead, }: { page: TeaserPage; seen: ReadonlySet; + current: TeaserStep; + lead: boolean; }) { const globalsOn = seen.has(page.globalsEffect); const listOn = seen.has(page.listEffect); + const reached = page.step <= current; return (
@@ -144,6 +159,7 @@ function TeaserGroup({ article={article} position={index + 1} seen={seen} + ahead={!reached && !(lead && index === 0)} /> ))}
@@ -307,8 +323,14 @@ export function ArticleTeaserTracking({
- {TEASER_PAGE_IDS.map((id) => ( - + {TEASER_PAGE_IDS.map((id, index) => ( + ))}
diff --git a/apps/explorer/src/design/components/viz/__tests__/demos.test.tsx b/apps/explorer/src/design/components/viz/__tests__/demos.test.tsx index 81961e281..ce623407b 100644 --- a/apps/explorer/src/design/components/viz/__tests__/demos.test.tsx +++ b/apps/explorer/src/design/components/viz/__tests__/demos.test.tsx @@ -96,6 +96,51 @@ describe('first frames, on the server and in the first client frame', () => { }); }); +// Below 640px the cards stack, and the narrow rule hides what a step has not +// reached (jsdom evaluates no container query, so this checks the classes it +// targets). The first card stands for the tagged teaser from step 1. +describe('teaser: what a step has not reached yet', () => { + const ahead = (container: HTMLElement, part: 'card' | 'page') => + Array.from( + container.querySelectorAll(`.elb-viz-teaser__${part}`), + (element) => element.classList.contains(`elb-viz-teaser__${part}--ahead`), + ); + + it('the server frame: only the first card, the article list hidden', () => { + const container = serverHtml(); + expect(ahead(container, 'card')).toEqual([ + false, + true, + true, + true, + true, + true, + ]); + expect(ahead(container, 'page')).toEqual([false, true]); + }); + + it.each([ + [1, [false, true, true, true, true, true], [false, true]], + [2, [false, true, true, true, true, true], [false, true]], + [3, [false, false, false, true, true, true], [false, true]], + [4, [false, false, false, false, false, false], [false, false]], + [5, [false, false, false, false, false, false], [false, false]], + ] as const)('step %i', (step, cards, pages) => { + const { container } = render( + , + ); + expect(ahead(container, 'card')).toEqual(cards); + expect(ahead(container, 'page')).toEqual(pages); + }); + + it('reduced motion: nothing hidden', () => { + preferReducedMotion(); + const { container } = render(); + expect(ahead(container, 'card')).not.toContain(true); + expect(ahead(container, 'page')).not.toContain(true); + }); +}); + describe('reduced motion', () => { beforeEach(preferReducedMotion); diff --git a/apps/explorer/src/design/components/viz/data/destination-mapping.ts b/apps/explorer/src/design/components/viz/data/destination-mapping.ts index 0d080e474..dcf2ce691 100644 --- a/apps/explorer/src/design/components/viz/data/destination-mapping.ts +++ b/apps/explorer/src/design/components/viz/data/destination-mapping.ts @@ -61,7 +61,7 @@ export interface MappingDemoEvent { export const GENERATED_ID = '…'; export const MAPPING_CAPTION = - '50+ destination adapters ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.'; + '50+ destination packages ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.'; const GA4_MEASUREMENT_ID = 'G-XXXXXXXXXX'; diff --git a/apps/explorer/src/index.ts b/apps/explorer/src/index.ts index a1751eb18..d7b3d6085 100644 --- a/apps/explorer/src/index.ts +++ b/apps/explorer/src/index.ts @@ -84,15 +84,8 @@ export { allowedRefKinds } from './utils/allowed-ref-kinds'; export type { RefKind } from './utils/allowed-ref-kinds'; export { getJsonPathAtOffset } from './utils/monaco-json-path'; -// Monaco Editor themes -export { - palenightTheme, - lighthouseTheme, - registerPalenightTheme, - registerLighthouseTheme, - registerAllThemes, -} from './themes'; -export type { ExplorerTheme } from './themes'; +// Monaco Editor code theme (one dark theme; code surfaces are dark islands) +export { registerTheme, ELB_THEME_DARK } from './themes'; // Monaco Editor type (re-exported for consumers of callbacks that receive a // Monaco instance — e.g. `CodeBox.beforeMount(monaco)`. Raw `Editor` / `DiffEditor` diff --git a/apps/explorer/src/styles/PANE_STANDARDS.md b/apps/explorer/src/styles/PANE_STANDARDS.md index e77bcd1fb..f1ba39014 100644 --- a/apps/explorer/src/styles/PANE_STANDARDS.md +++ b/apps/explorer/src/styles/PANE_STANDARDS.md @@ -44,7 +44,7 @@ From `_mapping-panes.scss:280-286`: flex: 1; min-height: 0; overflow-y: auto; // ← Scrolling - padding: var(--spacing-grid-gap); // ← 16px padding (all sides) + padding: 16px; // ← padding on all sides padding-bottom: 50vh; // ← Extra bottom for scroll comfort } ``` @@ -148,7 +148,7 @@ These use their own wrapper patterns but still follow the principle: ## Benefits 1. **Consistency** - All panes have the same padding and scroll behavior -2. **DRY** - Change padding in ONE place (`--spacing-grid-gap`) +2. **DRY** - Change padding in ONE place (`.elb-mapping-pane-content`) 3. **Maintainability** - No guessing about spacing 4. **No Conflicts** - No double padding issues 5. **Easy Testing** - Predictable layout behavior diff --git a/apps/explorer/src/styles/components/atoms/_button.scss b/apps/explorer/src/styles/components/atoms/_button.scss index 5ed0d8465..a416a9056 100644 --- a/apps/explorer/src/styles/components/atoms/_button.scss +++ b/apps/explorer/src/styles/components/atoms/_button.scss @@ -1,37 +1,36 @@ .elb-explorer-btn { - padding: var(--spacing-button); + padding: 4px 8px; background: transparent; border: none; - border-radius: var(--radius-button); + border-radius: var(--radius-xs); cursor: pointer; display: flex; align-items: center; justify-content: center; - color: var(--color-text-button); + color: var(--fg-2); transition: all 0.15s; &:hover { - background: var(--bg-button-hover); - color: var(--color-text-button-hover); + background: var(--surface-2); + color: var(--fg); } &.active { - background: var(--bg-button-active); - color: var(--color-text-button-active); - box-shadow: var(--shadow-button-active); - font-weight: var(--font-weight-semibold); + background: var(--surface); + color: var(--fg); + font-weight: 600; } } .elb-explorer-button-group { display: flex; - background: var(--bg-button-group); - border-radius: var(--radius-button-group); - padding: var(--spacing-button-group); - border: 1px solid var(--border-button-group); + background: var(--bg-2); + border-radius: var(--radius-xs); + padding: 1px; + border: 1px solid var(--border-strong); } -// Tabs variant — flat header tabs matching the step-detail modal tab style. +// Tabs variant: flat header tabs matching the step-detail modal tab style. // No track fill or outer border; the active tab is a filled, top-rounded // surface that reads as connected to the content below. .elb-explorer-button-group.elb-explorer-button-group--tabs { @@ -43,21 +42,20 @@ .elb-explorer-btn { padding: 3px 10px; - font-size: var(--font-size-label); - font-weight: var(--font-weight-medium); - color: var(--color-text-button); + font-size: var(--type-product-small-size); + font-weight: 500; + color: var(--fg-2); background: transparent; - border-radius: var(--radius-button) var(--radius-button) 0 0; + border-radius: var(--radius-xs) var(--radius-xs) 0 0; &:hover { background: transparent; - color: var(--color-text-button-hover); + color: var(--fg); } &.active { - background: var(--bg-button-active); - color: var(--color-text-button-active); - box-shadow: none; + background: var(--surface); + color: var(--fg); } } } diff --git a/apps/explorer/src/styles/components/atoms/_code-static.scss b/apps/explorer/src/styles/components/atoms/_code-static.scss index c34bfa357..9589d45f1 100644 --- a/apps/explorer/src/styles/components/atoms/_code-static.scss +++ b/apps/explorer/src/styles/components/atoms/_code-static.scss @@ -1,6 +1,9 @@ // CodeStatic component - Shiki-rendered, read-only code -// Visual parity with .elb-code (Monaco wrapper) so CodeView matches CodeBox. +// Visual parity with .elb-code (Monaco wrapper) so CodeView matches CodeBox: +// a dark island that paints the code panel in both page themes. .elb-code-static { + --monaco-font-size: 13px; + flex: 1; min-height: 0; width: 100%; @@ -8,17 +11,18 @@ flex-direction: column; position: relative; overflow: hidden; + background: var(--code-bg); + color: var(--code-fg); - font-size: var(--monaco-font-size, 13px); + font-size: var(--monaco-font-size); line-height: 1.5; - font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Menlo', 'Consolas', - monospace); + font-family: var(--font-mono); pre.shiki, pre { flex: 1; margin: 0 !important; - padding: var(--spacing-sm, 8px) var(--spacing-md, 12px) !important; + padding: 8px 12px !important; background: transparent !important; font-family: inherit !important; font-size: inherit !important; @@ -36,15 +40,3 @@ display: block; } } - -// Shiki dual-theme emits `--shiki-dark*` CSS variables but no ready-made dark -// selector. Docusaurus toggles dark mode via `html[data-theme='dark']`, so map -// the dark variables onto the rendered tokens there. -html[data-theme='dark'] .shiki, -html[data-theme='dark'] .shiki span { - color: var(--shiki-dark) !important; - background-color: var(--shiki-dark-bg) !important; - font-style: var(--shiki-dark-font-style) !important; - font-weight: var(--shiki-dark-font-weight) !important; - text-decoration: var(--shiki-dark-text-decoration) !important; -} diff --git a/apps/explorer/src/styles/components/atoms/_code.scss b/apps/explorer/src/styles/components/atoms/_code.scss index 1d3096894..8cd35fe7b 100644 --- a/apps/explorer/src/styles/components/atoms/_code.scss +++ b/apps/explorer/src/styles/components/atoms/_code.scss @@ -1,4 +1,5 @@ -// Code component - Monaco editor wrapper +// Code component - Monaco editor wrapper, a dark island that paints the code +// panel in both page themes .elb-code { flex: 1; min-height: 0; // Critical for flex overflow @@ -7,6 +8,7 @@ flex-direction: column; position: relative; overflow: hidden; // Monaco handles scrolling internally + background: var(--code-bg); // Monaco wrapper div - must fill available space > div { @@ -26,7 +28,7 @@ } } - // Monaco editor styling - transparent, let box handle background + // Monaco editor styling - transparent, the wrapper paints the panel .monaco-editor { background: transparent !important; @@ -40,7 +42,7 @@ // Sticky scroll widget: opaque background so content doesn't show through .sticky-widget { - background: var(--bg-content) !important; + background: var(--code-bg) !important; border: none !important; outline: none !important; box-shadow: none !important; diff --git a/apps/explorer/src/styles/components/atoms/_preview-footer.scss b/apps/explorer/src/styles/components/atoms/_preview-footer.scss index c1d059c72..f9d18f884 100644 --- a/apps/explorer/src/styles/components/atoms/_preview-footer.scss +++ b/apps/explorer/src/styles/components/atoms/_preview-footer.scss @@ -6,17 +6,19 @@ grid-auto-columns: 1fr; flex: 1; align-self: stretch; - background: var(--bg-footer); + background: var(--bg-2); } .elb-preview-btn { padding: 6px 6px; - font-size: var(--font-size-toggle); + font-size: var(--type-product-caption-size); font-weight: 600; - color: var(--color-text-button); + color: var(--fg-2); border: 0; background: transparent; - transition: color 0.2s ease, background 0.2s ease; + transition: + color 0.2s ease, + background 0.2s ease; position: relative; cursor: pointer; text-align: center; @@ -29,12 +31,12 @@ justify-content: center; &:hover { - color: var(--color-text-button-hover); - background: var(--bg-button-hover); + color: var(--fg); + background: var(--surface-2); } &:active { - background: var(--bg-button-active); + background: var(--surface); } // Separator line between buttons @@ -45,28 +47,28 @@ top: 20%; height: 60%; width: 1px; - background: var(--border-footer); + background: var(--border); opacity: 0.3; } - // Active states with color highlights + // Active states in the event colours (the preview is a dark island) &.highlight-globals { - color: var(--highlight-globals); + color: var(--event-globals); } &.highlight-context { - color: var(--highlight-context); + color: var(--event-context); } &.highlight-entity { - color: var(--highlight-entity); + color: var(--event-entity); } &.highlight-property { - color: var(--highlight-property); + color: var(--event-property); } &.highlight-action { - color: var(--highlight-action); + color: var(--event-action); } } diff --git a/apps/explorer/src/styles/components/design/_article-teaser-tracking.scss b/apps/explorer/src/styles/components/design/_article-teaser-tracking.scss index db6b00985..6278e783d 100644 --- a/apps/explorer/src/styles/components/design/_article-teaser-tracking.scss +++ b/apps/explorer/src/styles/components/design/_article-teaser-tracking.scss @@ -420,5 +420,12 @@ .elb-viz-teaser__link { margin-top: 0; } + + // Stacked, the cards a step has not reached would be a column of empty + // boxes; they come in with their step. + .elb-viz-teaser__page--ahead, + .elb-viz-teaser__card--ahead { + display: none; + } } } diff --git a/apps/explorer/src/styles/components/molecules/_architecture-flow.scss b/apps/explorer/src/styles/components/molecules/_architecture-flow.scss index e54a08d7a..90a41e257 100644 --- a/apps/explorer/src/styles/components/molecules/_architecture-flow.scss +++ b/apps/explorer/src/styles/components/molecules/_architecture-flow.scss @@ -32,12 +32,12 @@ $container-breakpoint: 550px; // Title styling .elb-architecture-flow__title { - font-size: var(--font-size-base, 16px); - font-weight: var(--font-weight-semibold, 600); + font-size: var(--type-product-body-size); + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; - color: var(--color-text-muted, #6b7280); + color: var(--fg-2); &--desktop { display: none; @@ -85,17 +85,17 @@ $container-breakpoint: 550px; // Individual section box .elb-architecture-flow__section { padding: 16px; - border-radius: 12px; - border: 1px solid var(--border-box, rgba(128, 128, 128, 0.3)); - background-color: var(--bg-box, transparent); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background-color: var(--surface); } // Section title (e.g., "Client-side", "Server-side") .elb-architecture-flow__section-title { display: block; - font-size: calc(var(--font-size-base, 16px) - 2px); + font-size: var(--type-product-caption-size); font-weight: 500; - color: var(--color-text-muted, #6b7280); + color: var(--fg-2); margin-bottom: 12px; } @@ -111,7 +111,7 @@ $container-breakpoint: 550px; display: flex; align-items: center; gap: 12px; - color: var(--color-text, currentColor); + color: var(--fg); } // Item icon @@ -131,22 +131,22 @@ $container-breakpoint: 550px; // Item label .elb-architecture-flow__item-label { - font-size: var(--font-size-base, 16px); + font-size: var(--type-product-body-size); white-space: nowrap; } // "and more..." indicator .elb-architecture-flow__more { - font-size: calc(var(--font-size-base, 16px) - 2px); + font-size: var(--type-product-caption-size); font-style: italic; - color: var(--color-text-muted, #6b7280); + color: var(--fg-2); opacity: 0.7; text-align: center; } // Arrow styling .elb-architecture-flow__arrow { - color: var(--color-button-primary, #01b5e2); + color: var(--link); > svg { width: 32px; diff --git a/apps/explorer/src/styles/components/molecules/_code-box.scss b/apps/explorer/src/styles/components/molecules/_code-box.scss index 4a1d84768..3cfee865b 100644 --- a/apps/explorer/src/styles/components/molecules/_code-box.scss +++ b/apps/explorer/src/styles/components/molecules/_code-box.scss @@ -12,17 +12,17 @@ } } +// Floating layers: a surface with a 1px strong edge, no shadow .elb-codebox-settings { position: absolute; top: 100%; right: 0; - z-index: 10; + z-index: var(--z-dropdown); margin-top: 4px; padding: 8px 0; - background: var(--bg-dropdown); - border: 1px solid var(--border-box); - border-radius: var(--radius-button); - box-shadow: var(--shadow-dropdown); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-xs); min-width: 140px; } @@ -31,14 +31,14 @@ align-items: center; gap: 8px; padding: 6px 12px; - font-size: var(--font-size-label); - color: var(--color-text); + font-size: var(--type-product-small-size); + color: var(--fg); cursor: pointer; user-select: none; white-space: nowrap; &:hover { - background: var(--bg-dropdown-option-hover); + background: var(--surface-2); } input[type='checkbox'] { @@ -47,17 +47,17 @@ } } -// Marker count badges in header +// Marker count badges in header: an icon and a count in the status colour .elb-codebox-marker-badge { display: flex; align-items: center; gap: 3px; padding: 2px 6px; border: none; - border-radius: var(--radius-button); + border-radius: var(--radius-xs); cursor: pointer; - font-size: calc(var(--font-size-base) - 2px); - font-weight: var(--font-weight-semibold); + font-size: var(--type-product-caption-size); + font-weight: 600; font-family: var(--font-mono); line-height: 1; transition: all 0.15s; @@ -67,20 +67,20 @@ } &--error { - color: var(--color-alert-error-text); + color: var(--danger); background: transparent; &:hover { - background: var(--color-alert-error-bg); + background: var(--danger-bg); } } &--warning { - color: var(--color-alert-warning-text); + color: var(--warning); background: transparent; &:hover { - background: var(--color-alert-warning-bg); + background: var(--warning-bg); } } } @@ -90,13 +90,12 @@ position: absolute; top: 100%; right: 0; - z-index: 10; + z-index: var(--z-dropdown); margin-top: 4px; padding: 4px 0; - background: var(--bg-dropdown); - border: 1px solid var(--border-box); - border-radius: var(--radius-button); - box-shadow: var(--shadow-dropdown); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-xs); min-width: 200px; max-width: 340px; max-height: 200px; @@ -112,19 +111,19 @@ border: none; background: none; cursor: pointer; - font-size: calc(var(--font-size-base) - 2px); - color: var(--color-text); + font-size: var(--type-product-caption-size); + color: var(--fg); text-align: left; &:hover { - background: var(--bg-dropdown-option-hover); + background: var(--surface-2); } } .elb-codebox-marker-menu-line { flex-shrink: 0; font-family: var(--font-mono); - font-weight: var(--font-weight-semibold); + font-weight: 600; opacity: 0.6; } diff --git a/apps/explorer/src/styles/components/molecules/_code-diff-box.scss b/apps/explorer/src/styles/components/molecules/_code-diff-box.scss new file mode 100644 index 000000000..3dcfe9eae --- /dev/null +++ b/apps/explorer/src/styles/components/molecules/_code-diff-box.scss @@ -0,0 +1,36 @@ +// CodeDiffBox header actions: summary counts, the split or inline toggle and +// the copy button, on the code bar of the dark island. +.elb-code-diff-box__actions { + display: flex; + align-items: center; + gap: 8px; +} + +// Each count carries its sign (+, -, ~), so the colour never stands alone +.elb-code-diff-box__summary { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--type-product-caption-size); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.elb-code-diff-box__added { + color: var(--success); +} + +.elb-code-diff-box__deleted { + color: var(--danger); +} + +.elb-code-diff-box__modified { + color: var(--fg-2); +} + +.elb-code-diff-box__toggle .elb-explorer-btn { + padding: 2px 8px; + font-size: var(--type-product-caption-size); + font-weight: 500; + user-select: none; +} diff --git a/apps/explorer/src/styles/components/molecules/_code-view.scss b/apps/explorer/src/styles/components/molecules/_code-view.scss index df6132a95..c1b270933 100644 --- a/apps/explorer/src/styles/components/molecules/_code-view.scss +++ b/apps/explorer/src/styles/components/molecules/_code-view.scss @@ -3,6 +3,8 @@ // `.elb-code-view--fit`: the box sizes to its code and scrolls inside past // the cap, instead of the Box default fixed height. .elb-code-view--fit { + --height-code-view-max: 600px; + // (0,2,0) outranks `.elb-explorer-box` in _box.scss, which loads later. &.elb-explorer-box { height: auto; @@ -26,10 +28,10 @@ position: absolute; top: 6px; right: 6px; - z-index: 1; + z-index: var(--z-raised); display: flex; - background: var(--bg-box); - border-radius: var(--radius-button); + background: var(--code-bar); + border-radius: var(--radius-xs); opacity: 0; transition: opacity 0.15s; diff --git a/apps/explorer/src/styles/components/molecules/_explorer-controls.scss b/apps/explorer/src/styles/components/molecules/_explorer-controls.scss deleted file mode 100644 index 3721d60f5..000000000 --- a/apps/explorer/src/styles/components/molecules/_explorer-controls.scss +++ /dev/null @@ -1,57 +0,0 @@ -// Explorer button -.elb-explorer-btn { - padding: var(--spacing-button); - background: transparent; - border: none; - border-radius: var(--radius-button); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-button); - transition: all 0.15s; - - &:hover { - background: var(--bg-button-hover); - color: var(--color-text-button-hover); - } - - &.active { - background: var(--bg-button-active); - color: var(--color-text-button-active); - box-shadow: var(--shadow-button-active); - font-weight: var(--font-weight-semibold); - } -} - -// Explorer button group -.elb-explorer-button-group { - display: flex; - background: var(--bg-button-group); - border-radius: var(--radius-button-group); - padding: var(--spacing-button-group); - border: 1px solid var(--border-button-group); -} - -// Explorer controls container -.elb-explorer-controls { - padding: 8px 0 12px 0; - display: flex; - justify-content: flex-end; -} - -// Explorer toggle (checkbox with label) -.elb-explorer-toggle { - display: flex; - align-items: center; - gap: 6px; - font-size: var(--font-size-toggle); - color: var(--color-text-toggle); - cursor: pointer; - user-select: none; - - input[type='checkbox'] { - margin: 0; - cursor: pointer; - } -} diff --git a/apps/explorer/src/styles/components/molecules/_preview.scss b/apps/explorer/src/styles/components/molecules/_preview.scss index 420112d5e..e16906caa 100644 --- a/apps/explorer/src/styles/components/molecules/_preview.scss +++ b/apps/explorer/src/styles/components/molecules/_preview.scss @@ -14,18 +14,6 @@ display: block; } -.token.attr-name { - color: white !important; - - &[data-elb], - &[data-elbaction], - &[data-elbglobals], - &[data-elbcontext], - &[data-elbproperty] { - color: var(--highlight-property) !important; - } -} - // Preview needs more height in mobile for visual content @container grid-container (max-width: 799px) { .elb-preview-content { diff --git a/apps/explorer/src/styles/components/molecules/_property-table.scss b/apps/explorer/src/styles/components/molecules/_property-table.scss index 897b47503..0cc948e0f 100644 --- a/apps/explorer/src/styles/components/molecules/_property-table.scss +++ b/apps/explorer/src/styles/components/molecules/_property-table.scss @@ -1,8 +1,8 @@ // Property Table - Full-width responsive layout .elb-property-table__container { overflow: visible; - border-radius: var(--radius-box); - border: 1px solid var(--border-box); + border-radius: var(--radius-xs); + border: 1px solid var(--border); width: 100%; box-sizing: border-box; container-type: inline-size; @@ -17,22 +17,22 @@ width: 100%; table-layout: auto; border-collapse: collapse; - font-size: calc(var(--font-size-base) - 1px); - background: var(--bg-box); + font-size: var(--type-product-small-size); + background: var(--surface); display: table; } .elb-property-table thead { - background: var(--bg-header); + background: var(--bg-2); } .elb-property-table th { padding: 12px 16px; text-align: left; - font-weight: var(--font-weight-semibold); - color: var(--color-text-label); - border-bottom: 2px solid var(--border-box); - font-size: calc(var(--font-size-base) - 2px); + font-weight: 600; + color: var(--fg-2); + border-bottom: 2px solid var(--border); + font-size: var(--type-product-caption-size); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; @@ -40,7 +40,7 @@ .elb-property-table td { padding: 12px 16px; - border-bottom: 1px solid var(--border-box); + border-bottom: 1px solid var(--border); vertical-align: top; } @@ -50,19 +50,19 @@ } .elb-property-table__property-name { - font-weight: var(--font-weight-semibold); - color: var(--color-button-primary); + font-weight: 600; + color: var(--link); font-family: var(--font-mono); - font-size: calc(var(--font-size-base) - 2px); + font-size: var(--type-product-caption-size); } // Nested rows: subtle tint plus a left guide line so the grouping reads as a // nested type rather than a flat list of unrelated properties. .elb-property-table__row--nested { - background: var(--bg-header); + background: var(--bg-2); .elb-property-table__property-cell { - border-left: 2px solid var(--border-box); + border-left: 2px solid var(--border); } } @@ -76,18 +76,18 @@ background: none; border: none; cursor: pointer; - color: var(--color-text-label); + color: var(--fg-2); vertical-align: baseline; &:hover { - color: var(--color-button-primary); + color: var(--link); } } .elb-property-table__property-type { - color: var(--color-text); + color: var(--fg); font-family: var(--font-mono); - font-size: calc(var(--font-size-base) - 3px); + font-size: var(--type-product-caption-size); white-space: nowrap; } @@ -103,7 +103,7 @@ top: 0; bottom: 0; width: 30px; - background: linear-gradient(to right, transparent, var(--bg-box)); + background: linear-gradient(to right, transparent, var(--surface)); pointer-events: none; } } @@ -124,12 +124,12 @@ } &:hover .elb-property-table__property-type--truncated::after { - background: linear-gradient(to right, transparent, var(--bg-input-hover)); + background: linear-gradient(to right, transparent, var(--surface-2)); } } .elb-property-table__required-icon { - color: var(--color-button-danger); + color: var(--danger); font-weight: bold; margin-left: 4px; font-size: 1.1em; @@ -153,7 +153,7 @@ // Description column will auto-fill remaining space (no width specified) .elb-property-table__description { line-height: 1.4; - color: var(--color-text); + color: var(--fg); word-break: break-word; overflow-wrap: anywhere; min-width: 0; @@ -163,9 +163,9 @@ background: none; border: none; padding: 6px; - border-radius: var(--radius-button); + border-radius: var(--radius-xs); cursor: pointer; - color: var(--color-button-primary); + color: var(--link); display: inline-flex; align-items: center; justify-content: center; @@ -173,13 +173,13 @@ } .elb-property-table__more-button:hover { - background: var(--bg-button-hover); + background: var(--surface-2); } .elb-property-table__required-notice { margin-top: 0; - font-size: calc(var(--font-size-base) - 2px); - color: var(--color-text-muted); + font-size: var(--type-product-caption-size); + color: var(--fg-2); font-style: italic; } @@ -190,23 +190,23 @@ left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--backdrop); display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: var(--z-modal); padding: 20px; } +// A floating layer: a surface with a 1px strong edge, no shadow .elb-property-table__modal-content { - background: var(--bg-box); - border-radius: var(--radius-box); - box-shadow: var(--shadow-dropdown); + background: var(--surface); + border-radius: var(--radius-xs); max-width: 600px; width: 100%; max-height: 80vh; overflow-y: auto; - border: 1px solid var(--border-box); + border: 1px solid var(--border-strong); } .elb-property-table__modal-header { @@ -214,31 +214,31 @@ align-items: center; justify-content: space-between; padding: 20px 24px 16px; - border-bottom: 1px solid var(--border-box); + border-bottom: 1px solid var(--border); } .elb-property-table__modal-title { margin: 0; - font-size: 1.25rem; - font-weight: var(--font-weight-semibold); + font-size: var(--type-product-heading-size); + font-weight: 600; display: flex; align-items: center; } .elb-property-table__modal-property-name { font-family: var(--font-mono); - color: var(--color-button-primary); - font-weight: var(--font-weight-semibold); - font-size: 1rem; + color: var(--link); + font-weight: 600; + font-size: var(--type-product-title-size); } .elb-property-table__close-button { background: none; border: none; padding: 8px; - border-radius: var(--radius-button); + border-radius: var(--radius-xs); cursor: pointer; - color: var(--color-text-muted); + color: var(--fg-2); display: inline-flex; align-items: center; justify-content: center; @@ -248,8 +248,8 @@ } .elb-property-table__close-button:hover { - color: var(--color-text); - background: var(--bg-button-hover); + color: var(--fg); + background: var(--surface-2); } .elb-property-table__modal-body { @@ -266,16 +266,16 @@ .elb-property-table__modal-label { display: block; - font-weight: var(--font-weight-semibold); - color: var(--color-text-label); + font-weight: 600; + color: var(--fg-2); margin-bottom: 6px; - font-size: calc(var(--font-size-base) - 1px); + font-size: var(--type-product-small-size); } .elb-property-table__modal-type { font-family: var(--font-mono); - color: var(--color-text); - font-size: calc(var(--font-size-base) - 1px); + color: var(--fg); + font-size: var(--type-product-small-size); } .elb-property-table__modal-type--wrap { @@ -288,19 +288,19 @@ .elb-property-table__modal-description { margin: 0; line-height: 1.5; - color: var(--color-text); + color: var(--fg); } .elb-property-table__modal-default { font-family: var(--font-mono); - color: var(--color-text); - font-size: calc(var(--font-size-base) - 1px); + color: var(--fg); + font-size: var(--type-product-small-size); } .elb-property-table__modal-example { font-family: var(--font-mono); - color: var(--color-text); - font-size: calc(var(--font-size-base) - 1px); + color: var(--fg); + font-size: var(--type-product-small-size); display: block; white-space: pre-wrap; word-break: break-word; @@ -313,7 +313,7 @@ } .elb-property-table { - font-size: calc(var(--font-size-base) - 2px); + font-size: var(--type-product-caption-size); display: block; } @@ -328,21 +328,21 @@ .elb-property-table tr { display: block; margin-bottom: 1rem; - border: 1px solid var(--border-box); - border-radius: var(--radius-box); - background: var(--bg-box); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface); position: relative; } .elb-property-table td { display: block; padding: 8px 12px; - border-bottom: 1px solid var(--border-box); + border-bottom: 1px solid var(--border); text-align: left; } .elb-property-table tbody tr:last-child td { - border-bottom: 1px solid var(--border-box); + border-bottom: 1px solid var(--border); } .elb-property-table td:last-child { @@ -352,16 +352,16 @@ .elb-property-table td::before { content: attr(data-label); display: block; - font-weight: var(--font-weight-semibold); - color: var(--color-text-label); - font-size: calc(var(--font-size-base) - 3px); + font-weight: 600; + color: var(--fg-2); + font-size: var(--type-product-caption-size); text-transform: uppercase; margin-bottom: 4px; } .elb-property-table__property-name, .elb-property-table__property-type { - font-size: calc(var(--font-size-base) - 2px); + font-size: var(--type-product-caption-size); } .elb-property-table__property-cell { @@ -380,7 +380,7 @@ } .elb-property-table__description { - font-size: calc(var(--font-size-base) - 2px); + font-size: var(--type-product-caption-size); } .elb-property-table__action-cell { @@ -404,7 +404,7 @@ width: auto; min-width: 28px; height: 28px; - border-radius: 4px; + border-radius: var(--radius-xs); } .elb-property-table__more-button svg { @@ -432,7 +432,7 @@ justify-content: center; min-height: 120px; padding: 2rem 1rem; - color: var(--color-text-muted); + color: var(--fg-2); font-style: italic; text-align: center; } @@ -471,7 +471,7 @@ column-gap: 8px; row-gap: 4px; padding: 12px 16px; - border-bottom: 1px solid var(--border-box); + border-bottom: 1px solid var(--border); } .elb-property-table tbody tr:last-child { @@ -511,7 +511,7 @@ grid-row: 2; grid-column: 1 / -1; line-height: 1.4; - color: var(--color-text-muted); - font-size: calc(var(--font-size-base) - 1px); + color: var(--fg-2); + font-size: var(--type-product-small-size); } } diff --git a/apps/explorer/src/styles/components/molecules/_step-example.scss b/apps/explorer/src/styles/components/molecules/_step-example.scss index a4e6f9635..85966ffae 100644 --- a/apps/explorer/src/styles/components/molecules/_step-example.scss +++ b/apps/explorer/src/styles/components/molecules/_step-example.scss @@ -3,12 +3,17 @@ // container narrows. Flex wrap rather than Grid: no breakpoint or JS height // sync, and a wrapped box fills its row. Each box is a CodeView that sizes to // its code (_code-view.scss); this only lays out the row and caps it lower. +.elb-step-example { + --step-example-min-box-width: 220px; + --step-example-max-height: 360px; +} + .elb-step-example-boxes { display: flex; flex-wrap: wrap; // Boxes in a row share the tallest one's height, itself capped below. align-items: stretch; - gap: var(--spacing-grid-gap); + gap: 12px; // (0,3,0) outranks the CodeView cap regardless of stylesheet order. .elb-step-example & > .elb-explorer-box { diff --git a/apps/explorer/src/styles/components/organisms/_box.scss b/apps/explorer/src/styles/components/organisms/_box.scss index 81e83f6b0..c9d44690d 100644 --- a/apps/explorer/src/styles/components/organisms/_box.scss +++ b/apps/explorer/src/styles/components/organisms/_box.scss @@ -2,9 +2,9 @@ .elb-explorer-box { display: flex; flex-direction: column; - background: var(--bg-box); - border: 1px solid var(--border-box); - border-radius: var(--radius-box); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xs); overflow: hidden; box-sizing: border-box; @@ -46,23 +46,41 @@ } } +// Code panels (CodeBox, CodeView, CodeDiffBox, BrowserBox) are dark islands in +// the code block colours, matching the docs' code blocks. +.elb-explorer-box--code { + background: var(--code-bg); + border-color: var(--code-border); + + > .elb-explorer-header, + > .elb-explorer-tabs, + > .elb-explorer-footer { + background: var(--code-bar); + border-color: var(--code-border); + } + + > .elb-explorer-content { + background: var(--code-bg); + } +} + // Header section .elb-explorer-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--spacing-header); - background: var(--bg-header); - border-bottom: 1px solid var(--border-header); + padding: 6px 10px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); flex-shrink: 0; height: 36px; box-sizing: border-box; } .elb-explorer-label { - font-size: var(--font-size-label); - font-weight: var(--font-weight-semibold); - color: var(--color-text-label); + font-size: var(--type-product-small-size); + font-weight: 600; + color: var(--fg-2); user-select: none; } @@ -71,9 +89,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: var(--spacing-footer); - background: var(--bg-footer); - border-top: 1px solid var(--border-footer); + padding: 6px 10px; + background: var(--bg-2); + border-top: 1px solid var(--border); flex-shrink: 0; height: 40px; box-sizing: border-box; @@ -89,7 +107,7 @@ flex: 1; min-height: 0; // Critical for flex overflow position: relative; - background: var(--bg-box); + background: var(--surface); overflow: hidden; // Hidden (not auto) - lets Monaco handle internal scrolling display: flex; flex-direction: column; @@ -106,21 +124,12 @@ } } -// Button group styling -.elb-explorer-button-group { - display: flex; - background: var(--bg-button-group); - border-radius: var(--radius-button-group); - padding: var(--spacing-button-group); - border: 1px solid var(--border-button-group); -} - // Tabs header .elb-explorer-tabs { display: flex; align-items: center; - background: var(--bg-header); - border-bottom: 1px solid var(--border-header); + background: var(--bg-2); + border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 36px; box-sizing: border-box; @@ -128,23 +137,25 @@ .elb-explorer-tab { padding: 0.5rem 1rem; - font-size: var(--font-size-label); - font-weight: var(--font-weight-semibold); - color: var(--color-text-muted); + font-size: var(--type-product-small-size); + font-weight: 600; + color: var(--fg-2); background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; - transition: color 0.15s ease, border-color 0.15s ease; + transition: + color 0.15s ease, + border-color 0.15s ease; user-select: none; &:hover { - color: var(--color-text-label); + color: var(--fg); } &--active { - color: #01b5e2; - border-bottom-color: #01b5e2; + color: var(--link); + border-bottom-color: var(--primary); } } @@ -153,7 +164,7 @@ padding-right: 0.5rem; } -// Traffic lights (Mac window decoration) +// Traffic lights (Mac window decoration), neutral dots on the bar .elb-explorer-traffic-lights { display: flex; gap: 0.5rem; @@ -164,17 +175,6 @@ .elb-explorer-traffic-light { width: 0.75rem; height: 0.75rem; - border-radius: 50%; - - &--red { - background-color: #ff5f56; - } - - &--yellow { - background-color: #ffbd2e; - } - - &--green { - background-color: #27c93f; - } + border-radius: var(--radius-full); + background-color: var(--border-strong); } diff --git a/apps/explorer/src/styles/foundation/_grid.scss b/apps/explorer/src/styles/foundation/_grid.scss index 821b29865..8e7e8f167 100644 --- a/apps/explorer/src/styles/foundation/_grid.scss +++ b/apps/explorer/src/styles/foundation/_grid.scss @@ -2,8 +2,14 @@ @use 'sass:map'; @use '../foundation/responsive' as *; -// Grid wrapper with container query context +// Grid wrapper with container query context. The grid geometry is local to +// the component; Grid's props override it on the grid element. .elb-explorer-grid-wrapper { + --grid-min-box-width: 350px; + --grid-row-min-height: 250px; + --grid-row-max-height: 450px; + --grid-box-max-height-mobile: 500px; + position: relative; width: 100%; container-type: inline-size; @@ -13,7 +19,7 @@ // Grid layout - modern CSS, no JS required .elb-explorer-grid { display: grid; - gap: var(--spacing-grid-gap); + gap: 12px; width: 100%; // Mobile-first: Vertical stack @@ -83,21 +89,20 @@ position: absolute; top: 50%; transform: translateY(-50%); - z-index: 10; + z-index: var(--z-raised); width: 40px; height: 40px; padding: 0; - border: none; - border-radius: 50%; - background: var(--bg-box); - color: var(--color-text); + border: 1px solid var(--border-strong); + border-radius: var(--radius-full); + background: var(--surface); + color: var(--fg); font-size: 24px; line-height: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); transition: all 0.2s ease; user-select: none; @@ -107,8 +112,7 @@ } &:hover { - background: var(--bg-button-hover); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + background: var(--surface-2); transform: translateY(-50%) scale(1.1); } diff --git a/apps/explorer/src/styles/foundation/_reset.scss b/apps/explorer/src/styles/foundation/_reset.scss index 20e4955f7..ae2729284 100644 --- a/apps/explorer/src/styles/foundation/_reset.scss +++ b/apps/explorer/src/styles/foundation/_reset.scss @@ -1,11 +1,11 @@ // Scope all resets under .elb-explorer to avoid affecting host application .elb-explorer { - // Establish baseline styles for embedded environments - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, - Cantarell, 'Helvetica Neue', sans-serif; - font-size: 14px; - line-height: 1.5; - color: var(--color-text); + // Establish baseline styles for embedded environments: the product body + // type style on the design tokens + font-family: var(--font-sans); + font-size: var(--type-product-body-size); + line-height: var(--type-product-body-line-height); + color: var(--fg); * { box-sizing: border-box; diff --git a/apps/explorer/src/styles/foundation/_spacing.scss b/apps/explorer/src/styles/foundation/_spacing.scss deleted file mode 100644 index 416964464..000000000 --- a/apps/explorer/src/styles/foundation/_spacing.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use '../theme/tokens' as *; - -@mixin spacing($property, $size) { - #{$property}: var(--spacing-#{$size}, $size); -} - -@mixin padding-sm { - padding: $spacing-sm; -} - -@mixin padding-md { - padding: $spacing-md; -} - -@mixin padding-lg { - padding: $spacing-lg; -} - -@mixin gap-sm { - gap: $spacing-sm; -} - -@mixin gap-md { - gap: $spacing-md; -} - -@mixin gap-lg { - gap: $spacing-lg; -} diff --git a/apps/explorer/src/styles/foundation/_typography.scss b/apps/explorer/src/styles/foundation/_typography.scss index 815abb5ec..0de8c45c5 100644 --- a/apps/explorer/src/styles/foundation/_typography.scss +++ b/apps/explorer/src/styles/foundation/_typography.scss @@ -1,18 +1,18 @@ @mixin text-label { - font-size: var(--font-size-label); - font-weight: var(--font-weight-normal); - color: var(--color-text-label); + font-size: var(--type-product-small-size); + font-weight: 400; + color: var(--fg-2); line-height: 1.4; } @mixin text-muted { - font-size: calc(var(--font-size-base) - 1px); - color: var(--color-text-muted); + font-size: var(--type-product-small-size); + color: var(--fg-2); line-height: 1.4; } @mixin text-button { - font-size: var(--font-size-base); - font-family: var(--font-family-base); - font-weight: var(--font-weight-normal); + font-size: var(--type-product-body-size); + font-family: var(--font-sans); + font-weight: 400; } diff --git a/apps/explorer/src/styles/index.scss b/apps/explorer/src/styles/index.scss index 7d7f5b262..cdeee90a7 100644 --- a/apps/explorer/src/styles/index.scss +++ b/apps/explorer/src/styles/index.scss @@ -1,21 +1,14 @@ // walkerOS Explorer - SCSS Architecture // Single entry point for all styles -// Theme (always first) -@use './theme/tokens'; -@use './theme/variables'; -@use './theme/dark'; - // Foundation @use './foundation/reset'; @use './foundation/typography'; @use './foundation/layout'; -@use './foundation/spacing'; @use './foundation/responsive'; @use './foundation/grid'; // Utilities -@use './utilities/scrollbar'; @use './utilities/helpers'; // Components - Atoms @@ -26,11 +19,11 @@ // Components - Molecules @use './components/molecules/code-box'; +@use './components/molecules/code-diff-box'; @use './components/molecules/code-snippet'; @use './components/molecules/code-view'; @use './components/molecules/step-example'; @use './components/molecules/preview'; -@use './components/molecules/explorer-controls'; @use './components/molecules/architecture-flow'; @use './components/molecules/flow-map'; @use './components/molecules/property-table'; diff --git a/apps/explorer/src/styles/theme/_dark.scss b/apps/explorer/src/styles/theme/_dark.scss deleted file mode 100644 index e8c49d9c1..000000000 --- a/apps/explorer/src/styles/theme/_dark.scss +++ /dev/null @@ -1,75 +0,0 @@ -// Scope dark theme to .elb-explorer under [data-theme='dark'] -// Colors aligned with website (Tailwind gray scale) -[data-theme='dark'] .elb-explorer { - --color-text: #f3f4f6; - --color-text-label: #d1d5db; - --color-text-button: #d1d5db; - --color-text-button-hover: #ffffff; - --color-text-button-active: #ffffff; - --color-text-muted: #9ca3af; - --color-text-toggle: #9ca3af; - --color-text-input: #f3f4f6; - --color-text-placeholder: #6b7280; - - --bg-box: #292d3e; - --bg-header: #1f2937; - --bg-footer: #1f2937; - --bg-button-hover: #374151; - --bg-button-active: #111827; - --bg-button-group: #374151; - --bg-input: #1f2937; - --bg-input-hover: #374151; - --bg-code-inline: rgba(255, 255, 255, 0.05); - --bg-dropdown: #1f2937; - --bg-dropdown-option-hover: #374151; - --bg-dropdown-option-highlighted: #1e3a5f; - - --border-box: #4b5563; - --border-header: #4b5563; - --border-footer: #4b5563; - --border-button-group: #4b5563; - --border-input: #4b5563; - --border-input-focus: #01b5e2; - - --shadow-button-active: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-dropdown: 0 10px 15px -3px rgba(0, 0, 0, 0.3); - - --color-button-primary: #01b5e2; - --color-button-primary-hover: #015372; - --color-button-danger: #ef4444; - --color-button-danger-hover: #dc2626; - - // FlowMap - data flow visualization colors (dark theme) - // Soft Pastels palette: mint → amber → sky → amber → lavender - --flow-source-fill: transparent; - --flow-source-stroke: #6ee7b7; // Mint - --flow-transformer-fill: transparent; - --flow-transformer-stroke: #fcd34d; // Amber (warm, between stages) - --flow-collector-fill: transparent; - --flow-collector-stroke: #7dd3fc; // Sky - --flow-destination-fill: transparent; - --flow-destination-stroke: #c4b5fd; // Lavender - --flow-edge-stroke: #9ca3af; - --flow-before-fill: transparent; - --flow-before-stroke: #9ca3af; // Gray (neutral) - --flow-after-fill: transparent; - --flow-after-stroke: #9ca3af; // Gray (neutral) - - // Alert variants (dark theme) - --color-alert-info-bg: rgba(59, 130, 246, 0.15); - --color-alert-info-border: rgba(59, 130, 246, 0.3); - --color-alert-info-text: #93c5fd; - --color-alert-success-bg: rgba(34, 197, 94, 0.15); - --color-alert-success-border: rgba(34, 197, 94, 0.3); - --color-alert-success-text: #86efac; - --color-alert-warning-bg: rgba(245, 158, 11, 0.15); - --color-alert-warning-border: rgba(245, 158, 11, 0.3); - --color-alert-warning-text: #fcd34d; - --color-alert-error-bg: rgba(239, 68, 68, 0.15); - --color-alert-error-border: rgba(239, 68, 68, 0.3); - --color-alert-error-text: #fca5a5; - - // Focus/error states (dark theme) - --color-focus-ring: rgba(1, 181, 226, 0.3); - --color-error-ring: rgba(239, 68, 68, 0.3); -} diff --git a/apps/explorer/src/styles/theme/_tokens.scss b/apps/explorer/src/styles/theme/_tokens.scss deleted file mode 100644 index bc85eeda1..000000000 --- a/apps/explorer/src/styles/theme/_tokens.scss +++ /dev/null @@ -1,25 +0,0 @@ -$spacing-xs: 4px; -$spacing-sm: 8px; -$spacing-md: 12px; -$spacing-lg: 16px; -$spacing-xl: 24px; -$spacing-xxl: 32px; - -$height-code-view-max: 600px; - -$radius-sm: 3px; -$radius-md: 4px; -$radius-lg: 6px; - -$font-size-xs: 11px; -$font-size-sm: 12px; -$font-size-md: 13px; -$font-size-base: 14px; -$font-size-lg: 0.75rem; -$font-size-xl: 16px; - -$font-weight-normal: 400; -$font-weight-semibold: 600; - -$line-height-base: 1.5; -$line-height-tight: 1.4; diff --git a/apps/explorer/src/styles/theme/_variables.scss b/apps/explorer/src/styles/theme/_variables.scss deleted file mode 100644 index 7f6203ad5..000000000 --- a/apps/explorer/src/styles/theme/_variables.scss +++ /dev/null @@ -1,260 +0,0 @@ -@use './tokens' as *; - -// Default light theme - fallback when no data-theme attribute is set -// Colors aligned with website (Tailwind gray scale) -.elb-explorer { - --color-text: #111827; - --color-text-label: #4b5563; - --color-text-button: #6b7280; - --color-text-button-hover: #374151; - --color-text-button-active: #1f2937; - --color-text-muted: #6b7280; - --color-text-toggle: #6b7280; - --color-text-input: #111827; - --color-text-placeholder: #9ca3af; - - --bg-box: #ffffff; - --bg-header: #f5f5f5; - --bg-footer: #f5f5f5; - --bg-button-hover: #e8e8e8; - --bg-button-active: #ffffff; - --bg-button-group: #f3f4f6; - --bg-input: #ffffff; - --bg-input-hover: #f9f9f9; - --bg-code-inline: #f9f9f9; - --bg-dropdown: #ffffff; - --bg-dropdown-option-hover: #f0f0f0; - --bg-dropdown-option-highlighted: #e3f2fd; - - --border-box: #e0e0e0; - --border-header: #e0e0e0; - --border-footer: #e0e0e0; - --border-button-group: #d1d5db; - --border-input: #d1d5db; - --border-input-focus: #01b5e2; - - --shadow-button-active: 0 1px 2px rgba(0, 0, 0, 0.1); - --shadow-dropdown: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - - --color-button-primary: #01b5e2; - --color-button-primary-hover: #015372; - --color-button-primary-text: #ffffff; - --color-button-danger: #ef4444; - --color-button-danger-hover: #dc2626; - --color-button-danger-text: #ffffff; - - --color-status-enabled: #22c55e; - --color-status-disabled: #9ca3af; - --color-status-warning: #f59e0b; - - // Alert variants - --color-alert-info-bg: #eff6ff; - --color-alert-info-border: #bfdbfe; - --color-alert-info-text: #1e40af; - --color-alert-success-bg: #f0fdf4; - --color-alert-success-border: #bbf7d0; - --color-alert-success-text: #166534; - --color-alert-warning-bg: #fefce8; - --color-alert-warning-border: #fde68a; - --color-alert-warning-text: #854d0e; - --color-alert-error-bg: #fef2f2; - --color-alert-error-border: #fecaca; - --color-alert-error-text: #991b1b; - - // Form focus/error states - --color-focus-ring: rgba(1, 181, 226, 0.2); - --color-error-ring: rgba(239, 68, 68, 0.2); - - // Spacing tokens - --spacing-xs: #{$spacing-xs}; - --spacing-sm: #{$spacing-sm}; - --spacing-md: #{$spacing-md}; - --spacing-lg: #{$spacing-lg}; - --spacing-xl: #{$spacing-xl}; - - // Additional font sizes - --font-size-xs: #{$font-size-xs}; - --font-size-sm: #{$font-size-sm}; - --line-height-relaxed: 1.6; - --font-weight-medium: 500; - - // Shadows - --shadow-lg: - 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - - // Z-index - --z-dropdown: 50; - - --color-highlight-primary: #01b5e2; - - --radius-box: #{$radius-md}; - --radius-button: #{$radius-sm}; - --radius-button-group: #{$radius-md}; - --radius-highlight-button: #{$radius-lg}; - - --spacing-header: 6px 10px; - --spacing-footer: 6px 10px; - --spacing-button: 4px 8px; - --spacing-button-group: 1px; - --spacing-grid-gap: #{$spacing-md}; - - --grid-min-box-width: 350px; - --grid-row-min-height: 250px; - --grid-row-max-height: 450px; - --grid-box-max-height-mobile: 500px; - - --height-code-view-max: #{$height-code-view-max}; - - --step-example-min-box-width: 220px; - --step-example-max-height: 360px; - - --monaco-font-size: #{$font-size-md}; - --monaco-line-height: 1.5; - --monaco-font-size-snippet: #{$font-size-xl}; - - --font-family-base: - system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', - 'Helvetica Neue', sans-serif; - --font-mono: - 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', - monospace; - --font-size-base: #{$font-size-base}; - --font-size-label: #{$font-size-md}; - --font-size-toggle: #{$font-size-sm}; - --font-size-highlight-button: #{$font-size-lg}; - --font-size-xl: #{$font-size-xl}; - --line-height-base: #{$line-height-base}; - --font-weight-normal: #{$font-weight-normal}; - --font-weight-semibold: #{$font-weight-semibold}; - - --highlight-globals: #4fc3f7cc; - --highlight-context: #ffbd44cc; - --highlight-entity: #00ca4ecc; - --highlight-property: #ff605ccc; - --highlight-action: #9900ffcc; - --highlight-background: #1f2937; - --highlight-text: #9ca3af; - --highlight-hover: rgba(255, 255, 255, 0.05); - --highlight-separator: rgba(255, 255, 255, 0.05); - - // FlowMap - data flow visualization colors (light theme) - // Soft Pastels palette: mint → amber → sky → amber → lavender - --flow-source-fill: transparent; - --flow-source-stroke: #6ee7b7; // Mint - --flow-transformer-fill: transparent; - --flow-transformer-stroke: #fcd34d; // Amber (warm, between stages) - --flow-collector-fill: transparent; - --flow-collector-stroke: #7dd3fc; // Sky - --flow-destination-fill: transparent; - --flow-destination-stroke: #c4b5fd; // Lavender - --flow-edge-stroke: #9ca3af; -} - -// Explicit light theme when data-theme='light' is set on ancestor -[data-theme='light'] .elb-explorer { - --color-text: #000; - --color-text-label: #424242; - --color-text-button: #616161; - --color-text-button-hover: #424242; - --color-text-button-active: #1f2937; - --color-text-muted: #666; - --color-text-toggle: #666; - --color-text-input: #000; - --color-text-placeholder: #9ca3af; - - --bg-box: #ffffff; - --bg-header: #f5f5f5; - --bg-footer: #f5f5f5; - --bg-button-hover: #e8e8e8; - --bg-button-active: #ffffff; - --bg-button-group: #f3f4f6; - --bg-input: #ffffff; - --bg-input-hover: #f9f9f9; - --bg-code-inline: #f9f9f9; - --bg-dropdown: #ffffff; - --bg-dropdown-option-hover: #f0f0f0; - --bg-dropdown-option-highlighted: #e3f2fd; - - --border-box: #e0e0e0; - --border-header: #e0e0e0; - --border-footer: #e0e0e0; - --border-button-group: #d1d5db; - --border-input: #d1d5db; - --border-input-focus: #01b5e2; - - --shadow-button-active: 0 1px 2px rgba(0, 0, 0, 0.1); - --shadow-dropdown: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - - --color-button-primary: #01b5e2; - --color-button-primary-hover: #015372; - --color-button-primary-text: #ffffff; - --color-button-danger: #ef4444; - --color-button-danger-hover: #dc2626; - --color-button-danger-text: #ffffff; - - --color-status-enabled: #22c55e; - --color-status-disabled: #9ca3af; - --color-status-warning: #f59e0b; - - --radius-box: #{$radius-md}; - --radius-button: #{$radius-sm}; - --radius-button-group: #{$radius-md}; - --radius-highlight-button: #{$radius-lg}; - - --spacing-header: 6px 10px; - --spacing-footer: 6px 10px; - --spacing-button: 4px 8px; - --spacing-button-group: 1px; - --spacing-grid-gap: #{$spacing-md}; - - --grid-min-box-width: 350px; - --grid-row-min-height: 250px; - --grid-row-max-height: 450px; - --grid-box-max-height-mobile: 500px; - - --height-code-view-max: #{$height-code-view-max}; - - --step-example-min-box-width: 220px; - --step-example-max-height: 360px; - - --monaco-font-size: #{$font-size-md}; - --monaco-line-height: 1.5; - --monaco-font-size-snippet: #{$font-size-xl}; - - --font-family-base: - system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', - 'Helvetica Neue', sans-serif; - --font-mono: - 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', - monospace; - --font-size-base: #{$font-size-base}; - --font-size-label: #{$font-size-md}; - --font-size-toggle: #{$font-size-sm}; - --font-size-highlight-button: #{$font-size-lg}; - --font-size-xl: #{$font-size-xl}; - --line-height-base: #{$line-height-base}; - --font-weight-normal: #{$font-weight-normal}; - --font-weight-semibold: #{$font-weight-semibold}; - - --highlight-globals: #4fc3f7cc; - --highlight-context: #ffbd44cc; - --highlight-entity: #00ca4ecc; - --highlight-property: #ff605ccc; - --highlight-action: #9900ffcc; - --highlight-background: #1f2937; - --highlight-text: #9ca3af; - --highlight-hover: rgba(255, 255, 255, 0.05); - --highlight-separator: rgba(255, 255, 255, 0.05); - - // FlowMap - data flow visualization colors (explicit light) - // Soft Pastels palette: mint → amber → sky → amber → lavender - --flow-source-fill: transparent; - --flow-source-stroke: #6ee7b7; // Mint - --flow-transformer-fill: transparent; - --flow-transformer-stroke: #fcd34d; // Amber (warm, between stages) - --flow-collector-fill: transparent; - --flow-collector-stroke: #7dd3fc; // Sky - --flow-destination-fill: transparent; - --flow-destination-stroke: #c4b5fd; // Lavender - --flow-edge-stroke: #9ca3af; -} diff --git a/apps/explorer/src/styles/utilities/_helpers.scss b/apps/explorer/src/styles/utilities/_helpers.scss index 1f5f045c9..b0cd586a2 100644 --- a/apps/explorer/src/styles/utilities/_helpers.scss +++ b/apps/explorer/src/styles/utilities/_helpers.scss @@ -3,8 +3,17 @@ max-width: 100%; background: transparent; padding: 0; - font-family: var(--font-family-base); - font-size: var(--font-size-base); - line-height: var(--line-height-base); - color: var(--color-text); +} + +// Text for screen readers only +.elb-explorer-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } diff --git a/apps/explorer/src/styles/utilities/_scrollbar.scss b/apps/explorer/src/styles/utilities/_scrollbar.scss deleted file mode 100644 index 322cb3c49..000000000 --- a/apps/explorer/src/styles/utilities/_scrollbar.scss +++ /dev/null @@ -1,29 +0,0 @@ -.elb-explorer { - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); - border-radius: 4px; - - &:hover { - background: rgba(0, 0, 0, 0.2); - } - } -} - -[data-theme='dark'] .elb-explorer { - ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - - &:hover { - background: rgba(255, 255, 255, 0.2); - } - } -} diff --git a/apps/explorer/src/themes/__tests__/theme.test.ts b/apps/explorer/src/themes/__tests__/theme.test.ts new file mode 100644 index 000000000..69e9b4c70 --- /dev/null +++ b/apps/explorer/src/themes/__tests__/theme.test.ts @@ -0,0 +1,46 @@ +import type { editor } from 'monaco-editor'; +import * as design from '../../design'; +import { ELB_THEME_DARK, registerTheme } from '..'; + +// Every colour a design constant holds, in both themes. +const designColors = new Set( + Object.values(design).flatMap((value) => + typeof value === 'string' ? [value] : [value.dark, value.light], + ), +); + +// Monaco rules take hex without the leading `#`. +const withHash = (color: string): string => + color.startsWith('#') ? color : `#${color}`; + +function registeredThemes() { + const defineTheme = jest.fn( + (name: string, theme: editor.IStandaloneThemeData) => ({ name, theme }), + ); + registerTheme({ editor: { defineTheme } }); + return defineTheme.mock.calls; +} + +it('registers one dark theme for every editor', () => { + const calls = registeredThemes(); + + expect(calls).toHaveLength(1); + const [name, theme] = calls[0]; + expect(name).toBe(ELB_THEME_DARK); + expect(theme.base).toBe('vs-dark'); +}); + +it('builds every theme colour from the design constants', () => { + const [[, theme]] = registeredThemes(); + const colors = [ + ...theme.rules.flatMap((rule) => + rule.foreground === undefined ? [] : [rule.foreground], + ), + ...Object.values(theme.colors), + ]; + + expect(colors.length).toBeGreaterThan(0); + expect(colors.filter((color) => !designColors.has(withHash(color)))).toEqual( + [], + ); +}); diff --git a/apps/explorer/src/themes/index.ts b/apps/explorer/src/themes/index.ts index 93f25240c..c4588b380 100644 --- a/apps/explorer/src/themes/index.ts +++ b/apps/explorer/src/themes/index.ts @@ -1,43 +1,40 @@ /** - * Monaco Editor Themes for walkerOS Explorer + * The one code theme of walkerOS Explorer * - * Custom Monaco themes that integrate seamlessly with the CSS-based theme system. - * Theme switching is handled via CSS `data-theme` attribute on document root. + * Code surfaces are dark in both page themes, so Monaco (CodeBox) and Shiki + * (CodeView) share one theme, built from the design constants. */ +import type { editor } from 'monaco-editor'; +import { palenightTheme } from './palenight'; -// Import registration functions for use in registerAllThemes -import { registerPalenightTheme } from './palenight'; -import { registerLighthouseTheme } from './lighthouse'; +/** Monaco `defineTheme` key and Shiki theme name of the code theme. */ +export const ELB_THEME_DARK = 'elbTheme-dark'; -// Monaco Editor themes -export { palenightTheme, registerPalenightTheme } from './palenight'; -export { lighthouseTheme, registerLighthouseTheme } from './lighthouse'; - -// Theme name constants — use these instead of hard-coded string literals -export { ELB_THEME_DARK, ELB_THEME_LIGHT } from './names'; -export type { ElbThemeName } from './names'; - -// Unified scope → color grouping (drives Monaco + Shiki from one source) -export type { TokenGroup } from './token-groups'; - -// Theme types (for documentation and TypeScript support) -export type { ExplorerTheme } from './types'; +/** The part of the Monaco API that registering a theme needs. */ +export interface MonacoThemeRegistry { + editor: { + defineTheme( + themeName: string, + themeData: editor.IStandaloneThemeData, + ): void; + }; +} /** - * Register all Monaco themes - * Convenience function for registering both themes at once + * Register the code theme with Monaco. Call it before the first editor + * mounts; Code and CodeDiff do so in their `beforeMount`. * * @example * ```typescript - * import { registerAllThemes } from '@walkeros/explorer'; - * import * as monaco from 'monaco-editor'; + * import { registerTheme, ELB_THEME_DARK } from '@walkeros/explorer'; * - * registerAllThemes(monaco); + * registerTheme(monaco); + * monaco.editor.setTheme(ELB_THEME_DARK); * ``` */ -export function registerAllThemes( - monaco: typeof import('monaco-editor'), -): void { - registerPalenightTheme(monaco); - registerLighthouseTheme(monaco); +export function registerTheme(monaco: MonacoThemeRegistry): void { + monaco.editor.defineTheme(ELB_THEME_DARK, palenightTheme); } + +// Unified scope to colour grouping (drives Monaco and Shiki from one source) +export type { TokenGroup } from './token-groups'; diff --git a/apps/explorer/src/themes/lighthouse.ts b/apps/explorer/src/themes/lighthouse.ts deleted file mode 100644 index 6b5e03a50..000000000 --- a/apps/explorer/src/themes/lighthouse.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Lighthouse Theme for Monaco Editor - * - * Custom light theme for walkerOS Explorer, designed to complement the Palenight dark theme. - * Based on VS Code Light+ with adjustments for optimal readability and brand consistency. - * - * Color Palette Strategy: - * - Comments: Muted green (#6A737D) for subtle annotations - * - Strings: Dark green (#22863A) for clear text distinction - * - Numbers: Brick red (#D73A49) for numeric emphasis - * - Functions: Deep blue (#005CC5) for callable identification - * - Keywords: Purple (#6F42C1) maintaining syntax hierarchy - * - Variables: Near black (#24292E) for primary code elements - * - Types/Classes: Dark gold (#B08800) for type definitions - * - Tags: Red (#D73A49) for HTML/JSX elements - * - Operators: Teal (#0086B3) for logical operations - * - Booleans: Red (#D73A49) for literal values - */ - -import type { editor } from 'monaco-editor'; -import { ELB_THEME_LIGHT } from './names'; -import { tokenGroupsToMonacoRules, type TokenGroup } from './token-groups'; - -// Semantic color palette — change a hex here, it flows to every scope that uses it. -const C = { - comment: '6A737D', - string: '22863A', - regexp: '032F62', - number: 'D73A49', - numericConstant: 'fb923c', - keyword: '6F42C1', - operator: '01b5e2', // Brand cyan — `operator` / `operators` tokens only - operatorKeyword: '0086B3', // Darker teal — `keyword.operator*` - function: '005CC5', - type: 'B08800', - variable: '24292E', - bool: 'ef4444', - tag: 'D73A49', - namespace: '6F42C1', - url: '005CC5', - invalid: 'CB2431', - invalidDep: 'D73A49', - cssSelector: 'D73A49', - cssClass: 'B08800', - cssId: '005CC5', - cssProperty: '6F42C1', - cssValue: 'D73A49', -} as const; - -// Token groups pool Monarch token names (Monaco) + TextMate scopes (Shiki). -// Both engines consume the SAME list — change a group, both pick up. -const TOKEN_GROUPS: TokenGroup[] = [ - // Comments - { - foreground: C.comment, - fontStyle: 'italic', - scopes: [ - 'comment', - 'comment.block', - 'comment.line', - 'comment.html', - 'comment.line.double-slash', - 'comment.line.number-sign', - 'comment.block.documentation', - 'punctuation.definition.comment', - ], - }, - - // Strings (generic) - { - foreground: C.string, - scopes: [ - 'string', - 'string.quoted', - 'string.template', - 'string.value.json', - 'string.json', - 'string.html', - 'string.css', - 'string.js', - 'string.ts', - 'string.quoted.single', - 'string.quoted.double', - 'string.quoted.triple', - 'punctuation.definition.string', - 'punctuation.definition.string.begin', - 'punctuation.definition.string.end', - 'meta.string', - 'attribute.value.html', - ], - }, - - // Regex - { - foreground: C.regexp, - scopes: ['string.regexp'], - }, - - // Numbers (base) - { - foreground: C.number, - scopes: [ - 'number', - 'number.hex', - 'number.binary', - 'number.octal', - 'number.float', - 'constant.numeric.decimal', - 'constant.numeric.integer', - 'constant.numeric.float', - 'constant.numeric.hex', - 'constant.numeric.binary', - 'constant.numeric.octal', - 'keyword.other.unit', - ], - }, - - // Numeric constants (distinct hex in original theme) - { - foreground: C.numericConstant, - scopes: ['constant.numeric'], - }, - - // Keywords — italic - { - foreground: C.keyword, - fontStyle: 'italic', - scopes: [ - 'keyword', - 'keyword.control', - 'keyword.control.flow', - 'keyword.control.import', - 'keyword.control.conditional', - 'keyword.control.loop', - 'storage.type', - 'storage.modifier', - 'keyword.declaration', - ], - }, - - // Keyword "other" — same color, upright - { - foreground: C.keyword, - scopes: ['keyword.other'], - }, - - // `operator` / `operators` — brand cyan (Monarch tokens) - { - foreground: C.operator, - scopes: ['operator', 'operators'], - }, - - // `keyword.operator*` — darker teal - { - foreground: C.operatorKeyword, - scopes: [ - 'keyword.operator', - 'keyword.operator.assignment', - 'keyword.operator.arithmetic', - 'keyword.operator.logical', - 'keyword.operator.comparison', - 'keyword.operator.type', - 'keyword.operator.type.ts', - ], - }, - - // Functions - { - foreground: C.function, - scopes: [ - 'function', - 'identifier.function', - 'support.function', - 'entity.name.function', - 'meta.function-call', - 'meta.function-call.entity.name.function', - 'variable.function', - ], - }, - - // Types & classes - { - foreground: C.type, - scopes: [ - 'type', - 'type.identifier', - 'entity.name.type', - 'entity.name.class', - 'support.type', - 'support.class', - 'support.type.primitive.ts', - 'support.type.primitive.js', - 'entity.name.type.ts', - 'entity.name.type.js', - 'meta.type.annotation', - 'meta.type.annotation.ts', - 'entity.other.inherited-class', - 'storage.type.class', - 'storage.type.function', - 'storage.type.interface', - 'support.type.primitive', - ], - }, - - // Variables / identifiers / property names — near-black - { - foreground: C.variable, - scopes: [ - 'variable', - 'variable.name', - 'variable.parameter', - 'variable.parameter.ts', - 'variable.parameter.js', - 'variable.other', - 'variable.other.readwrite', - 'variable.other.constant', - 'variable.language', - 'meta.definition.variable', - 'identifier', - 'identifier.ts', - 'identifier.js', - // Object keys — JSON, TS, JS - 'support.type.property-name', - 'support.type.property-name.json', - 'string.key.json', - 'string.name.tag.json', - 'meta.object-literal.key', - 'variable.other.property', - 'variable.other.object.property', - 'variable.other.constant.property', - ], - }, - - // Constants & built-ins — deep blue - { - foreground: C.function, - scopes: ['constant', 'constant.character', 'support.constant'], - }, - - // Booleans / null / language constants - { - foreground: C.bool, - scopes: [ - 'constant.language', - 'constant.language.boolean', - 'constant.language.null', - 'constant.language.undefined', - 'constant.language.boolean.true', - 'constant.language.boolean.false', - 'keyword.constant.boolean', - ], - }, - - // Delimiters & punctuation - { - foreground: C.variable, - scopes: [ - 'delimiter', - 'delimiter.bracket', - 'delimiter.parenthesis', - 'delimiter.square', - 'delimiter.html', - 'punctuation', - 'punctuation.separator', - 'punctuation.definition', - 'punctuation.terminator', - 'punctuation.section', - 'meta.brace', - 'meta.brace.round', - 'meta.brace.square', - 'meta.brace.curly', - 'meta.tag.html', - 'punctuation.definition.tag.html', - ], - }, - - // Tags (HTML/XML/JSX) - { - foreground: C.tag, - scopes: [ - 'tag', - 'meta.tag', - 'entity.name.tag', - 'entity.name.tag.tsx', - 'entity.name.tag.jsx', - 'punctuation.definition.tag', - 'punctuation.definition.tag.begin', - 'punctuation.definition.tag.end', - ], - }, - - // Attribute names — string color (matches prior theme intent) - { - foreground: C.string, - scopes: ['attribute.name', 'entity.other.attribute-name', 'meta.attribute'], - }, - - // Namespaces - { - foreground: C.namespace, - scopes: ['namespace', 'entity.name.namespace', 'storage.type.namespace'], - }, - - // URLs / links - { - foreground: C.url, - scopes: ['markup.underline.link', 'string.other.link'], - }, - - // Doctype - { - foreground: C.comment, - fontStyle: 'italic', - scopes: ['meta.tag.sgml.doctype'], - }, - - // Markdown - { - fontStyle: 'bold', - scopes: ['markup.bold'], - }, - { - fontStyle: 'italic', - scopes: ['markup.italic'], - }, - { - foreground: C.keyword, - fontStyle: 'bold', - scopes: ['markup.heading'], - }, - { - foreground: C.string, - scopes: ['markup.raw'], - }, - { - foreground: C.comment, - fontStyle: 'italic', - scopes: ['markup.quote'], - }, - { - foreground: C.variable, - scopes: ['markup.list'], - }, - - // Language-Specific: HTML — tag names/attributes rendered as identifiers - { - foreground: C.variable, - scopes: [ - 'entity.name.tag.html', - 'tag.html', - 'entity.other.attribute-name.html', - 'attribute.name.html', - ], - }, - - // Language-Specific: CSS - { - foreground: C.cssSelector, - scopes: ['entity.name.tag.css'], - }, - { - foreground: C.cssClass, - scopes: ['entity.other.attribute-name.class.css'], - }, - { - foreground: C.cssId, - scopes: ['entity.other.attribute-name.id.css'], - }, - { - foreground: C.cssProperty, - scopes: ['support.type.property-name.css'], - }, - { - foreground: C.cssValue, - scopes: ['support.constant.property-value.css', 'keyword.other.unit.css'], - }, - - // Errors / invalid - { - foreground: C.invalid, - scopes: ['invalid', 'invalid.illegal'], - }, - { - foreground: C.invalidDep, - scopes: ['invalid.deprecated'], - }, -]; - -export const lighthouseTheme: editor.IStandaloneThemeData = { - base: 'vs', - inherit: true, - rules: tokenGroupsToMonacoRules(TOKEN_GROUPS), - colors: { - // Editor Background - transparent, let box handle background - 'editor.background': '#00000000', // Transparent - 'editor.foreground': '#24292E', - - // Editor Lines - transparent to inherit box background - 'editor.lineHighlightBackground': '#00000000', // Transparent - 'editorLineNumber.foreground': '#1B1F2380', - 'editorLineNumber.activeForeground': '#6F42C1', - - // Editor Cursor & Selection - 'editorCursor.foreground': '#6F42C1', // Purple cursor for light theme - 'editor.selectionBackground': '#0366D630', // Visible blue selection - 'editor.inactiveSelectionBackground': '#0366D620', - 'editor.selectionHighlightBackground': '#0366D615', - - // Editor Gutter - transparent to inherit box background - 'editorGutter.background': '#00000000', // Transparent - 'editorGutter.modifiedBackground': '#005CC5', - 'editorGutter.addedBackground': '#28A745', - 'editorGutter.deletedBackground': '#D73A49', - - // Editor Widgets - 'editorWidget.background': '#F6F8FA', - 'editorWidget.border': '#E1E4E8', - 'editorSuggestWidget.background': '#F6F8FA', - 'editorSuggestWidget.border': '#E1E4E8', - 'editorSuggestWidget.selectedBackground': '#0366D625', - - // Sticky Scroll - match box background with subtle border - 'editorStickyScroll.background': '#ffffff', - 'editorStickyScroll.border': '#E1E4E8', - 'editorStickyScrollHover.background': '#ffffff', - 'editorStickyScroll.shadow': '#00000000', - 'editorStickyScrollGutter.background': '#ffffff', - - // Hover Widgets - Tooltips and hover information - 'editorHoverWidget.background': '#F6F8FA', - 'editorHoverWidget.border': '#E1E4E8', - 'editorHoverWidget.statusBarBackground': '#E1E4E8', - - // Inline Hints - Type hints, parameter hints, inline suggestions - 'editorInlineHint.background': '#F6F8FA', - 'editorInlineHint.foreground': '#6A737D', - - // Code Lens - Reference counts, test indicators - 'editorCodeLens.foreground': '#6A737D', - - // Ghost Text - Autocomplete suggestions shown inline - 'editorGhostText.foreground': '#1B1F2350', - - // Editor Whitespace & Indentation - 'editorWhitespace.foreground': '#1B1F2340', - 'editorIndentGuide.background': '#E1E4E820', - 'editorIndentGuide.activeBackground': '#E1E4E8', - - // Scrollbar - 'scrollbar.shadow': '#00000000', - 'scrollbarSlider.background': '#1B1F2330', - 'scrollbarSlider.hoverBackground': '#1B1F2350', - 'scrollbarSlider.activeBackground': '#1B1F2370', - - // Bracket Matching - 'editorBracketMatch.background': '#34D05840', - 'editorBracketMatch.border': '#34D058', - - // Find/Replace - 'editor.findMatchBackground': '#FFDF5D40', - 'editor.findMatchHighlightBackground': '#FFDF5D30', - 'editor.findRangeHighlightBackground': '#0366D620', - - // Minimap - 'minimap.background': '#FAFBFC', - 'minimap.selectionHighlight': '#0366D625', - 'minimap.findMatchHighlight': '#FFDF5D40', - - // Overview Ruler - 'editorOverviewRuler.border': '#E1E4E820', - 'editorOverviewRuler.modifiedForeground': '#005CC5', - 'editorOverviewRuler.addedForeground': '#28A745', - 'editorOverviewRuler.deletedForeground': '#D73A49', - - // Peek View - 'peekView.border': '#0366D6', - 'peekViewEditor.background': '#F6F8FA', - 'peekViewResult.background': '#FAFBFC', - 'peekViewTitle.background': '#F6F8FA', - - // Diff Editor - 'diffEditor.insertedTextBackground': '#28A74520', - 'diffEditor.removedTextBackground': '#D73A4920', - }, -}; - -/** - * Register the lighthouse theme with Monaco Editor - * Call this function before creating any editor instances - */ -export function registerLighthouseTheme( - monaco: typeof import('monaco-editor'), -) { - monaco.editor.defineTheme(ELB_THEME_LIGHT, lighthouseTheme); -} diff --git a/apps/explorer/src/themes/names.ts b/apps/explorer/src/themes/names.ts deleted file mode 100644 index 239d7a98b..000000000 --- a/apps/explorer/src/themes/names.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Theme name constants - * - * Single source of truth for Monaco `defineTheme` keys and Shiki theme names. - * Import these instead of hard-coding the string literals — renaming a theme - * then only changes one place and both engines stay in sync. - */ - -export const ELB_THEME_DARK = 'elbTheme-dark'; -export const ELB_THEME_LIGHT = 'elbTheme-light'; - -export type ElbThemeName = typeof ELB_THEME_DARK | typeof ELB_THEME_LIGHT; diff --git a/apps/explorer/src/themes/palenight.ts b/apps/explorer/src/themes/palenight.ts index d3aaaaa25..77d3a9cf1 100644 --- a/apps/explorer/src/themes/palenight.ts +++ b/apps/explorer/src/themes/palenight.ts @@ -1,56 +1,65 @@ /** - * Palenight Theme for Monaco Editor + * Palenight code theme for Monaco Editor * - * Dark theme for walkerOS Explorer matching Prism's palenight theme. - * Aligned with website documentation syntax highlighting for consistency. - * - * Token Color Mappings (Matching Prism Palenight): - * - comment: #697098 - gray, italic - * - string/inserted: #c3e88d - green - * - number: #f78c6c - orange - * - builtin/char/constant/function: #82aaff - blue - * - punctuation/delimiter: #c792ea - purple - * - variable: #bfc7d5 - light gray - * - class-name: #ffcb6b - yellow/gold - * - attr-name: #c3e88d - green (matching string color) - * - tag/deleted: #ff5572 - red/pink - * - operator: #89ddff - cyan - * - boolean: #ff5874 - red - * - keyword: #c084fc - bright purple, italic - * - doctype: #c084fc - purple, italic - * - namespace: #b2ccd6 - blue-gray - * - url: #dddddd - white + * The dark code theme of walkerOS Explorer, also rendered by Shiki (CodeView) + * and matching the docs' Prism theme. Every colour is a design constant: the + * `syntax-*` tokens for code, the `code-*` tokens for the editor UI. */ import type { editor } from 'monaco-editor'; -import { ELB_THEME_DARK } from './names'; +import { + codeBar, + codeBg, + codeBorder, + codeDeletedBg, + codeFg, + codeInsertedBg, + codeLineNumber, + codeSelection, + primary, + syntaxComment, + syntaxConstant, + syntaxFunction, + syntaxKeyword, + syntaxNamespace, + syntaxNumber, + syntaxOperator, + syntaxPunct, + syntaxString, + syntaxTag, + syntaxType, +} from '../design'; import { tokenGroupsToMonacoRules, type TokenGroup } from './token-groups'; -// Semantic color palette — change a hex here, it flows to every scope that uses it. +/** Monaco rules take a colour without the leading `#`. */ +const rule = (color: string): string => color.replace(/^#/, ''); + +// Semantic palette: each role reads one design constant, and every scope +// that uses the role follows it. const C = { - comment: '697098', - string: 'c3e88d', - regexp: '89ddff', - number: 'f78c6c', - keyword: 'c084fc', - operator: '89ddff', - function: '82aaff', - type: 'ffcb6b', - variable: 'bfc7d5', - bool: 'ff5874', - punctuation: 'c792ea', - tag: 'ff5572', - namespace: 'b2ccd6', - url: 'dddddd', - invalid: 'ff5572', - invalidDep: 'f78c6c', - cssSelector: 'ff5572', - cssId: '82aaff', - cssProperty: 'c084fc', + comment: rule(syntaxComment), + string: rule(syntaxString), + regexp: rule(syntaxOperator), + number: rule(syntaxNumber), + keyword: rule(syntaxKeyword), + operator: rule(syntaxOperator), + function: rule(syntaxFunction), + type: rule(syntaxType), + variable: rule(codeFg), + bool: rule(syntaxConstant), + punctuation: rule(syntaxPunct), + tag: rule(syntaxTag), + namespace: rule(syntaxNamespace), + url: rule(codeFg), + invalid: rule(syntaxTag), + invalidDep: rule(syntaxNumber), + cssSelector: rule(syntaxTag), + cssId: rule(syntaxFunction), + cssProperty: rule(syntaxKeyword), } as const; // Token groups pool Monarch token names (Monaco) + TextMate scopes (Shiki). -// Both engines consume the SAME list — change a group, both pick up. +// Both engines consume the SAME list: change a group, both pick it up. // // ORDER MATTERS: more-specific scopes should come AFTER broader ones so they // win when both match. Monaco walks `rules[]` top-to-bottom and (like @@ -92,12 +101,12 @@ const TOKEN_GROUPS: TokenGroup[] = [ 'punctuation.definition.string.begin', 'punctuation.definition.string.end', 'meta.string', - // HTML/JSX attribute values stay in string color (matches prior rule) + // HTML/JSX attribute values stay in the string colour 'attribute.value.html', ], }, - // Regex (cyan — distinct from plain strings) + // Regex (operator colour, distinct from plain strings) { foreground: C.regexp, scopes: ['string.regexp'], @@ -123,7 +132,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Keywords — italic + // Keywords, italic { foreground: C.keyword, fontStyle: 'italic', @@ -140,13 +149,13 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Keyword "other" — same color, upright + // Keyword "other", same colour, upright { foreground: C.keyword, scopes: ['keyword.other'], }, - // Operators — cyan + // Operators { foreground: C.operator, scopes: [ @@ -200,7 +209,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Variables / identifiers / property names — unified light gray + // Variables, identifiers and property names: the plain code colour { foreground: C.variable, scopes: [ @@ -217,7 +226,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ 'identifier', 'identifier.ts', 'identifier.js', - // Object keys — JSON, TS, JS + // Object keys: JSON, TS, JS 'support.type.property-name', 'support.type.property-name.json', 'string.key.json', @@ -229,7 +238,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Constants & built-ins (blue — matches function color per Prism palenight) + // Constants and built-ins (the function colour) { foreground: C.function, scopes: ['constant', 'constant.character', 'support.constant'], @@ -272,7 +281,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Tags (HTML/XML/JSX) — red/pink accent + // Tags (HTML/XML/JSX) { foreground: C.tag, scopes: [ @@ -287,7 +296,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ ], }, - // Attribute names — string color (matches prior theme intent) + // Attribute names, in the string colour { foreground: C.string, scopes: ['attribute.name', 'entity.other.attribute-name', 'meta.attribute'], @@ -340,7 +349,7 @@ const TOKEN_GROUPS: TokenGroup[] = [ scopes: ['markup.list'], }, - // Language-Specific: HTML — tag names/attributes rendered as identifiers + // Language-specific: HTML tag names and attributes render as identifiers { foreground: C.variable, scopes: [ @@ -389,104 +398,90 @@ export const palenightTheme: editor.IStandaloneThemeData = { inherit: true, rules: tokenGroupsToMonacoRules(TOKEN_GROUPS), colors: { - // Editor Background - transparent, let box handle background - // Falls back to CSS var(--bg-input) in _code.scss - 'editor.background': '#00000000', // Transparent - 'editor.foreground': '#bfc7d5', - - // Editor Lines - transparent to inherit box background - 'editor.lineHighlightBackground': '#00000000', // Transparent - 'editorLineNumber.foreground': '#676E95', - 'editorLineNumber.activeForeground': '#c084fc', - - // Editor Cursor & Selection - 'editorCursor.foreground': '#c084fc', // Purple cursor for dark theme - 'editor.selectionBackground': '#717CB450', // Visible selection - 'editor.inactiveSelectionBackground': '#717CB430', - 'editor.selectionHighlightBackground': '#717CB420', - - // Editor Gutter - transparent to inherit box background - 'editorGutter.background': '#00000000', // Transparent - 'editorGutter.modifiedBackground': '#82aaff', - 'editorGutter.addedBackground': '#c3e88d', - 'editorGutter.deletedBackground': '#ff5572', - - // Editor Widgets - 'editorWidget.background': '#1e1e2e', - 'editorWidget.border': '#676E95', - 'editorSuggestWidget.background': '#1e1e2e', - 'editorSuggestWidget.border': '#676E95', - 'editorSuggestWidget.selectedBackground': '#717CB440', - - // Sticky Scroll - match box background with subtle border - 'editorStickyScroll.background': '#292d3e', - 'editorStickyScroll.border': '#676E9540', - 'editorStickyScrollHover.background': '#292d3e', - 'editorStickyScroll.shadow': '#00000000', - 'editorStickyScrollGutter.background': '#292d3e', - - // Hover Widgets - Tooltips and hover information - 'editorHoverWidget.background': '#1e1e2e', - 'editorHoverWidget.border': '#676E95', - 'editorHoverWidget.statusBarBackground': '#676E95', - - // Inline Hints - Type hints, parameter hints, inline suggestions - 'editorInlineHint.background': '#292d3e', - 'editorInlineHint.foreground': '#676E95', - - // Code Lens - Reference counts, test indicators - 'editorCodeLens.foreground': '#697098', - - // Ghost Text - Autocomplete suggestions shown inline - 'editorGhostText.foreground': '#676E9540', - - // Editor Whitespace & Indentation - 'editorWhitespace.foreground': '#676E9540', - 'editorIndentGuide.background': '#676E9520', - 'editorIndentGuide.activeBackground': '#676E95', + // Editor + 'editor.background': codeBg, + 'editor.foreground': codeFg, + 'editor.lineHighlightBackground': codeBg, + 'editorLineNumber.foreground': codeLineNumber, + 'editorLineNumber.activeForeground': codeFg, + + // Cursor and selection + 'editorCursor.foreground': primary.dark, + 'editor.selectionBackground': codeSelection, + 'editor.inactiveSelectionBackground': codeSelection, + 'editor.selectionHighlightBackground': codeSelection, + + // Gutter: change markers take the syntax colours of inserted, deleted + // and changed code + 'editorGutter.background': codeBg, + 'editorGutter.modifiedBackground': syntaxFunction, + 'editorGutter.addedBackground': syntaxString, + 'editorGutter.deletedBackground': syntaxTag, + + // Widgets: the code bar with a code border, no shadow + 'editorWidget.background': codeBar, + 'editorWidget.border': codeBorder, + 'editorSuggestWidget.background': codeBar, + 'editorSuggestWidget.border': codeBorder, + 'editorSuggestWidget.selectedBackground': codeSelection, + + // Sticky scroll: the panel colour, its shadow drawn in the same colour + 'editorStickyScroll.background': codeBg, + 'editorStickyScroll.border': codeBorder, + 'editorStickyScrollHover.background': codeBar, + 'editorStickyScroll.shadow': codeBg, + 'editorStickyScrollGutter.background': codeBg, + + // Hover widgets + 'editorHoverWidget.background': codeBar, + 'editorHoverWidget.border': codeBorder, + 'editorHoverWidget.statusBarBackground': codeBar, + + // Inline hints, code lens, ghost text + 'editorInlineHint.background': codeBar, + 'editorInlineHint.foreground': codeLineNumber, + 'editorCodeLens.foreground': codeLineNumber, + 'editorGhostText.foreground': codeLineNumber, + + // Whitespace and indentation + 'editorWhitespace.foreground': codeBorder, + 'editorIndentGuide.background': codeBorder, + 'editorIndentGuide.activeBackground': codeLineNumber, // Scrollbar - 'scrollbar.shadow': '#00000000', - 'scrollbarSlider.background': '#676E9530', - 'scrollbarSlider.hoverBackground': '#676E9550', - 'scrollbarSlider.activeBackground': '#676E9570', + 'scrollbar.shadow': codeBg, + 'scrollbarSlider.background': codeSelection, + 'scrollbarSlider.hoverBackground': codeLineNumber, + 'scrollbarSlider.activeBackground': codeLineNumber, - // Bracket Matching - 'editorBracketMatch.background': '#676E9540', - 'editorBracketMatch.border': '#676E95', + // Bracket matching + 'editorBracketMatch.background': codeSelection, + 'editorBracketMatch.border': codeLineNumber, - // Find/Replace - 'editor.findMatchBackground': '#717CB440', - 'editor.findMatchHighlightBackground': '#676E9530', - 'editor.findRangeHighlightBackground': '#676E9520', + // Find and replace + 'editor.findMatchBackground': codeSelection, + 'editor.findMatchHighlightBackground': codeSelection, + 'editor.findRangeHighlightBackground': codeSelection, // Minimap - 'minimap.background': '#292d3e', - 'minimap.selectionHighlight': '#717CB440', - 'minimap.findMatchHighlight': '#717CB440', - - // Overview Ruler - 'editorOverviewRuler.border': '#676E9520', - 'editorOverviewRuler.modifiedForeground': '#82aaff', - 'editorOverviewRuler.addedForeground': '#c3e88d', - 'editorOverviewRuler.deletedForeground': '#ff5572', - - // Peek View - 'peekView.border': '#676E95', - 'peekViewEditor.background': '#1e1e2e', - 'peekViewResult.background': '#1e1e2e', - 'peekViewTitle.background': '#1e1e2e', - - // Diff Editor - 'diffEditor.insertedTextBackground': '#c3e88d20', - 'diffEditor.removedTextBackground': '#ff557220', + 'minimap.background': codeBg, + 'minimap.selectionHighlight': codeSelection, + 'minimap.findMatchHighlight': codeSelection, + + // Overview ruler + 'editorOverviewRuler.border': codeBorder, + 'editorOverviewRuler.modifiedForeground': syntaxFunction, + 'editorOverviewRuler.addedForeground': syntaxString, + 'editorOverviewRuler.deletedForeground': syntaxTag, + + // Peek view + 'peekView.border': codeBorder, + 'peekViewEditor.background': codeBar, + 'peekViewResult.background': codeBar, + 'peekViewTitle.background': codeBar, + + // Diff editor + 'diffEditor.insertedTextBackground': codeInsertedBg, + 'diffEditor.removedTextBackground': codeDeletedBg, }, }; - -/** - * Register the palenight theme with Monaco Editor - * Call this function before creating any editor instances - */ -export function registerPalenightTheme(monaco: typeof import('monaco-editor')) { - monaco.editor.defineTheme(ELB_THEME_DARK, palenightTheme); -} diff --git a/apps/explorer/src/themes/shiki-adapter.ts b/apps/explorer/src/themes/shiki-adapter.ts index 6352862cf..efef590a2 100644 --- a/apps/explorer/src/themes/shiki-adapter.ts +++ b/apps/explorer/src/themes/shiki-adapter.ts @@ -3,16 +3,17 @@ * * Converts a Monaco IStandaloneThemeData into Shiki's TextMate-format * ThemeRegistrationRaw so CodeStatic (Shiki) and Code (Monaco) use the - * same walkerOS token colors (`elbTheme-dark` / `elbTheme-light`). + * same walkerOS token colors (`elbTheme-dark`). * * Monaco token names are already TextMate-compatible scopes (e.g. * `string.quoted`, `entity.name.function`). The main differences: * - Shiki colors need a leading `#` prefix * - Shiki uses `tokenColors[]` instead of `rules[]` - * - Shiki needs a `name` and `type` ('light' | 'dark') + * - Shiki needs a `name` and `type` */ import type { editor } from 'monaco-editor'; import type { ThemeRegistrationRaw } from 'shiki'; +import { codeBg, codeFg } from '../design'; function normalizeColor(hex: string | undefined): string | undefined { if (!hex) return undefined; @@ -33,15 +34,10 @@ function normalizeColors( export interface MonacoToShikiOptions { name: string; - type: 'light' | 'dark'; - /** Fallback foreground if the Monaco theme didn't set editor.foreground. */ - defaultForeground?: string; - /** Fallback background if the Monaco theme uses a transparent one. */ - defaultBackground?: string; } /** - * Convert a Monaco theme to a Shiki ThemeRegistrationRaw. + * Convert the dark Monaco code theme to a Shiki ThemeRegistrationRaw. */ export function monacoThemeToShiki( monaco: editor.IStandaloneThemeData, @@ -60,21 +56,10 @@ export function monacoThemeToShiki( const colors = normalizeColors(monaco.colors); - // Shiki requires an opaque background and a foreground. Monaco themes use - // transparent backgrounds (`#00000000`) so CSS can theme the box — replace - // those with the caller-provided fallback. - const isTransparent = (c: string | undefined) => - !c || c === '#00000000' || /^#[0-9a-f]{6}00$/i.test(c); - - const bg = isTransparent(colors['editor.background']) - ? (options.defaultBackground ?? - (options.type === 'dark' ? '#292d3e' : '#ffffff')) - : colors['editor.background']; - - const fg = - colors['editor.foreground'] ?? - options.defaultForeground ?? - (options.type === 'dark' ? '#bfc7d5' : '#24292E'); + // Shiki requires a background and a foreground; the code panel colours + // apply when the Monaco theme leaves either out. + const bg = colors['editor.background'] ?? codeBg; + const fg = colors['editor.foreground'] ?? codeFg; // Shiki's ThemeRegistrationRaw requires a `settings` array (TextMate format). // First entry sets global defaults; remaining entries are the token rules. @@ -85,7 +70,7 @@ export function monacoThemeToShiki( return { name: options.name, - type: options.type, + type: 'dark', bg, fg, colors: { diff --git a/apps/explorer/src/themes/types.ts b/apps/explorer/src/themes/types.ts deleted file mode 100644 index c192b5072..000000000 --- a/apps/explorer/src/themes/types.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * TypeScript type definitions for walkerOS Explorer themes - * - * These types provide type-safe access to theme properties and enable - * programmatic theme customization. - */ - -export interface ExplorerTheme { - mode: 'light' | 'dark'; - - colors: { - // Text colors - text: string; - textLabel: string; - textButton: string; - textButtonHover: string; - textButtonActive: string; - textMuted: string; - textToggle: string; - textInput: string; - textPlaceholder: string; - - // Background colors - bgBox: string; - bgHeader: string; - bgFooter: string; - bgButtonHover: string; - bgButtonActive: string; - bgButtonGroup: string; - bgInput: string; - bgInputHover: string; - bgCodeInline: string; - bgDropdown: string; - bgDropdownOptionHover: string; - bgDropdownOptionHighlighted: string; - - // Border colors - borderBox: string; - borderHeader: string; - borderFooter: string; - borderButtonGroup: string; - borderInput: string; - borderInputFocus: string; - - // Button colors - buttonPrimary: string; - buttonPrimaryHover: string; - buttonPrimaryText: string; - buttonDanger: string; - buttonDangerHover: string; - buttonDangerText: string; - - // Status colors - statusEnabled: string; - statusDisabled: string; - statusWarning: string; - - // Highlight colors - highlightPrimary: string; - highlightGlobals: string; - highlightContext: string; - highlightEntity: string; - highlightProperty: string; - highlightAction: string; - highlightBackground: string; - highlightText: string; - highlightHover: string; - highlightSeparator: string; - }; - - typography: { - fontFamily: string; - fontMono: string; - fontSize: { - base: string; - label: string; - toggle: string; - highlightButton: string; - }; - fontWeight: { - normal: number; - semibold: number; - }; - lineHeight: { - base: number; - }; - }; - - spacing: { - header: string; - footer: string; - button: string; - buttonGroup: string; - gridGap: string; - }; - - radius: { - box: string; - button: string; - buttonGroup: string; - highlightButton: string; - }; - - shadows: { - buttonActive: string; - dropdown: string; - }; - - grid: { - minBoxWidth: string; - rowMinHeight: string; - rowMaxHeight: string; - boxMaxHeightMobile: string; - }; - - monaco: { - theme: string; - fontSize: string; - lineHeight: string; - }; -} diff --git a/apps/explorer/src/utils/monaco-decorators.ts b/apps/explorer/src/utils/monaco-decorators.ts index 8f29783ba..da660492c 100644 --- a/apps/explorer/src/utils/monaco-decorators.ts +++ b/apps/explorer/src/utils/monaco-decorators.ts @@ -95,7 +95,7 @@ export function registerDataElbStyles() { style.id = styleId; style.textContent = ` .monaco-editor .elb-data-attribute { - color: var(--color-highlight-primary, #01b5e2) !important; + color: var(--primary) !important; } `; document.head.appendChild(style); diff --git a/apps/explorer/src/utils/monaco-walkeros-decorations.ts b/apps/explorer/src/utils/monaco-walkeros-decorations.ts index 48e051cf4..124186185 100644 --- a/apps/explorer/src/utils/monaco-walkeros-decorations.ts +++ b/apps/explorer/src/utils/monaco-walkeros-decorations.ts @@ -1,4 +1,11 @@ import type { editor } from 'monaco-editor'; +import { + syntaxFunction, + syntaxKeyword, + syntaxOperator, + syntaxString, + syntaxType, +} from '../design'; export type ReferenceType = | 'variable' @@ -131,13 +138,13 @@ export function registerWalkerOSDecorationStyles(): void { const style = document.createElement('style'); style.id = 'walkeros-ref-styles'; style.textContent = ` - .monaco-editor .elb-ref-variable { color: #89ddff !important; font-style: italic; } - .monaco-editor .elb-ref-secret { color: #ffcb6b !important; font-style: italic; } - .monaco-editor .elb-ref-env { color: #ffcb6b !important; font-style: italic; } - .monaco-editor .elb-ref-contract { color: #c3e88d !important; font-style: italic; } - .monaco-editor .elb-ref-store { color: #89ddff !important; font-style: italic; } - .monaco-editor .elb-ref-flow { color: #82aaff !important; font-style: italic; } - .monaco-editor .elb-ref-code { color: #c084fc !important; } + .monaco-editor .elb-ref-variable { color: ${syntaxOperator} !important; font-style: italic; } + .monaco-editor .elb-ref-secret { color: ${syntaxType} !important; font-style: italic; } + .monaco-editor .elb-ref-env { color: ${syntaxType} !important; font-style: italic; } + .monaco-editor .elb-ref-contract { color: ${syntaxString} !important; font-style: italic; } + .monaco-editor .elb-ref-store { color: ${syntaxOperator} !important; font-style: italic; } + .monaco-editor .elb-ref-flow { color: ${syntaxFunction} !important; font-style: italic; } + .monaco-editor .elb-ref-code { color: ${syntaxKeyword} !important; } `; document.head.appendChild(style); } diff --git a/apps/explorer/turbo.json b/apps/explorer/turbo.json index ae243a7ec..87855d3ad 100644 --- a/apps/explorer/turbo.json +++ b/apps/explorer/turbo.json @@ -4,6 +4,19 @@ "test": { "dependsOn": ["build", "^build"], "inputs": ["$TURBO_DEFAULT$", "dist/**"] + }, + "lint": { + "dependsOn": ["build", "^build"], + "inputs": [ + "$TURBO_EXTENDS$", + "src/**/*.jsx", + "src/**/*.cjs", + "src/**/*.css", + "src/**/*.scss", + "src/**/*.mdx", + "src/**/*.html", + "src/**/*.svg" + ] } } } diff --git a/website/docs/getting-started/index.mdx b/website/docs/getting-started/index.mdx index 44ecc3da0..c73dc5a55 100644 --- a/website/docs/getting-started/index.mdx +++ b/website/docs/getting-started/index.mdx @@ -74,8 +74,8 @@ A "flow" connects sources to destinations through a collector. You can have one collector={{ label: 'Collector', link: '/docs/collector' }} destinations={{ dest: { label: 'Destination', link: '/docs/destinations' } }} markers={[ - { position: 'source-collector', id: '1', legend: 'Source Mapping' }, - { position: 'collector-destination', id: '2', legend: 'Destination Mapping' }, + { position: 'source-collector', id: '1', text: 'Source Mapping' }, + { position: 'collector-destination', id: '2', text: 'Destination Mapping' }, ]} /> diff --git a/website/scripts/data-collection.test.mjs b/website/scripts/data-collection.test.mjs new file mode 100644 index 000000000..e2b704f2f --- /dev/null +++ b/website/scripts/data-collection.test.mjs @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// walkeros.io runs two flows: the docs' demo flow (window.walkerjs) and the +// hosted analytics bundle (window.alst). The hosted bundle sets window.elb to +// its own collector's elb, so a route change that ran the demo flow through +// window.elb ran the hosted flow twice: two page views and two session starts +// per navigation. These tests drive the real demo flow on a jsdom page and +// stand in for the hosted bundle the way it sets up the window. +const { window } = new JSDOM('', { + url: 'https://www.walkeros.io/', +}); +globalThis.window = window; +globalThis.document = window.document; + +const WEBSITE = new URL('..', import.meta.url).pathname; +const { onRouteChange } = await import(`${WEBSITE}src/components/walkerjs.tsx`); + +async function until(check) { + for (let attempt = 0; attempt < 100 && !check(); attempt++) + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +test('a route change runs each flow once, each through its own elb', async () => { + await onRouteChange(); + const demo = window.walkerjs; + assert.ok(demo, 'the first page starts the demo flow'); + assert.equal( + document.head.querySelectorAll('script[src*="cdn.walkeros.io"]').length, + 1, + ); + + // What the hosted bundle does once it has loaded. + const hosted = []; + window.elb = (...args) => { + hosted.push(['elb', ...args]); + }; + window.alst = (...args) => { + hosted.push(['alst', ...args]); + }; + + for (const path of ['/docs/', '/playground/']) { + window.history.pushState({}, '', path); + const round = demo.round; + hosted.length = 0; + await onRouteChange(); + await until(() => demo.round > round); + assert.equal(demo.round, round + 1, `${path}: the demo flow runs once`); + assert.deepEqual( + hosted, + [['alst', 'walker run']], + `${path}: the hosted flow runs once, through window.alst`, + ); + } +}); diff --git a/website/src/components/walkerjs.tsx b/website/src/components/walkerjs.tsx index 2150b147f..dfab96acc 100644 --- a/website/src/components/walkerjs.tsx +++ b/website/src/components/walkerjs.tsx @@ -15,56 +15,62 @@ declare global { // Hosted analytics bundle (self-initializing IIFE that exposes window.alst). const ANALYTICS_BUNDLE_SRC = 'https://cdn.walkeros.io/d/x1fsqqucg526/walker.js'; let analyticsScriptInjected = false; +// The demo flow's own elb. window.elb can belong to another flow on the +// page (the hosted bundle may set it), so route changes run the demo flow +// through this one. +let demoElb: Elb.Fn | undefined; -export const DataCollection = () => { - const location = useLocation(); - - useEffect(() => { - const init = async () => { - // Setup demo walkerOS flow - if (!window.walkerjs) { - const { collector, elb } = await startFlow({ - sources: { - browser: { - code: sourceBrowser, - config: { - settings: { - pageview: false, - }, - }, +/** Runs on the first page and on every route change after it. */ +export async function onRouteChange(): Promise { + // Setup demo walkerOS flow + if (!demoElb) { + const { collector, elb } = await startFlow({ + sources: { + browser: { + code: sourceBrowser, + config: { + settings: { + pageview: false, }, }, - destinations: {}, - consent: { functional: true, marketing: true }, - user: { session: 's3ss10n' }, - }); - window.elb = elb; - window.walkerjs = collector; - } else { - // new page load - reinitialize DOM tracking - window.elb('walker run'); - } + }, + }, + destinations: {}, + consent: { functional: true, marketing: true }, + user: { session: 's3ss10n' }, + }); + demoElb = elb; + window.elb = elb; + window.walkerjs = collector; + } else { + // new page load - reinitialize DOM tracking + demoElb('walker run'); + } - // Load hosted analytics bundle (self-initializing IIFE). - if (!analyticsScriptInjected) { - analyticsScriptInjected = true; - const script = document.createElement('script'); - script.async = true; - script.src = ANALYTICS_BUNDLE_SRC; - // If the CDN request fails (blocked, offline, 5xx), clear the flag so a - // later navigation can retry injection instead of leaving analytics - // dead for the rest of the session. - script.onerror = () => { - analyticsScriptInjected = false; - }; - document.head.appendChild(script); - } else if (typeof window.alst === 'function') { - // new page load - re-scan the DOM for the new page - window.alst('walker run'); - } + // Load hosted analytics bundle (self-initializing IIFE). + if (!analyticsScriptInjected) { + analyticsScriptInjected = true; + const script = document.createElement('script'); + script.async = true; + script.src = ANALYTICS_BUNDLE_SRC; + // If the CDN request fails (blocked, offline, 5xx), clear the flag so a + // later navigation can retry injection instead of leaving analytics + // dead for the rest of the session. + script.onerror = () => { + analyticsScriptInjected = false; }; + document.head.appendChild(script); + } else if (typeof window.alst === 'function') { + // new page load - re-scan the DOM for the new page + window.alst('walker run'); + } +} - init(); +export const DataCollection = () => { + const location = useLocation(); + + useEffect(() => { + onRouteChange(); }, [location]); return null; From 71326194176278f3a9f536245e1dc3c21125d5e4 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Thu, 8 Oct 2026 17:19:52 +0200 Subject: [PATCH 04/10] demos --- .changeset/core-mapping-value-collector.md | 6 + .changeset/explorer-browser-frame.md | 9 + .changeset/explorer-playground-events.md | 13 + .changeset/explorer-view-source.md | 9 + .changeset/storybook-addon-design-sizes.md | 6 + .prettierignore | 4 +- apps/demos/README.md | 2 + .../.storybook/{preview.ts => preview.tsx} | 15 +- apps/demos/storybook/README.md | 49 +- apps/demos/storybook/package.json | 3 +- apps/demos/storybook/src/App.css | 36 -- apps/demos/storybook/src/App.stories.tsx | 2 +- apps/demos/storybook/src/App.tsx | 80 +-- apps/demos/storybook/src/assets/react.svg | 1 - apps/demos/storybook/src/components/index.ts | 3 + .../components/media/atoms/Button/Button.css | 30 - .../media/atoms/Button/Button.stories.ts | 1 - .../components/media/atoms/Button/Button.tsx | 25 +- .../media/atoms/Icon/Icon.stories.ts | 4 +- .../media/atoms/Image/Image.stories.ts | 8 +- .../components/media/atoms/Image/Image.tsx | 88 +-- .../media/atoms/Typography/Typography.css | 94 --- .../media/atoms/Typography/Typography.tsx | 37 +- .../media/molecules/BannerText/BannerText.tsx | 6 +- .../molecules/CarouselItem/CarouselItem.tsx | 4 +- .../NavigationMenu/NavigationMenu.tsx | 6 +- .../CarouselSection.stories.ts | 5 - .../CarouselSection/CarouselSection.tsx | 2 +- .../media/organisms/HeaderBar/HeaderBar.tsx | 6 +- .../media/organisms/HeroBanner/HeroBanner.tsx | 4 +- .../PromotionBanner.stories.ts | 4 +- .../PromotionBanner/PromotionBanner.tsx | 4 +- .../MediathekTemplate/MediathekTemplate.tsx | 6 +- .../shop/atoms/Badge/Badge.stories.ts | 20 + .../src/components/shop/atoms/Badge/Badge.tsx | 10 + .../src/components/shop/atoms/Badge/index.ts | 2 + .../shop/atoms/Button/Button.stories.ts | 36 ++ .../components/shop/atoms/Button/Button.tsx | 44 ++ .../src/components/shop/atoms/Button/index.ts | 2 + .../shop/atoms/Heading/Heading.stories.ts | 38 ++ .../components/shop/atoms/Heading/Heading.tsx | 44 ++ .../components/shop/atoms/Heading/index.ts | 2 + .../shop/atoms/Icon/Icon.stories.tsx | 64 ++ .../src/components/shop/atoms/Icon/Icon.tsx | 207 +++++++ .../src/components/shop/atoms/Icon/index.ts | 2 + .../shop/atoms/Input/Input.stories.ts | 24 + .../src/components/shop/atoms/Input/Input.tsx | 14 + .../src/components/shop/atoms/Input/index.ts | 2 + .../shop/atoms/Link/Link.stories.ts | 35 ++ .../src/components/shop/atoms/Link/Link.tsx | 36 ++ .../src/components/shop/atoms/Link/index.ts | 2 + .../shop/atoms/Price/Price.stories.ts | 34 ++ .../src/components/shop/atoms/Price/Price.tsx | 25 + .../src/components/shop/atoms/Price/index.ts | 2 + .../ProductImage/ProductImage.stories.tsx | 41 ++ .../shop/atoms/ProductImage/ProductImage.tsx | 25 + .../shop/atoms/ProductImage/index.ts | 2 + .../shop/atoms/Select/Select.stories.ts | 32 + .../components/shop/atoms/Select/Select.tsx | 28 + .../src/components/shop/atoms/Select/index.ts | 2 + .../atoms/StarRating/StarRating.stories.ts | 26 + .../shop/atoms/StarRating/StarRating.tsx | 27 + .../components/shop/atoms/StarRating/index.ts | 2 + .../shop/atoms/Text/Text.stories.ts | 37 ++ .../src/components/shop/atoms/Text/Text.tsx | 50 ++ .../src/components/shop/atoms/Text/index.ts | 2 + .../storybook/src/components/shop/data.ts | 235 ++++++++ .../storybook/src/components/shop/index.ts | 47 ++ .../Breadcrumb/Breadcrumb.stories.ts | 21 + .../shop/molecules/Breadcrumb/Breadcrumb.tsx | 22 + .../shop/molecules/Breadcrumb/index.ts | 2 + .../CartLineItem/CartLineItem.stories.tsx | 34 ++ .../molecules/CartLineItem/CartLineItem.tsx | 82 +++ .../shop/molecules/CartLineItem/index.ts | 2 + .../ConsentControls.stories.ts | 22 + .../ConsentControls/ConsentControls.tsx | 25 + .../shop/molecules/ConsentControls/index.ts | 2 + .../molecules/FormField/FormField.stories.tsx | 40 ++ .../shop/molecules/FormField/FormField.tsx | 25 + .../shop/molecules/FormField/index.ts | 2 + .../HeaderGlobals/HeaderGlobals.stories.ts | 19 + .../molecules/HeaderGlobals/HeaderGlobals.tsx | 38 ++ .../shop/molecules/HeaderGlobals/index.ts | 2 + .../molecules/NavLinks/NavLinks.stories.ts | 21 + .../shop/molecules/NavLinks/NavLinks.tsx | 17 + .../shop/molecules/NavLinks/index.ts | 2 + .../molecules/OrderItem/OrderItem.stories.tsx | 28 + .../shop/molecules/OrderItem/OrderItem.tsx | 37 ++ .../shop/molecules/OrderItem/index.ts | 2 + .../ProductCard/ProductCard.stories.tsx | 40 ++ .../molecules/ProductCard/ProductCard.tsx | 79 +++ .../shop/molecules/ProductCard/index.ts | 2 + .../ProductFacts/ProductFacts.stories.ts | 23 + .../molecules/ProductFacts/ProductFacts.tsx | 48 ++ .../shop/molecules/ProductFacts/index.ts | 2 + .../PromotionCard/PromotionCard.stories.ts | 19 + .../molecules/PromotionCard/PromotionCard.tsx | 75 +++ .../shop/molecules/PromotionCard/index.ts | 2 + .../SocialLinks/SocialLinks.stories.ts | 16 + .../molecules/SocialLinks/SocialLinks.tsx | 25 + .../shop/molecules/SocialLinks/index.ts | 2 + .../StatusMessage/StatusMessage.stories.ts | 42 ++ .../molecules/StatusMessage/StatusMessage.tsx | 42 ++ .../shop/molecules/StatusMessage/index.ts | 2 + .../SummaryRow/SummaryRow.stories.tsx | 36 ++ .../shop/molecules/SummaryRow/SummaryRow.tsx | 41 ++ .../shop/molecules/SummaryRow/index.ts | 2 + .../organisms/Checkout/Checkout.stories.ts | 16 + .../shop/organisms/Checkout/Checkout.tsx | 165 ++++++ .../shop/organisms/Checkout/index.ts | 2 + .../ConsentBar/ConsentBar.stories.ts | 45 ++ .../shop/organisms/ConsentBar/ConsentBar.tsx | 65 ++ .../shop/organisms/ConsentBar/index.ts | 2 + .../shop/organisms/Footer/Footer.stories.ts | 16 + .../shop/organisms/Footer/Footer.tsx | 42 ++ .../components/shop/organisms/Footer/index.ts | 2 + .../shop/organisms/Header/Header.stories.ts | 24 + .../shop/organisms/Header/Header.tsx | 31 + .../components/shop/organisms/Header/index.ts | 2 + .../OrderComplete/OrderComplete.stories.ts | 16 + .../organisms/OrderComplete/OrderComplete.tsx | 142 +++++ .../shop/organisms/OrderComplete/index.ts | 2 + .../ProductDetail/ProductDetail.stories.ts | 16 + .../organisms/ProductDetail/ProductDetail.tsx | 122 ++++ .../shop/organisms/ProductDetail/index.ts | 2 + .../PromotionHero/PromotionHero.stories.ts | 16 + .../organisms/PromotionHero/PromotionHero.tsx | 33 ++ .../shop/organisms/PromotionHero/index.ts | 2 + .../Recommendations.stories.tsx | 27 + .../Recommendations/Recommendations.tsx | 69 +++ .../shop/organisms/Recommendations/index.ts | 2 + .../pages/TaggingDemo/TaggingDemo.stories.tsx | 137 +++++ .../shop/pages/TaggingDemo/TaggingDemo.tsx | 98 +++ .../shop/pages/TaggingDemo/index.ts | 2 + .../storybook/src/components/shop/tagging.ts | 9 + .../ShopLayout/ShopLayout.stories.tsx | 35 ++ .../shop/templates/ShopLayout/ShopLayout.tsx | 28 + .../shop/templates/ShopLayout/index.ts | 2 + .../src/components/shop/useDemoProducts.ts | 17 + apps/demos/storybook/src/index.css | 40 +- apps/demos/storybook/src/stories/Button.tsx | 39 -- apps/demos/storybook/src/stories/button.css | 30 - apps/demos/storybook/story.md | 234 ++++++++ apps/demos/storybook/tailwind.config.js | 43 -- apps/demos/storybook/turbo.json | 15 + apps/demos/tagging/index.html | 54 +- apps/demos/tagging/index.local.html | 54 +- apps/explorer/AGENT.md | 17 +- apps/explorer/README.md | 10 +- apps/explorer/SKILL.md | 58 +- apps/explorer/STYLE.md | 75 +-- apps/explorer/design/README.md | 19 +- apps/explorer/design/tokens.json | 106 +++- .../components/__tests__/code-roots.test.tsx | 12 + .../atoms/__tests__/toggle-button.test.tsx | 86 +++ apps/explorer/src/components/atoms/box.tsx | 20 +- .../src/components/atoms/code-diff.tsx | 3 +- apps/explorer/src/components/atoms/code.tsx | 5 +- apps/explorer/src/components/atoms/grid.tsx | 49 +- apps/explorer/src/components/atoms/header.tsx | 2 +- .../src/components/atoms/status-icons.tsx | 36 ++ .../atoms/toggle-button.stories.tsx | 39 ++ .../src/components/atoms/toggle-button.tsx | 58 ++ .../components/demos/PromotionPlayground.tsx | 560 +++++++----------- .../__tests__/PromotionPlayground.test.tsx | 353 +++++++++++ .../__tests__/product-card.demo.test.tsx | 65 ++ .../src/components/demos/product-card.demo.ts | 94 +++ .../__tests__/preview-failure.test.tsx | 62 ++ .../molecules/__tests__/preview.test.tsx | 101 ++++ .../molecules/__tests__/view-source.test.tsx | 527 ++++++++++++++++ .../ArchitectureFlow.stories.tsx | 2 +- .../src/components/molecules/code-box.tsx | 56 +- .../components/molecules/flow-map/FlowMap.tsx | 31 +- .../components/molecules/preview.stories.tsx | 175 +----- .../src/components/molecules/preview.tsx | 274 +++++---- .../components/molecules/property-table.tsx | 2 +- .../molecules/view-source.stories.tsx | 50 ++ .../src/components/molecules/view-source.tsx | 458 ++++++++++++++ .../organisms/__tests__/live-code.test.tsx | 111 ++++ .../organisms/browser-box.stories.tsx | 74 +-- .../organisms/live-code.stories.tsx | 24 +- .../src/components/organisms/live-code.tsx | 54 +- .../__snapshots__/generate.test.ts.snap | 28 + .../src/design/__tests__/check.test.ts | 273 ++++++++- .../src/design/__tests__/contrast.test.ts | 6 +- apps/explorer/src/design/__tests__/fixture.ts | 17 +- .../src/design/__tests__/generate.test.ts | 120 +++- .../src/design/__tests__/preview-css.test.ts | 49 ++ .../src/design/__tests__/tokens.test.ts | 64 ++ apps/explorer/src/design/base.css | 23 +- apps/explorer/src/design/check/cli.ts | 14 +- apps/explorer/src/design/check/rules.ts | 256 +++++++- .../src/design/components/atoms/Icon.tsx | 113 +++- .../atoms/PhotoPlaceholder.stories.tsx | 16 + .../components/atoms/PhotoPlaceholder.tsx | 36 ++ .../atoms/__tests__/PhotoPlaceholder.test.tsx | 53 ++ .../atoms/__tests__/pass-through.test.tsx | 2 + .../src/design/components/atoms/index.ts | 2 + .../molecules/BrowserFrame.stories.tsx | 61 ++ .../components/molecules/BrowserFrame.tsx | 90 +++ .../molecules/__tests__/BrowserFrame.test.tsx | 135 +++++ .../molecules/__tests__/pass-through.test.tsx | 8 + .../src/design/components/molecules/index.ts | 2 + .../viz/ArticleTeaserTracking.stories.tsx | 1 + .../components/viz/ArticleTeaserTracking.tsx | 19 +- .../viz/__tests__/teaser-steps.test.tsx | 125 ++++ apps/explorer/src/design/generate.ts | 122 +++- apps/explorer/src/design/index.ts | 93 +++ apps/explorer/src/design/preview-css.ts | 4 + .../src/design/tailwind-namespaces.ts | 2 +- apps/explorer/src/design/tokens.ts | 116 +++- apps/explorer/src/index.ts | 4 + apps/explorer/src/styles/PANE_STANDARDS.md | 157 ++--- .../src/styles/components/atoms/_button.scss | 2 +- .../styles/components/atoms/_code-static.scss | 4 +- .../components/atoms/_preview-footer.scss | 4 +- .../components/atoms/_toggle-button.scss | 33 ++ .../design/_article-teaser-tracking.scss | 25 +- .../src/styles/components/design/_atoms.scss | 1 + .../components/design/_browser-frame.scss | 147 +++++ .../design/_destination-mapping-viz.scss | 6 +- .../components/design/_event-legend.scss | 2 +- .../styles/components/design/_faq-item.scss | 2 +- .../components/design/_hero-tagging-viz.scss | 6 +- .../components/design/_inline-code.scss | 2 +- .../styles/components/design/_molecules.scss | 1 + .../components/design/_photo-placeholder.scss | 28 + .../components/design/_section-heading.scss | 2 +- .../src/styles/components/design/_stat.scss | 2 +- .../styles/components/design/_viz-pill.scss | 2 +- .../styles/components/design/_viz-table.scss | 4 +- .../components/molecules/_code-box.scss | 17 +- .../components/molecules/_code-snippet.scss | 4 +- .../components/molecules/_code-view.scss | 2 +- .../components/molecules/_property-table.scss | 16 +- .../components/molecules/_view-source.scss | 146 +++++ .../src/styles/components/organisms/_box.scss | 43 +- .../explorer/src/styles/foundation/_grid.scss | 58 +- apps/explorer/src/styles/index.scss | 2 + .../src/themes/__tests__/theme.test.ts | 8 +- apps/explorer/src/utils/monaco-types.ts | 4 + .../src/components/AttributeTreeView.tsx | 5 +- .../src/components/HighlightButtons.tsx | 4 +- apps/storybook-addon/src/components/List.tsx | 7 +- apps/walkerjs/README.md | 8 +- package-lock.json | 1 + packages/core/src/__tests__/mapping.test.ts | 13 +- packages/core/src/mapping.ts | 18 +- packages/core/src/mappingValue.ts | 19 +- packages/core/src/types/mapping.ts | 9 + .../web/destinations/snowplow/src/index.ts | 2 +- .../web/destinations/snowplow/src/push.ts | 8 +- skills/walkeros-create-destination/SKILL.md | 8 +- .../walkeros-understanding-mapping/SKILL.md | 2 +- website/docs/comparisons/dataLayer.mdx | 6 +- website/docs/comparisons/gtm.mdx | 2 +- website/docs/core/index.mdx | 48 +- website/docs/core/server.mdx | 5 +- website/docs/destinations/web/gtag/gtm.mdx | 4 +- website/docs/getting-started/first-checks.mdx | 53 -- website/docs/getting-started/index.mdx | 2 +- .../quickstart}/walkerjs.mdx | 2 +- website/docs/mapping/index.mdx | 5 +- website/docs/mapping/rule.mdx | 10 +- website/docs/mapping/value.mdx | 44 +- website/docs/sources/web/browser/index.mdx | 2 +- website/docusaurus.config.ts | 8 +- website/scripts/live-mapping.test.mjs | 72 +++ website/sidebars.ts | 6 +- .../organisms/markdownActions.module.css | 8 +- website/src/css/custom.css | 55 +- website/src/pages/playground/index.tsx | 14 +- 272 files changed, 9282 insertions(+), 1834 deletions(-) create mode 100644 .changeset/core-mapping-value-collector.md create mode 100644 .changeset/explorer-browser-frame.md create mode 100644 .changeset/explorer-playground-events.md create mode 100644 .changeset/explorer-view-source.md create mode 100644 .changeset/storybook-addon-design-sizes.md rename apps/demos/storybook/.storybook/{preview.ts => preview.tsx} (64%) delete mode 100644 apps/demos/storybook/src/assets/react.svg delete mode 100644 apps/demos/storybook/src/components/media/atoms/Button/Button.css delete mode 100644 apps/demos/storybook/src/components/media/atoms/Typography/Typography.css create mode 100644 apps/demos/storybook/src/components/shop/atoms/Badge/Badge.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Badge/Badge.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Badge/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Button/Button.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Button/Button.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Button/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Heading/Heading.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Heading/Heading.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Heading/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Icon/Icon.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Icon/Icon.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Icon/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Input/Input.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Input/Input.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Input/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Link/Link.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Link/Link.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Link/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Price/Price.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Price/Price.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Price/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/ProductImage/ProductImage.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/ProductImage/ProductImage.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/ProductImage/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Select/Select.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Select/Select.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Select/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/StarRating/StarRating.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/StarRating/StarRating.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/StarRating/index.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Text/Text.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/atoms/Text/Text.tsx create mode 100644 apps/demos/storybook/src/components/shop/atoms/Text/index.ts create mode 100644 apps/demos/storybook/src/components/shop/data.ts create mode 100644 apps/demos/storybook/src/components/shop/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/Breadcrumb/Breadcrumb.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/Breadcrumb/Breadcrumb.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/Breadcrumb/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/CartLineItem/CartLineItem.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/CartLineItem/CartLineItem.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/CartLineItem/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/ConsentControls/ConsentControls.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/ConsentControls/ConsentControls.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/ConsentControls/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/FormField/FormField.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/FormField/FormField.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/FormField/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/HeaderGlobals/HeaderGlobals.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/HeaderGlobals/HeaderGlobals.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/HeaderGlobals/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/NavLinks/NavLinks.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/NavLinks/NavLinks.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/NavLinks/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/OrderItem/OrderItem.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/OrderItem/OrderItem.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/OrderItem/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductCard/ProductCard.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductCard/ProductCard.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductCard/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductFacts/ProductFacts.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductFacts/ProductFacts.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/ProductFacts/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/PromotionCard/PromotionCard.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/PromotionCard/PromotionCard.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/PromotionCard/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/SocialLinks/SocialLinks.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/SocialLinks/SocialLinks.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/SocialLinks/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/StatusMessage/StatusMessage.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/StatusMessage/StatusMessage.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/StatusMessage/index.ts create mode 100644 apps/demos/storybook/src/components/shop/molecules/SummaryRow/SummaryRow.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/SummaryRow/SummaryRow.tsx create mode 100644 apps/demos/storybook/src/components/shop/molecules/SummaryRow/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Checkout/Checkout.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Checkout/Checkout.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/Checkout/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/ConsentBar/ConsentBar.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/ConsentBar/ConsentBar.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/ConsentBar/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Footer/Footer.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Footer/Footer.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/Footer/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Header/Header.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Header/Header.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/Header/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/OrderComplete/OrderComplete.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/OrderComplete/OrderComplete.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/OrderComplete/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/ProductDetail/ProductDetail.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/ProductDetail/ProductDetail.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/ProductDetail/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/PromotionHero/PromotionHero.stories.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/PromotionHero/PromotionHero.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/PromotionHero/index.ts create mode 100644 apps/demos/storybook/src/components/shop/organisms/Recommendations/Recommendations.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/Recommendations/Recommendations.tsx create mode 100644 apps/demos/storybook/src/components/shop/organisms/Recommendations/index.ts create mode 100644 apps/demos/storybook/src/components/shop/pages/TaggingDemo/TaggingDemo.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/pages/TaggingDemo/TaggingDemo.tsx create mode 100644 apps/demos/storybook/src/components/shop/pages/TaggingDemo/index.ts create mode 100644 apps/demos/storybook/src/components/shop/tagging.ts create mode 100644 apps/demos/storybook/src/components/shop/templates/ShopLayout/ShopLayout.stories.tsx create mode 100644 apps/demos/storybook/src/components/shop/templates/ShopLayout/ShopLayout.tsx create mode 100644 apps/demos/storybook/src/components/shop/templates/ShopLayout/index.ts create mode 100644 apps/demos/storybook/src/components/shop/useDemoProducts.ts delete mode 100644 apps/demos/storybook/src/stories/Button.tsx delete mode 100644 apps/demos/storybook/src/stories/button.css delete mode 100644 apps/demos/storybook/tailwind.config.js create mode 100644 apps/demos/storybook/turbo.json create mode 100644 apps/explorer/src/components/atoms/__tests__/toggle-button.test.tsx create mode 100644 apps/explorer/src/components/atoms/status-icons.tsx create mode 100644 apps/explorer/src/components/atoms/toggle-button.stories.tsx create mode 100644 apps/explorer/src/components/atoms/toggle-button.tsx create mode 100644 apps/explorer/src/components/demos/__tests__/PromotionPlayground.test.tsx create mode 100644 apps/explorer/src/components/demos/__tests__/product-card.demo.test.tsx create mode 100644 apps/explorer/src/components/demos/product-card.demo.ts create mode 100644 apps/explorer/src/components/molecules/__tests__/preview-failure.test.tsx create mode 100644 apps/explorer/src/components/molecules/__tests__/preview.test.tsx create mode 100644 apps/explorer/src/components/molecules/__tests__/view-source.test.tsx create mode 100644 apps/explorer/src/components/molecules/view-source.stories.tsx create mode 100644 apps/explorer/src/components/molecules/view-source.tsx create mode 100644 apps/explorer/src/components/organisms/__tests__/live-code.test.tsx create mode 100644 apps/explorer/src/design/__tests__/preview-css.test.ts create mode 100644 apps/explorer/src/design/components/atoms/PhotoPlaceholder.stories.tsx create mode 100644 apps/explorer/src/design/components/atoms/PhotoPlaceholder.tsx create mode 100644 apps/explorer/src/design/components/atoms/__tests__/PhotoPlaceholder.test.tsx create mode 100644 apps/explorer/src/design/components/molecules/BrowserFrame.stories.tsx create mode 100644 apps/explorer/src/design/components/molecules/BrowserFrame.tsx create mode 100644 apps/explorer/src/design/components/molecules/__tests__/BrowserFrame.test.tsx create mode 100644 apps/explorer/src/design/components/viz/__tests__/teaser-steps.test.tsx create mode 100644 apps/explorer/src/design/preview-css.ts create mode 100644 apps/explorer/src/styles/components/atoms/_toggle-button.scss create mode 100644 apps/explorer/src/styles/components/design/_browser-frame.scss create mode 100644 apps/explorer/src/styles/components/design/_photo-placeholder.scss create mode 100644 apps/explorer/src/styles/components/molecules/_view-source.scss delete mode 100644 website/docs/getting-started/first-checks.mdx rename website/docs/{apps => getting-started/quickstart}/walkerjs.mdx (99%) create mode 100644 website/scripts/live-mapping.test.mjs diff --git a/.changeset/core-mapping-value-collector.md b/.changeset/core-mapping-value-collector.md new file mode 100644 index 000000000..8d61e84c6 --- /dev/null +++ b/.changeset/core-mapping-value-collector.md @@ -0,0 +1,6 @@ +--- +'@walkeros/core': patch +--- + +The `context` of `getMappingValue` now requires `collector` in its type +(`Mapping.ValueContext`), matching the runtime, which already threw without one. diff --git a/.changeset/explorer-browser-frame.md b/.changeset/explorer-browser-frame.md new file mode 100644 index 000000000..33f37fd07 --- /dev/null +++ b/.changeset/explorer-browser-frame.md @@ -0,0 +1,9 @@ +--- +'@walkeros/explorer': patch +--- + +`@walkeros/explorer/design/components` gains `BrowserFrame`, a browser window on +the site theme around any page: an address field with the URL, an optional +reload button, a slot for the caller's actions and an optional bookmarks row +that marks the current page. It is sized by its caller and styled by +`@walkeros/explorer/styles.css`. `Icon` gains `lock`, `reload` and `bookmark`. diff --git a/.changeset/explorer-playground-events.md b/.changeset/explorer-playground-events.md new file mode 100644 index 000000000..8aa7fd653 --- /dev/null +++ b/.changeset/explorer-playground-events.md @@ -0,0 +1,13 @@ +--- +'@walkeros/explorer': patch +--- + +The `PromotionPlayground` and `LiveCode` mapping examples show events and +results again, and every failure says so in the box it concerns. `Preview` takes +a `collector` instead of `elb` and captures the page's events through it; its +document carries the design tokens, base rules and design atom styles, so the +demo product card is built from `Card`, `PhotoPlaceholder`, `Text` and `Button`. +The Playground drops its separate Code box and the `labelCode` prop: the +Preview's HTML, CSS and JS tabs edit the page. New props: `placeholder` and +`error` on `CodeBox`, `error` on `Box`, `boxWidth` on `Grid` for one row of +fixed-width boxes. diff --git a/.changeset/explorer-view-source.md b/.changeset/explorer-view-source.md new file mode 100644 index 000000000..6ac0e2f51 --- /dev/null +++ b/.changeset/explorer-view-source.md @@ -0,0 +1,9 @@ +--- +'@walkeros/explorer': patch +--- + +New `ViewSource` component: wrap a tagged element, hover it and toggle to its +live HTML with the `data-elb` attributes, edited in place. Back in Visual the +element renders your edit and walkerOS picks up the new tags; a Reset icon +restores the original. New `ToggleButton`: one button that names the option a +click switches to, at a fixed width. diff --git a/.changeset/storybook-addon-design-sizes.md b/.changeset/storybook-addon-design-sizes.md new file mode 100644 index 000000000..bcfaf3f88 --- /dev/null +++ b/.changeset/storybook-addon-design-sizes.md @@ -0,0 +1,6 @@ +--- +'@walkeros/storybook-addon': patch +--- + +The walkerOS panel takes its text sizes from the Storybook theme, so labels, +highlight buttons and attribute badges match the rest of the Storybook UI. diff --git a/.prettierignore b/.prettierignore index 32cbb2bb8..b6fc38b28 100644 --- a/.prettierignore +++ b/.prettierignore @@ -35,6 +35,8 @@ logs/ website/docs/**/*.mdx # walkerOS design system: design/ mirrors the Claude Design artifact file for -# file, and src/design/index.ts is generated from design/tokens.json. +# file; src/design/index.ts and src/design/preview-css.ts are generated by +# src/design/generate.ts. apps/explorer/design/ apps/explorer/src/design/index.ts +apps/explorer/src/design/preview-css.ts diff --git a/apps/demos/README.md b/apps/demos/README.md index 29a881491..9fdf1f1eb 100644 --- a/apps/demos/README.md +++ b/apps/demos/README.md @@ -1,4 +1,6 @@ # walkerOS demos - [React](./react/) +- [Storybook](./storybook/): the walkerOS Storybook addon with two tagged + component libraries, Media and Shop - [Tagging Website](./tagging/) diff --git a/apps/demos/storybook/.storybook/preview.ts b/apps/demos/storybook/.storybook/preview.tsx similarity index 64% rename from apps/demos/storybook/.storybook/preview.ts rename to apps/demos/storybook/.storybook/preview.tsx index 3c54ad166..90c78921c 100644 --- a/apps/demos/storybook/.storybook/preview.ts +++ b/apps/demos/storybook/.storybook/preview.tsx @@ -1,7 +1,16 @@ -import type { Preview } from '@storybook/react-vite'; +import type { Decorator, Preview } from '@storybook/react-vite'; import '../src/index.css'; +// The theme sits on , as on every walkerOS surface: dark by default, +// light from the toolbar. +const withTheme: Decorator = (Story, context) => { + const theme = context.globals.theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', theme); + return ; +}; + const preview: Preview = { + decorators: [withTheme], parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { @@ -30,11 +39,11 @@ const preview: Preview = { globalTypes: { theme: { description: 'Global theme for components', - defaultValue: 'light', + defaultValue: 'dark', toolbar: { title: 'Theme', icon: 'circlehollow', - items: ['light', 'dark'], + items: ['dark', 'light'], dynamicTitle: true, }, }, diff --git a/apps/demos/storybook/README.md b/apps/demos/storybook/README.md index 02f19abc8..aabf4eccc 100644 --- a/apps/demos/storybook/README.md +++ b/apps/demos/storybook/README.md @@ -15,12 +15,16 @@ visualize and debug event tracking in React components. ## Quick Start ```bash -# Clone and navigate to the demo +# Clone and install the monorepo git clone https://github.com/elbwalker/walkerOS.git -cd walkerOS/apps/demos/storybook - -# Install dependencies and start +cd walkerOS npm install + +# Build the demo's dependencies, explorer's design CSS among them +npx turbo run build --filter=@walkeros/storybook-demo... + +# Start Storybook +cd apps/demos/storybook npm run storybook ``` @@ -103,12 +107,37 @@ export const Primary = { ## Demo Components -This demo includes examples of: - -- **Simple Components**: Basic buttons with tracking -- **Complex Components**: Hero banners with nested data -- **Template Components**: Full page layouts with contextual tracking -- **Different Patterns**: Various approaches to component tracking +Two example component libraries, each built with atomic design (atoms, +molecules, organisms, templates, pages) and tagged through the typed tagger in +`src/utils/tagger.ts`: + +- **Media** (`src/components/media`, stories under `Media/*`): a streaming site + with a header, hero banner, content carousels and a promotion banner. +- **Shop** (`src/components/shop`, stories under `Shop/*`): the + [static tagging demo](../tagging/) rebuilt as components. Header globals, + promotion, recommendations (with `data-elbobserve` and "Add product"), product + detail, checkout, order complete, footer and a consent bar carry the same tags + as the static page. `Shop/Pages/Tagging demo` shows every section on one page; + its consent bar sends the static demo's `walker user` and `walker consent` + commands to the addon's collector. + +[story.md](./story.md) lists every component of both libraries. + +## Design System + +Both libraries are styled only from the walkerOS design system, which lives in +`@walkeros/explorer` (a dependency of this package). `src/index.css` imports +`@walkeros/explorer/design/tailwind.css` and +`@walkeros/explorer/design/base.css`, and the Storybook toolbar switches between +the dark (default) and the light theme. `npm run lint` runs +`walkeros-design-check` over `src` and `.storybook`. + +The design CSS is read from explorer's built `dist`. In a fresh clone, build it +before `npm run storybook`, `npm run build-storybook` or `npm run build`: run +`npx turbo run build --filter=@walkeros/storybook-demo...` from the repository +root (or build `apps/explorer`). A job that builds this Storybook does the same. +A standalone `npm install` of this package, outside the monorepo, needs an +`@walkeros/explorer` release that ships the design exports. ## Key Features diff --git a/apps/demos/storybook/package.json b/apps/demos/storybook/package.json index 91ffd42f7..1a6484d4a 100644 --- a/apps/demos/storybook/package.json +++ b/apps/demos/storybook/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "eslint . && walkeros-design-check src .storybook", "typecheck": "tsc -b --noEmit", "preview": "vite preview", "storybook": "storybook dev -p 6006", @@ -14,6 +14,7 @@ }, "dependencies": { "@walkeros/core": "4.7.2", + "@walkeros/explorer": "4.7.2", "@walkeros/web-source-browser": "4.7.2", "react": "^19.2.3", "react-dom": "^19.2.3" diff --git a/apps/demos/storybook/src/App.css b/apps/demos/storybook/src/App.css index b9d355df2..902778b76 100644 --- a/apps/demos/storybook/src/App.css +++ b/apps/demos/storybook/src/App.css @@ -4,39 +4,3 @@ padding: 2rem; text-align: center; } - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/apps/demos/storybook/src/App.stories.tsx b/apps/demos/storybook/src/App.stories.tsx index 3c661deb9..ceecdc63b 100644 --- a/apps/demos/storybook/src/App.stories.tsx +++ b/apps/demos/storybook/src/App.stories.tsx @@ -3,7 +3,7 @@ import App from './App'; import './index.css'; const meta: Meta = { - title: 'Templates/Demos', + title: 'Media/Pages/App', component: App, parameters: { layout: 'fullscreen', diff --git a/apps/demos/storybook/src/App.tsx b/apps/demos/storybook/src/App.tsx index c409fed0f..e9644c17a 100644 --- a/apps/demos/storybook/src/App.tsx +++ b/apps/demos/storybook/src/App.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { MediathekTemplate } from './components/media/templates/MediathekTemplate'; -import { Button } from './stories/Button'; +import { Button } from './components/media/atoms/Button'; import { Typography } from './components/media/atoms/Typography/Typography'; import './App.css'; @@ -12,43 +12,22 @@ function App() { if (currentTemplate === 'landing') { return ( -
-
- +
+
+ Component Demo Explore complete application domains built with Atomic Design principles -
+
-
- +
+ 🎯 For the Full Experience - + Run{' '} - + npm run storybook {' '} to explore all components in detail with interactive documentation @@ -96,21 +56,7 @@ function App() { return (
-
+