@humanforest/slidev-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. package/useNotchCuts.js +200 -0
@@ -0,0 +1,201 @@
1
+ /*
2
+ The chart tooltip's markup and placement, for the deck.
3
+
4
+ ── WHY THIS IS A DECK-LOCAL COPY ──────────────────────────────────────────────────────────────
5
+ `packages/charts/src/forestTooltip.ts` is the SOURCE OF TRUTH for this markup, and it is a pure
6
+ string builder published under its own subpath. Four places in this package already say not to
7
+ depend on that package — README.md, Bars.vue, boroughGeometry.ts and chartData.ts — because it
8
+ peer-depends on `@nuxt/ui: ^4`, bun auto-installs peers, and the deck runs UnoCSS.
9
+
10
+ ⚠ HALF OF THAT REASONING IS STALE, measured 2026-09-02 rather than assumed: `@nuxt/ui` and
11
+ `tailwindcss` are in this package's tree ALREADY, pulled by `@humanforest/frames`, which
12
+ peer-depends on the same thing. Adding `@humanforest/charts` moved the count 339 -> 355 entries,
13
+ not the 204 -> 326 chartData.ts predicts. The rule still holds — one import of a chart component
14
+ drags a Nuxt UI pipeline into a deck that cannot compile it — but it holds on the PIPELINE, not
15
+ on an install size another dependency already pays for.
16
+
17
+ So this follows the rule the rest of the deck's chart set already follows — share the VALUES,
18
+ re-implement the PRESENTATION. What has to match is the SHAPE, and it does: a title bar over a
19
+ swatch/label/value grid, every size and colour read from a `--vis-tooltip-*` custom property with
20
+ the same names the product uses, so the two are themed by one vocabulary even though the code is
21
+ two copies.
22
+ */
23
+ import { onBeforeUnmount, shallowRef } from 'vue';
24
+ import { Tooltip } from '@unovis/ts';
25
+
26
+ /*
27
+ ★ THE HAND-DRAWN CHARTS DRIVE UNOVIS' OWN `Tooltip`, rather than growing a second panel. Neither
28
+ StatusTrack nor CalendarGrid has a library mark to hang a trigger on — one is a row of `<span>`s,
29
+ the other a grid of `<rect>`s — but the PANEL is a standalone class, and reusing it is what keeps
30
+ one implementation of the thing that is actually hard here: `place()`'s arithmetic, the flip near
31
+ the top edge, the viewport clamp, and the `zoom` correction base.css applies to all of it. It also
32
+ means these two are themed by the same block as the eleven library charts rather than by a copy.
33
+
34
+ Created on first use, so nothing touches `document` until a pointer moves — these components
35
+ render server-side like any other.
36
+ */
37
+ export function useDeckTip(): { show: (html: string, e: MouseEvent) => void; hide: () => void } {
38
+ const held = shallowRef<Tooltip | null>(null);
39
+ onBeforeUnmount(() => {
40
+ held.value?.destroy();
41
+ held.value = null;
42
+ });
43
+ return {
44
+ show(html, e) {
45
+ if (!held.value) held.value = new Tooltip(deckTooltip());
46
+ // `show` is render-then-place, and that order is load-bearing: `place()` measures the panel's
47
+ // own box, which is only laid out once the content is in it.
48
+ held.value.show(html, { x: e.clientX, y: e.clientY });
49
+ },
50
+ hide() {
51
+ held.value?.hide();
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Every interpolated string passes through here: the output is innerHTML on the tooltip element,
57
+ * and titles, series names and formatted values are all arbitrary text. */
58
+ const esc = (value: unknown): string =>
59
+ String(value)
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;')
64
+ .replace(/'/g, '&#39;');
65
+
66
+ const TITLE_STYLE = (hasRows: boolean) =>
67
+ 'color:var(--vis-tooltip-title-color,#000);'
68
+ + 'text-transform:var(--vis-tooltip-title-text-transform,none);'
69
+ + `border-bottom:${hasRows ? 'var(--vis-tooltip-title-border-bottom,1px solid #e5e7eb)' : 'none'};`
70
+ + 'padding:var(--vis-tooltip-title-padding,0.75rem 0.75rem 0.5rem 0.75rem);'
71
+ + `margin:${hasRows ? 'var(--vis-tooltip-title-margin,0 0 0.25rem 0)' : '0'};`
72
+ + 'font-size:var(--vis-tooltip-title-font-size,0.875rem);'
73
+ + 'line-height:var(--vis-tooltip-title-line-height,100%);'
74
+ + 'font-weight:var(--vis-tooltip-title-font-weight,600)';
75
+
76
+ /* The swatch column is only there when something fills it. A frame whose rows ALL lack a swatch —
77
+ a timeline's From/To, a treemap's share — would otherwise indent every label past a column of
78
+ nothing, which reads as a missing dot rather than as a chart with no series to name. Mixed
79
+ frames keep the column, hidden on the rows without one, so the labels stay in a line. */
80
+ const GRID_STYLE = (hasSwatch: boolean) =>
81
+ `display:grid;grid-template-columns:${hasSwatch ? 'auto 1fr auto' : '1fr auto'};align-items:center;`
82
+ + 'gap:var(--vis-tooltip-content-gap,0.25rem 0.5rem);'
83
+ + 'padding:var(--vis-tooltip-content-padding,0 0.75rem 0.5rem 0.75rem)';
84
+
85
+ /* The dot is the one thing the product's version hard-codes at 8px. Here it is a variable too, so
86
+ the deck can size it against 36px type from one place rather than overriding an inline style. */
87
+ const DOT_STYLE =
88
+ 'width:var(--vis-tooltip-dot-size,8px);height:var(--vis-tooltip-dot-size,8px);'
89
+ + 'aspect-ratio:1;flex-shrink:0;border-radius:999px;';
90
+
91
+ const LABEL_STYLE =
92
+ 'font-weight:var(--vis-tooltip-label-font-weight,400);'
93
+ + 'font-size:var(--vis-tooltip-label-font-size,0.875rem);'
94
+ + 'color:var(--vis-tooltip-label-color,inherit);'
95
+ + 'margin:var(--vis-tooltip-label-margin,0 1rem 0 0);white-space:nowrap';
96
+
97
+ const VALUE_STYLE =
98
+ 'font-size:var(--vis-tooltip-value-font-size,0.875rem);'
99
+ + 'font-weight:var(--vis-tooltip-value-font-weight,600);'
100
+ + 'color:var(--vis-tooltip-value-color,inherit);'
101
+ + 'text-align:right;font-variant-numeric:tabular-nums';
102
+
103
+ /*
104
+ ★★ A TRIGGER KEY IS A BARE CLASS NAME, ON EVERY MARK. Unovis binds one delegated `mousemove` per
105
+ component and walks `composedPath()` for `el.classList.contains(key)`, dotting the key itself for
106
+ its own `selectAll` — so the RAW export is what fires, and a normaliser that prefixed a dot
107
+ everywhere looked like the tidy fix and silently disabled three charts that had been working.
108
+
109
+ The class can sit on any element the mark draws — Sankey's node is a `rect`, not a `g` — so a
110
+ hand-written selector guessing at the tag finds nothing and looks exactly like a dead trigger.
111
+
112
+ The failure is invisible: no error, no warning, just a panel that never appears. It looks
113
+ identical to a hover that MISSED, which is how four families here were once recorded as broken
114
+ when all four worked. Verify by dispatching at the mark's own centre, never by hovering at
115
+ coordinates read off a screenshot.
116
+ */
117
+
118
+ export interface TipRow {
119
+ /** Swatch colour. Omit for a row with no dot: the column holds its width while ANY row in the
120
+ * frame carries one, so a mixed set stays aligned, and collapses when none of them do. */
121
+ swatch?: string;
122
+ label: string;
123
+ value: string | number;
124
+ }
125
+
126
+ /** The house tooltip chrome — a title bar over a swatch/label/value grid. */
127
+ export function tipFrame(title: string | number, rows: TipRow[] = []): string {
128
+ const hasSwatch = rows.some((r) => r.swatch);
129
+ const body = rows
130
+ .map((r) =>
131
+ (hasSwatch
132
+ ? `<span style="${DOT_STYLE}${r.swatch ? `background-color:${esc(r.swatch)}` : 'visibility:hidden'}"></span>`
133
+ : '')
134
+ + `<span style="${LABEL_STYLE}">${esc(r.label)}</span>`
135
+ + `<span style="${VALUE_STYLE}">${esc(r.value)}</span>`)
136
+ .join('');
137
+ return '<div style="display:flex;flex-direction:column">'
138
+ + `<div style="${TITLE_STYLE(rows.length > 0)}">${esc(title)}</div>`
139
+ + (rows.length > 0 ? `<div style="${GRID_STYLE(hasSwatch)}">${body}</div>` : '')
140
+ + '</div>';
141
+ }
142
+
143
+ /** A crosshair template: one row per series present on the hovered datum, in legend order. The
144
+ * title falls out of the row itself — the deck's rows lead with `label`. */
145
+ export function seriesTip(
146
+ series: readonly { key: string; name: string; color: string }[],
147
+ format: (v: number) => string,
148
+ ): (d: Record<string, unknown>) => string {
149
+ return (d) => {
150
+ const rows = series
151
+ .filter((s) => s.key in d)
152
+ .map((s) => ({ swatch: s.color, label: s.name, value: format(Number(d[s.key])) }));
153
+ return tipFrame(String(Object.values(d)[0]), rows);
154
+ };
155
+ }
156
+
157
+ /*
158
+ ★★ THE PANEL IS MOUNTED ON THE BODY, AND ON A SCALED SLIDE THAT IS NOT OPTIONAL. Slidev draws the
159
+ 1920x1080 canvas by SCALING it, and Unovis positions its panel by writing screen pixels into
160
+ `left`/`bottom`. Inside the slide those pixels are scaled again — measured on the Grouped bars
161
+ slide, a pointer at x 700 put the panel at x 264. The body is the only ancestor the transform does
162
+ not reach. `packages/charts` mounts there too, for its own reason (a short chart clamps the panel
163
+ back over the pointer), and arrives at the same place.
164
+
165
+ ★ `followCursor` IS TRUE HERE AND FALSE IN THE PRODUCT, and on this deck it reaches EVERY chart.
166
+ A per-mark trigger — a donut arc, a treemap tile, a scatter point — is shown by the tooltip
167
+ itself, so the flag applies directly. A crosshair shows the panel itself, and rather than ignoring
168
+ the flag it FORCES it on (`tooltip.config.followCursor = true`, with an inline note that its
169
+ tooltip must not be hoverable) and passes `[clientX, clientY]` — the raw pointer, not the snapped
170
+ column. So the panel bobs with the cursor there too. Measured, not assumed: its y tracked the
171
+ pointer frame by frame across the Line slide.
172
+
173
+ The product does not behave that way, and the difference is a dependency rather than a decision.
174
+ `useTooltipSnap` holds the panel at the top of the plot so it "steps as the crosshair snaps
175
+ between rows and holds still in between" — a panel that bobs free-floats while the reading it
176
+ shows only changes datum to datum. That composable lives in @humanforest/charts, which this
177
+ package cannot import; re-implementing it means owning the placement Unovis overwrites every
178
+ frame, and the LINE still snaps here, so the reading is never wrong — only restless.
179
+ */
180
+ export function deckTooltip(): Record<string, unknown> {
181
+ return {
182
+ container: typeof document === 'undefined' ? undefined : document.body,
183
+ followCursor: true,
184
+ horizontalShift: 12,
185
+ verticalShift: 12,
186
+ /*
187
+ ★★ PINNED SO THE CROSSHAIR CANNOT FLIP IT. Left at the default `auto`, Crosshair._showTooltip
188
+ re-decides the side on every frame — `xRelative > containerWidth / 2 ? Left : Right` — so
189
+ crossing the plot's midpoint throws the panel from one side of the pointer to the other.
190
+ MEASURED on the Line slide: a single frame moved it 262px, and near the midpoint it flips back
191
+ and forth with the pointer. Unovis guards that override with
192
+ `if (!config.horizontalPlacement || === Auto)`, so naming a side is what switches it off.
193
+
194
+ `center` rather than a side, because it is what the ELEVEN other charts already draw: `auto`
195
+ resolves to Center inside `place()`, and only the crosshair reinterprets it. One behaviour
196
+ across the deck, and the clamp keeps a panel near either edge on screen — honestly now that
197
+ the panel measures its drawn size.
198
+ */
199
+ horizontalPlacement: 'center',
200
+ };
201
+ }
@@ -0,0 +1,239 @@
1
+ // The Forest code theme — a Shiki theme built out of the deck's own colour roles.
2
+ //
3
+ // WHY THE SHIPPED ONE HAD TO GO, measured rather than asserted. Slidev's default is the vitesse
4
+ // pair, switched by CSS on `html.dark`. This deck runs LIGHT globally and marks individual slides
5
+ // `class: deck-dark`, which is not `html.dark` — so a code block on a dark slide got the LIGHT
6
+ // theme's tokens on a dark ground. Probed on a live render before any of this was written:
7
+ //
8
+ // ground #0c332d (--deck-surface, dark register)
9
+ // func #1e754f 2.43:1 <- the most frequent token in the sample
10
+ // 0 #2f798a 2.77:1
11
+ // * #ab5959 2.82:1
12
+ // Fleet #2e8f82 3.51:1
13
+ //
14
+ // Every scope but punctuation was under the 4.5:1 body floor, and a screenshot did not show it —
15
+ // at slide-thumbnail size the eye fills in what the DOM says is not there. Two fixes, both here:
16
+ // a theme that IS the deck's palette, and a register switch that keys off `.deck-dark` as well as
17
+ // `html.dark` (base.css, "Code" section).
18
+ //
19
+ // ── The palette ────────────────────────────────────────────────────────────────────────────────
20
+ //
21
+ // Ayu's five role colours on the deck's own grounds, lifted where they miss the body floor:
22
+ //
23
+ // role ayu scope light dark
24
+ // ink (default fg) #5c6166 5.33 published #bfbdb6 9.56 published
25
+ // muted comment #696a6d 4.61 #79828c 4.61
26
+ // accent keyword #ab5000 4.62 #ff8f40 7.92 published
27
+ // text string #557400 4.61 #aad94c 10.90 published
28
+ // value constant.numeric #8158a7 4.61 #d2a6ff 9.10 published
29
+ //
30
+ // A lift walks OKLCH lightness with hue and chroma held until the engine's contrast() clears 4.6 —
31
+ // the floor plus headroom, so a rounding difference cannot land a shipped ink under 4.5.
32
+ // codeTheme.static.test.ts re-derives every value from AYU below rather than comparing hexes.
33
+ //
34
+ // `muted` is also clamped under 0.02 chroma, the floor at which the hue-budget gate counts a colour
35
+ // as a hue. Two near-neutral greys a few thousandths apart are a scope distinction an audience
36
+ // cannot resolve at ten metres, so they resolve to one grey and the register stays inside the
37
+ // four-hue ceiling.
38
+ //
39
+ // The mapping is ayu's own, read off its scope rules. Its gold and its blue are left unspent here,
40
+ // which is what lets styles/base.css give the lit line a marker that cannot read as syntax.
41
+ //
42
+ // ⚖ Ayu is MIT-licensed (Copyright (c) 2016 Ike Ku); the entries used are named above.
43
+ import type { ThemeRegistrationRaw } from 'shiki';
44
+ import { palette, ramp, type Register } from './deckPalette';
45
+
46
+ /**
47
+ * Ayu's published entries, by its own scope names. The SOURCE, not what ships: `CODE_INKS` below is
48
+ * what ships, and the two differ only where a colour had to be lifted. Kept separate and exported so
49
+ * the gate can re-derive the lift rather than compare two copies of a hex.
50
+ *
51
+ * `marker` is ayu's blue, used for the lit-line bar. It is darkened on light before it ships — see
52
+ * --deck-code-marker in styles/tokens.css.
53
+ */
54
+ export const AYU = {
55
+ light: { flavour: 'light', marker: '#55b4d4',
56
+ ink: '#5c6166', muted: '#adaeb1', accent: '#fa8532', text: '#86b300', value: '#a37acc' },
57
+ dark: { flavour: 'dark', marker: '#39bae6',
58
+ ink: '#bfbdb6', muted: '#5a6673', accent: '#ff8f40', text: '#aad94c', value: '#d2a6ff' },
59
+ } as const;
60
+
61
+ /**
62
+ * The ground a code block is painted on, per register — and it is the DECK's, not ayu's.
63
+ *
64
+ * The palette is borrowed; the paper is not. Light is --deck-surface, derived from the page ground,
65
+ * so the block sits on the deck's own surface ladder.
66
+ *
67
+ * Dark is `--neutral-900`, the one deliberate step off that ladder. --deck-surface on dark is the
68
+ * page lifted 5% toward the ink (#0d332d), which reads 1.13:1 against the slide it sits on — a well
69
+ * that cannot be seen. neutral-900 is 1.16:1 and gives ayu's roles a worst case of 7.92:1. It is
70
+ * warm, so it sits with the deck's cream, and it is the same #191617 the light register inks with.
71
+ */
72
+ export const codeGround = (register: Register) =>
73
+ register === 'light' ? palette(register).surface : ramp('neutral', 900);
74
+
75
+ /*
76
+ ★★ `surface0` AND `blue` ARE THE BLOCK'S CHROME, NOT ITS SYNTAX, and they are here so the gate can
77
+ pin the stylesheet to published values rather than to two more loose hexes.
78
+
79
+ `base` moved the block's GROUND to Catppuccin; its edge and its lit-line marker stayed on the deck's
80
+ own roles, which is the same half-decision one level out. Measured on the dark register: the border
81
+ painted --deck-border, i.e. forest-950 mixed 14% toward the ink — #24443e at hue 181, a teal-green
82
+ line around a #1e1e2e violet-black box, 100 degrees of hue apart. Jose: "is this border green? why?"
83
+
84
+ `surface0` is Catppuccin's own one-rung-off-base, so the edge is the palette's answer rather than a
85
+ second derivation to keep in sync. `blue` is the marker: every other Catppuccin accent this deck
86
+ uses is already spent by the scope table — mauve on keywords, green on strings, peach on numbers —
87
+ so a marker in any of them would read as syntax.
88
+ */
89
+
90
+ /**
91
+ * What ships. Four of the five light entries are LIFTED (the derivation is in the header), and
92
+ * `muted` is additionally pulled under the hue ceiling's chroma floor; on dark, FOUR of five are
93
+ * ayu's published hex unchanged and only `muted` moves.
94
+ *
95
+ * ★ LITERAL HEXES RATHER THAN A LIFT COMPUTED AT IMPORT TIME, and that is a decision rather than a
96
+ * shortcut. This module runs in NODE at build time — Slidev highlights magic-move steps with it —
97
+ * so a derivation here would re-run on every build to produce a constant. The gate runs it instead,
98
+ * where a drift is a failure rather than a silent recompute.
99
+ */
100
+ const CODE_INKS = {
101
+ light: { ink: '#5c6166', muted: '#696a6d', accent: '#ab5000', text: '#557400', value: '#8158a7' },
102
+ dark: { ink: '#bfbdb6', muted: '#79828c', accent: '#ff8f40', text: '#aad94c', value: '#d2a6ff' },
103
+ } as const;
104
+
105
+ /** The five inks a Forest code block is allowed, per register. */
106
+ export function codeInks(register: Register) {
107
+ return {
108
+ /** Everything NAMED — variables, functions, types, properties. Catppuccin `text`. */
109
+ ink: CODE_INKS[register].ink,
110
+ /** Comments. Catppuccin `subtext0`. */
111
+ muted: CODE_INKS[register].muted,
112
+ /** Keywords, operators, tags — the control flow you point at. Catppuccin `mauve`. */
113
+ accent: CODE_INKS[register].accent,
114
+ /** Literal TEXT — strings, characters, regex. Catppuccin `green`. */
115
+ text: CODE_INKS[register].text,
116
+ /** Literal VALUES — numbers, booleans, language constants. Catppuccin `peach`. */
117
+ value: CODE_INKS[register].value,
118
+ /** The ground a code block is actually painted on — base.css sets `pre` to --deck-surface. */
119
+ ground: codeGround(register),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The scope table.
125
+ *
126
+ * Written once and instantiated per register, so the two themes cannot disagree about WHICH scopes
127
+ * are keywords — only about what colour a keyword is. A scope list that differed between light and
128
+ * dark would be a bug nobody could see without diffing two blobs of JSON.
129
+ *
130
+ * `fontStyle` is deliberately sparse. Italic on comments is the one convention worth keeping at
131
+ * projector scale (it separates prose from code without a hue); bold on names carries the
132
+ * structural distinction that would otherwise cost a fifth hue. Nothing else is styled.
133
+ */
134
+ const SCOPES: { key: 'ink' | 'muted' | 'accent' | 'text' | 'value'; fontStyle?: string; scopes: string[] }[] = [
135
+ {
136
+ key: 'muted',
137
+ fontStyle: 'italic',
138
+ scopes: ['comment', 'punctuation.definition.comment', 'string.comment'],
139
+ },
140
+ {
141
+ key: 'accent',
142
+ scopes: [
143
+ 'keyword',
144
+ 'keyword.control',
145
+ 'keyword.operator',
146
+ 'keyword.other',
147
+ 'storage',
148
+ 'storage.type',
149
+ 'storage.modifier',
150
+ 'entity.name.tag',
151
+ 'punctuation.definition.tag',
152
+ 'variable.language',
153
+ 'markup.heading',
154
+ ],
155
+ },
156
+ {
157
+ key: 'text',
158
+ scopes: [
159
+ 'string',
160
+ 'string.quoted',
161
+ 'string.template',
162
+ 'constant.character',
163
+ 'constant.character.escape',
164
+ 'string.regexp',
165
+ 'meta.attribute-selector',
166
+ ],
167
+ },
168
+ {
169
+ key: 'value',
170
+ scopes: [
171
+ 'constant.numeric',
172
+ 'constant.language',
173
+ 'constant.other',
174
+ 'support.constant',
175
+ 'variable.other.constant',
176
+ ],
177
+ },
178
+ {
179
+ key: 'ink',
180
+ fontStyle: 'bold',
181
+ scopes: [
182
+ 'entity.name.function',
183
+ 'support.function',
184
+ 'entity.name.type',
185
+ 'entity.name.class',
186
+ 'support.type',
187
+ 'support.class',
188
+ 'entity.other.attribute-name',
189
+ ],
190
+ },
191
+ {
192
+ key: 'ink',
193
+ scopes: [
194
+ 'variable',
195
+ 'variable.other',
196
+ 'variable.parameter',
197
+ 'meta.object-literal.key',
198
+ 'punctuation',
199
+ 'meta.brace',
200
+ 'source',
201
+ ],
202
+ },
203
+ ];
204
+
205
+ export const themeName = (register: Register) => `forest-${register}`;
206
+
207
+ /**
208
+ * One Shiki theme for one register.
209
+ *
210
+ * `type` matters to Shiki's own dual-theme plumbing (it decides which `--shiki-*` variable a token
211
+ * lands on), so it tracks the register rather than being hard-coded — a theme declared `light` and
212
+ * handed to the `dark` slot emits its colours into the wrong custom property and the switch in
213
+ * base.css silently does nothing.
214
+ */
215
+ export function forestCodeTheme(register: Register): ThemeRegistrationRaw {
216
+ const inks = codeInks(register);
217
+ return {
218
+ name: themeName(register),
219
+ type: register === 'light' ? 'light' : 'dark',
220
+ // The block's own fill is base.css's job (--deck-surface, so it re-points with the register and
221
+ // sits on the same surface ladder as a Card). Declared here anyway because Shiki writes it into
222
+ // `--shiki-*-bg`, which @shikijs/magic-move and the twoslash popup both read.
223
+ bg: inks.ground,
224
+ fg: inks.ink,
225
+ settings: SCOPES.map(({ key, fontStyle, scopes }) => ({
226
+ scope: scopes,
227
+ settings: { foreground: inks[key], ...(fontStyle ? { fontStyle } : {}) },
228
+ })),
229
+ };
230
+ }
231
+
232
+ /** Both registers, in the shape Slidev's `setup/shiki.ts` wants back. */
233
+ export const forestCodeThemes = () => ({
234
+ light: forestCodeTheme('light'),
235
+ dark: forestCodeTheme('dark'),
236
+ });
237
+
238
+ /** Exported for the gate — the scope table is the thing under test, not a copy of it. */
239
+ export const CODE_SCOPES = SCOPES;
@@ -0,0 +1,157 @@
1
+ // The deck's colour roles, RESOLVED TO HEX, for the two consumers that cannot read a CSS variable.
2
+ //
3
+ // Everything else on a slide styles itself with `var(--deck-accent)` and re-points for free when the
4
+ // register changes. Two things in this theme cannot:
5
+ //
6
+ // · SHIKI runs in NODE. A code theme is a JSON object of TextMate scopes to `#rrggbb`, built
7
+ // before a browser exists, so `var(--deck-fg)` in it is a string that highlights nothing.
8
+ // · MERMAID renders into a SHADOW ROOT and derives ~100 secondary colours from the ones it is
9
+ // given, with real colour maths (khroma). A `var(…)` handed to that arithmetic is NaN, and the
10
+ // shadow boundary means a stylesheet cannot reach in and correct the result afterwards.
11
+ //
12
+ // So both need literal hex, per register — which is exactly the thing a design system must not let
13
+ // anyone type by hand. This module derives it instead, from the same two generated artefacts the CSS
14
+ // is generated from, so a ramp step retuned upstream moves the code theme and the diagrams with it.
15
+ //
16
+ // ★ THE DERIVED SURFACES ARE COMPUTED, NOT PINNED. `--deck-surface` is
17
+ // `color-mix(in oklab, var(--deck-bg), var(--deck-fg) 5%)` — there is no hex for it anywhere in the
18
+ // token output, because the whole point of deriving it is that it re-tunes when the ground moves.
19
+ // `mixOklab` below is that same operation in TypeScript, and codeTheme.static.test.ts checks it
20
+ // against the mix percentages deck.json publishes rather than against a number typed here.
21
+ // Verified against a live Chromium render of the deck before this file was written: computed
22
+ // #f1ede6 / #0c332d, measured #f1ede6 / #0c332d.
23
+ import ramps from '@humanforest/tokens/scales/ramps.json';
24
+ import deckTokens from '@humanforest/tokens/deck/deck.json';
25
+ import { hexToLin, linToOklab, oklabToLin, linToSrgb } from '@humanforest/tokens/colourEngine';
26
+
27
+ export type Register = 'light' | 'dark';
28
+
29
+ type Ramps = Record<string, { levels: Record<string, { hex: string }> }>;
30
+ const RAMPS = ramps as unknown as Ramps;
31
+
32
+ /** One ramp step, by family and level — the only place this theme reaches into the scales. */
33
+ export function ramp(family: string, level: number | string): string {
34
+ const fam = RAMPS[family];
35
+ if (!fam) throw new Error(`deckPalette: no ramp "${family}" — [${Object.keys(RAMPS).join(', ')}]`);
36
+ const step = fam.levels[String(level)];
37
+ if (!step) throw new Error(`deckPalette: ramp ${family} has no level ${level} — [${Object.keys(fam.levels).join(', ')}]`);
38
+ return step.hex.toLowerCase();
39
+ }
40
+
41
+ /**
42
+ * `color-mix(in oklab, a, b P%)`, in TypeScript.
43
+ *
44
+ * Oklab specifically, not sRGB: deck.css mixes in oklab and the two disagree visibly in the middle
45
+ * of a ramp. Mixing the wrong way here would give a code ground a shade off the one actually painted
46
+ * and every contrast figure this module's gate reports would be measuring a colour nothing renders.
47
+ */
48
+ export function mixOklab(a: string, b: string, percentB: number): string {
49
+ const A = linToOklab(hexToLin(a));
50
+ const B = linToOklab(hexToLin(b));
51
+ const t = percentB / 100;
52
+ const lin = oklabToLin(A.map((v, i) => v * (1 - t) + B[i] * t));
53
+ const byte = (v: number) => Math.round(Math.max(0, Math.min(1, linToSrgb(v))) * 255).toString(16).padStart(2, '0');
54
+ return `#${byte(lin[0])}${byte(lin[1])}${byte(lin[2])}`;
55
+ }
56
+
57
+ type DeckJson = {
58
+ roles: { light: Record<string, string>; dark: Record<string, string> };
59
+ derived: Record<string, { mixPercent: number; note: string }>;
60
+ backgrounds: Record<string, Record<Register, { fill: string; ink: string }>>;
61
+ };
62
+ const DECK = deckTokens as unknown as DeckJson;
63
+
64
+ /** `var(--forest-700)` → the hex it names. deck.json stores references, not colours. */
65
+ function resolveRef(raw: string, what: string): string {
66
+ if (raw.startsWith('#')) return raw.toLowerCase();
67
+ const m = /^var\(--([a-z]+)-(\d+)\)$/.exec(raw.trim());
68
+ if (!m) throw new Error(`deckPalette: ${what} is "${raw}", which is neither a hex nor a single ramp reference — deck.json's shape changed`);
69
+ return ramp(m[1], Number(m[2]));
70
+ }
71
+
72
+ function deref(role: string, register: Register): string {
73
+ const raw = DECK.roles[register][role];
74
+ if (!raw) throw new Error(`deckPalette: no --deck-${role} in the ${register} register — [${Object.keys(DECK.roles[register]).join(', ')}]`);
75
+ return resolveRef(raw, `--deck-${role}`);
76
+ }
77
+
78
+ /**
79
+ * A named background and the ink measured against it, resolved for a register.
80
+ *
81
+ * Fill and ink come back TOGETHER because deck.json pairs them, and the pairing is the contract: a
82
+ * caller picks a background by NAME and so cannot put a light ink on a dark ground by picking two values
83
+ * that were never measured against each other. `BACKGROUND_NAMES` is read off the same table rather than
84
+ * listed here, so a fifth background reaches every consumer without an edit.
85
+ */
86
+ export const BACKGROUND_NAMES: string[] = Object.keys(DECK.backgrounds);
87
+
88
+ export function background(name: string, register: Register): { fill: string; ink: string } {
89
+ const entry = DECK.backgrounds[name];
90
+ if (!entry) throw new Error(`deckPalette: deck.json declares no background "${name}" — [${BACKGROUND_NAMES.join(', ')}]`);
91
+ const pair = entry[register];
92
+ return {
93
+ fill: resolveRef(pair.fill, `--deck-bg-${name} (${register})`),
94
+ ink: resolveRef(pair.ink, `--deck-on-bg-${name} (${register})`),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Every role these two consumers need, per register.
100
+ *
101
+ * The derived levels come out of deck.json's own `derived` table, so the mix percentages live in one
102
+ * place and this file cannot drift from the stylesheet by a percent nobody notices.
103
+ */
104
+ /**
105
+ * A status role's `mark` step, resolved for a register.
106
+ *
107
+ * Reads the family off deck.json's own status table rather than naming it here, so a palette
108
+ * retune that moves danger to a different ramp reaches this without an edit.
109
+ */
110
+ function statusMark(state: string, register: Register): string {
111
+ const status = (DECK as { status?: { levels: Record<string, string>; markDark?: string; roles: Record<string, Record<string, string>> } }).status;
112
+ const ref = status?.roles?.[state]?.mark;
113
+ if (!ref) throw new Error(`deckPalette: deck.json declares no status role "${state}" with a mark`);
114
+ const family = /^var\(--([a-z]+)-\d+\)$/.exec(ref)?.[1];
115
+ if (!family) throw new Error(`deckPalette: status mark "${ref}" is not a single ramp reference — deck.json's shape changed`);
116
+ const step = register === 'light' ? status.levels.mark : status.markDark;
117
+ if (!step) throw new Error('deckPalette: deck.json records no markDark step — regenerate with bun run build:deck');
118
+ return ramp(family, Number(step));
119
+ }
120
+
121
+ /** One of deck.json's own derived mix percentages, by name — the stylesheet's number, not a copy. */
122
+ export function derivedMix(name: string): number {
123
+ const d = DECK.derived[name];
124
+ if (!d) throw new Error(`deckPalette: deck.json declares no derived surface "${name}" — [${Object.keys(DECK.derived).join(', ')}]`);
125
+ return d.mixPercent;
126
+ }
127
+
128
+ export function palette(register: Register) {
129
+ const bg = deref('bg', register);
130
+ const fg = deref('fg', register);
131
+ const mixed = (name: string) => {
132
+ const d = DECK.derived[name];
133
+ if (!d) throw new Error(`deckPalette: deck.json declares no derived surface "${name}" — [${Object.keys(DECK.derived).join(', ')}]`);
134
+ return mixOklab(bg, fg, d.mixPercent);
135
+ };
136
+ return {
137
+ bg,
138
+ fg,
139
+ fgMuted: deref('fg-muted', register),
140
+ fgSubtle: deref('fg-subtle', register),
141
+ accent: deref('accent', register),
142
+ accentStrong: deref('accent-strong', register),
143
+ onAccent: deref('on-accent', register),
144
+ surface: mixed('surface'),
145
+ surfaceStrong: mixed('surface-strong'),
146
+ border: mixed('border'),
147
+ borderStrong: mixed('border-strong'),
148
+ /* The semantic DANGER mark, per register — family-500 on light and family-400 on dark, exactly
149
+ as the generator re-points it. Exposed here rather than resolved by each caller because a
150
+ component that wants "the danger colour" should not have to know which ramp family danger is
151
+ or which step a mark takes; mermaidTheme's crit bar is the first caller and picked the wrong
152
+ one by hand before this existed. */
153
+ dangerMark: statusMark('danger', register),
154
+ };
155
+ }
156
+
157
+ export const REGISTERS: Register[] = ['light', 'dark'];
@@ -0,0 +1,60 @@
1
+ // The pure geometry DottedMap.vue's `highlight` prop resolves through — pulled out of the SFC's
2
+ // <script setup> so it is unit-testable. This package has no @vue/test-utils / component-mounting
3
+ // harness (only vitest, over plain modules) — the same reason backgrounds.js/iconSlugs.js are plain
4
+ // modules rather than living inline in the components that use them.
5
+ //
6
+ // FIX ROUND 3, IMPORTANT 5 — two bugs closed here, both silent:
7
+ //
8
+ // binOf(point, grid) used to trust its input completely: a caller passing a STRING (a borough
9
+ // NAME — `manifest/presets.ts`'s own `map` preset text used to tell an author to set `highlight`
10
+ // to "whichever boroughs the copy is about", with nothing warning that a NAME isn't what the prop
11
+ // actually accepts) hit `point.lng` on a string, got `undefined`, and every arithmetic step after
12
+ // that propagated NaN silently: `Math.floor(NaN) === NaN`, and `${NaN},${NaN}` never matches any
13
+ // real cell key. `binOf('Southwark')` returned `[NaN, NaN]` with no error at all. Fixed: `binOf`
14
+ // now THROWS on anything that is not a real `[col, row]` pair or a `{ lng, lat }` object with
15
+ // finite numbers, and validates the result actually lands inside the grid — so a garbled point
16
+ // (NaN from bad input, or a real lng/lat genuinely outside London's own bbox) is a loud,
17
+ // actionable error instead of a value that silently matches nothing.
18
+ //
19
+ // DottedMap.vue's own `onDots` used to be `grid.dots.filter(isOn)` — `grid.dots` is the LAND list
20
+ // only, disjoint from `grid.river` (the river band is deliberately dilated for legibility, so it
21
+ // claims real cells of its own). A point that binned correctly and landed in a river cell was
22
+ // never in `grid.dots` at all, so its highlight never drew — no mark, no warning, no fallback.
23
+ // Tower Bridge, Canary Wharf and Rotherhithe all bin there. Fixed: `highlightedCells` below
24
+ // returns every validated highlight directly, independent of whether its cell happens to be
25
+ // classified land or river in the base grid — DottedMap.vue draws from THIS, not from
26
+ // `grid.dots.filter(...)`.
27
+
28
+ /** A single highlight entry ([col, row] or { lng, lat }) resolved to a validated [col, row] pair,
29
+ or a thrown error naming exactly what was wrong — never a silent NaN. */
30
+ export const binOf = (point, grid) => {
31
+ let col;
32
+ let row;
33
+ if (Array.isArray(point)) {
34
+ [col, row] = point;
35
+ } else if (point && typeof point === 'object' && Number.isFinite(point.lng) && Number.isFinite(point.lat)) {
36
+ const [lng0, lat0, lng1, lat1] = grid.bbox;
37
+ col = Math.floor(((point.lng - lng0) / (lng1 - lng0)) * grid.width);
38
+ row = Math.floor(((lat1 - point.lat) / (lat1 - lat0)) * grid.height);
39
+ } else {
40
+ throw new Error(
41
+ `DottedMap: highlight entry ${JSON.stringify(point)} is neither a [col, row] pair nor a { lng, lat } ` +
42
+ 'object with finite numbers — a borough NAME string is not accepted; pass its real coordinates ' +
43
+ '({ lng, lat }) or a grid index ([col, row]).',
44
+ );
45
+ }
46
+ if (!Number.isFinite(col) || !Number.isFinite(row) || col < 0 || col >= grid.width || row < 0 || row >= grid.height) {
47
+ throw new Error(
48
+ `DottedMap: highlight entry ${JSON.stringify(point)} bins to [${col}, ${row}], outside the ` +
49
+ `${grid.width}×${grid.height} grid — check the coordinate is really inside London's own bbox.`,
50
+ );
51
+ }
52
+ return [col, row];
53
+ };
54
+
55
+ /** Every highlighted cell, validated and deduplicated by cell — independent of whether that cell
56
+ is classified land or river in the base grid, so a riverside point still draws its mark. */
57
+ export const highlightedCells = (highlight, grid) => {
58
+ const keys = new Set(highlight.map((p) => binOf(p, grid).join(',')));
59
+ return [...keys].map((k) => k.split(',').map(Number));
60
+ };