@heyramzi/ui 3.0.0 → 3.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.
@@ -0,0 +1,38 @@
1
+ const NICE = [
2
+ 1,
3
+ 2,
4
+ 2.5,
5
+ 5,
6
+ 10
7
+ ];
8
+ /** The smallest 1, 2, 2.5 or 5 times a power of ten that is at least `raw`. */
9
+ export function niceStep(raw) {
10
+ if (!(raw > 0) || !Number.isFinite(raw)) return 1;
11
+ const power = 10 ** Math.floor(Math.log10(raw));
12
+ // WHY the epsilon: 0.3 / 0.1 is 2.9999999999999996, which would skip 3 x 0.1 for the next step.
13
+ const multiple = NICE.find((m) => m * power >= raw * (1 - 1e-9)) ?? 10;
14
+ return multiple * power;
15
+ }
16
+ function ticksOf(step, count) {
17
+ // Rounded so 0.1 x 3 reads 0.3 on the axis, not 0.30000000000000004.
18
+ return Array.from({ length: count + 1 }, (_, i) => Number((i * step).toPrecision(12)));
19
+ }
20
+ /**
21
+ * Left and right domains from 0, with the same tick count so both axes sit on one grid.
22
+ * `target` is the number of intervals wanted; the left data may round it to one more or less.
23
+ */
24
+ export function dualAxis(leftMax, rightMax, target = 4) {
25
+ const leftStep = niceStep(Math.max(0, leftMax) / target);
26
+ const count = Math.max(1, Math.ceil(Math.max(0, leftMax) / leftStep - 1e-9));
27
+ const rightStep = niceStep(Math.max(0, rightMax) / count);
28
+ return {
29
+ left: {
30
+ max: leftStep * count,
31
+ ticks: ticksOf(leftStep, count)
32
+ },
33
+ right: {
34
+ max: rightStep * count,
35
+ ticks: ticksOf(rightStep, count)
36
+ }
37
+ };
38
+ }
@@ -5,9 +5,19 @@ import { resolveLabels } from "./labels.js";
5
5
  import { DEFAULT_CANVAS } from "./types.js";
6
6
  /** Height the controls bar takes at the bottom of the overview, in px. */
7
7
  const CONTROLS_ROOM = 56;
8
- /** How long the overview morph flag stays up, a little past the longest stagger. */
9
- const MORPH_MS = 900;
10
8
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
+ /**
10
+ * Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
11
+ * shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
12
+ */
13
+ function frame(cell, target, fit) {
14
+ const k = fit.scale / target.scale;
15
+ return {
16
+ x: fit.x + (cell.x - target.x) * k,
17
+ y: fit.y + (cell.y - target.y) * k,
18
+ scale: cell.scale * k
19
+ };
20
+ }
11
21
  function isEditable(el) {
12
22
  return el.isContentEditable || [
13
23
  "INPUT",
@@ -22,7 +32,7 @@ export class DeckController {
22
32
  /** The highlighted thumbnail in the overview, as an index among the visible slides. */
23
33
  cursor = $state(0);
24
34
  direction = $state(1);
25
- morphing = $state(false);
35
+ zoom = $state(null);
26
36
  size = $state({
27
37
  w: 0,
28
38
  h: 0
@@ -30,6 +40,7 @@ export class DeckController {
30
40
  options;
31
41
  #listeners = new Set();
32
42
  #timer;
43
+ #frame = 0;
33
44
  constructor(options) {
34
45
  this.options = options;
35
46
  }
@@ -61,8 +72,13 @@ export class DeckController {
61
72
  }, this.canvas, this.visible.length));
62
73
  /** Where slide `index` sits right now: centred to fit, or in its overview cell. */
63
74
  boxOf(index) {
64
- if (!this.overview) return this.fit;
65
- return this.grid.boxes[this.visible.indexOf(index)];
75
+ const zoom = this.zoom;
76
+ const cell = this.grid.boxes[this.visible.indexOf(index)];
77
+ if (zoom?.framed) {
78
+ const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
79
+ return cell && target ? frame(cell, target, this.fit) : this.fit;
80
+ }
81
+ return this.overview || zoom ? cell : this.fit;
66
82
  }
67
83
  register(entry) {
68
84
  untrack(() => {
@@ -78,7 +94,7 @@ export class DeckController {
78
94
  return () => this.#listeners.delete(listener);
79
95
  }
80
96
  mount() {
81
- return () => clearTimeout(this.#timer);
97
+ return () => this.#stopZoom();
82
98
  }
83
99
  /** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
84
100
  get listening() {
@@ -88,14 +104,15 @@ export class DeckController {
88
104
  this.options.index = position.index;
89
105
  this.options.step = position.step;
90
106
  }
91
- commit(target, source = "local") {
107
+ /** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
108
+ commit(target, source = "local", animate = true) {
92
109
  if (!target) return;
93
110
  const from = this.position;
94
111
  const to = settle(this.slides, target);
95
112
  if (to.index === from.index && to.step === from.step) return;
96
113
  this.direction = to.index >= from.index ? 1 : -1;
97
114
  const apply = () => this.#write(to);
98
- if (to.index !== from.index && this.options.transition) this.options.transition(apply);
115
+ if (animate && to.index !== from.index && this.options.transition) this.options.transition(apply);
99
116
  else apply();
100
117
  for (const listener of this.#listeners) listener(to, source);
101
118
  }
@@ -111,23 +128,53 @@ export class DeckController {
111
128
  step
112
129
  }, source);
113
130
  }
131
+ /**
132
+ * Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
133
+ * the current slide, so `select` moves there first.
134
+ */
114
135
  toggleOverview(force) {
115
136
  const open = force ?? !this.overview;
116
137
  if (open === this.overview) return;
117
- if (open) this.cursor = Math.max(0, this.visible.indexOf(this.position.index));
138
+ const index = this.position.index;
139
+ if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
118
140
  this.view = open ? "overview" : "slide";
119
- this.#morph();
141
+ this.#stopZoom();
142
+ const ms = this.options.zoomMs?.() ?? 0;
143
+ if (ms <= 0 || !this.visible.includes(index)) return;
144
+ /*
145
+ * Each way paints its start state first and moves on a later frame. Moved in the same
146
+ * flush, a style read in between (the overview handing focus back) commits the boxes
147
+ * before the transition rule is on, and the zoom jumps to its end.
148
+ */
149
+ this.zoom = {
150
+ index,
151
+ framed: open,
152
+ running: !open
153
+ };
154
+ this.#frame = requestAnimationFrame(() => {
155
+ this.#frame = requestAnimationFrame(() => {
156
+ this.zoom = {
157
+ index,
158
+ framed: !open,
159
+ running: true
160
+ };
161
+ this.#timer = setTimeout(() => this.#stopZoom(), ms);
162
+ });
163
+ });
120
164
  }
121
165
  /** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
122
166
  select(index) {
123
- this.toggleOverview(false);
124
167
  const keep = index === this.position.index;
125
- this.go(index, keep ? this.position.step : 0);
168
+ this.commit({
169
+ index,
170
+ step: keep ? this.position.step : 0
171
+ }, "local", false);
172
+ this.toggleOverview(false);
126
173
  }
127
- #morph() {
128
- this.morphing = true;
174
+ #stopZoom() {
129
175
  clearTimeout(this.#timer);
130
- this.#timer = setTimeout(() => this.morphing = false, MORPH_MS);
176
+ cancelAnimationFrame(this.#frame);
177
+ this.zoom = null;
131
178
  }
132
179
  #moveCursor(to) {
133
180
  this.cursor = clamp(to, 0, this.visible.length - 1);
@@ -10,7 +10,8 @@ export function fitBox(view, canvas) {
10
10
  function scaleFor(cols, count, view, canvas, pad, gap) {
11
11
  const rows = Math.ceil(count / cols);
12
12
  const byWidth = (view.w - 2 * pad - (cols - 1) * gap) / cols / canvas.w;
13
- const byHeight = (view.h - 2 * pad - (rows - 1) * gap) / rows / canvas.h;
13
+ /* Every row, the last too, keeps a gap under it for its numbers. */
14
+ const byHeight = (view.h - 2 * pad - rows * gap) / rows / canvas.h;
14
15
  return Math.min(byWidth, byHeight);
15
16
  }
16
17
  /** Lays `count` canvases in the roomiest grid that shows them all, centred. */
@@ -32,7 +33,7 @@ export function gridBoxes(view, canvas, count) {
32
33
  const cellH = canvas.h * scale;
33
34
  const rows = Math.ceil(count / cols);
34
35
  const left = (view.w - (cols * cellW + (cols - 1) * gap)) / 2;
35
- const top = (view.h - (rows * cellH + (rows - 1) * gap)) / 2;
36
+ const top = (view.h - rows * (cellH + gap)) / 2;
36
37
  const boxes = Array.from({ length: count }, (_, i) => ({
37
38
  x: left + i % cols * (cellW + gap),
38
39
  y: top + Math.floor(i / cols) * (cellH + gap),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -271,6 +271,10 @@
271
271
  "default": "./src/editor/block-ops.ts"
272
272
  },
273
273
  "./editor/inline-editor": {
274
+ "types": [
275
+ "./src/editor/inline-editor.svelte.d.ts",
276
+ "./src/editor/inline-editor.svelte"
277
+ ],
274
278
  "svelte": "./src/editor/inline-editor.svelte",
275
279
  "default": "./src/editor/inline-editor.svelte"
276
280
  },
@@ -291,7 +295,10 @@
291
295
  "default": "./src/canvas/index.ts"
292
296
  },
293
297
  "./canvas/*.svelte": {
294
- "types": "./src/canvas/*.svelte",
298
+ "types": [
299
+ "./src/canvas/*.svelte.d.ts",
300
+ "./src/canvas/*.svelte"
301
+ ],
295
302
  "svelte": "./src/canvas/*.svelte",
296
303
  "default": "./src/canvas/*.svelte"
297
304
  },
@@ -3,6 +3,9 @@
3
3
  // `.svelte` under node_modules, so without them every component types as `any`.
4
4
 
5
5
  // prepack writes them, postpack deletes them (`--clean`): left in src, they'd shadow the components.
6
+ // An export whose target is a `.svelte` file lists its `types` as [`x.svelte.d.ts`, `x.svelte`]:
7
+ // from a `.svelte` target TypeScript only looks for `x.d.svelte.ts`, and svelte-check stops
8
+ // faking that file once a real `x.svelte.d.ts` sits beside it, so the import found nothing (3.0.0).
6
9
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
10
  import { createRequire } from "node:module";
8
11
  import { tmpdir } from "node:os";
@@ -15,6 +15,12 @@
15
15
  width: 100%;
16
16
  }
17
17
 
18
+ /* A value axis owned by one series wears its colour. Mixed toward the foreground like the
19
+ figure above it, since a line colour clears 3:1 and 11px text needs 4.5:1. */
20
+ .hr-area-chart .hr-axis-tone[style*="--hr-axis-tone"] .lc-axis-tick-label {
21
+ fill: color-mix(in oklab, var(--hr-axis-tone) 70%, var(--foreground, oklch(0.2 0.01 286)));
22
+ }
23
+
18
24
  .hr-area-chart .empty {
19
25
  display: flex;
20
26
  align-items: center;
@@ -23,6 +23,7 @@
23
23
  import { resolveEdgeFade } from "./edge-fade.js";
24
24
  import Figure from "./figure.svelte";
25
25
  import HoverFocus from "./hover-focus.svelte";
26
+ import { dualAxis } from "./dual-axis.js";
26
27
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
27
28
  import { valueTick } from "./primitives/chart-utils.js";
28
29
  import Reveal from "./reveal.svelte";
@@ -44,6 +45,11 @@
44
45
  key: keyof TData & string;
45
46
  label?: string;
46
47
  color?: string;
48
+ /**
49
+ * `"right"` plots the series on a second value axis with its own scale, for a measure in a
50
+ * different unit or about 10x larger than the rest. It draws as a line, without the fill.
51
+ */
52
+ axis?: "left" | "right";
47
53
  };
48
54
 
49
55
  interface Props {
@@ -68,8 +74,15 @@
68
74
  xTicks?: number;
69
75
  /** A target drawn across the plot, like the deal a cost runs up to. */
70
76
  reference?: ChartReference;
71
- /** The headline while no point is hovered, like a period total. Defaults to the last point. */
72
- resting?: { value: number; label: string };
77
+ /**
78
+ * The headline while no point is hovered, like a period total. Defaults to the last point.
79
+ * `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
80
+ */
81
+ resting?: {
82
+ value?: number;
83
+ values?: Partial<Record<keyof TData & string, number>>;
84
+ label: string;
85
+ };
73
86
  /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
74
87
  smoothing?: Smoothing;
75
88
  /** The chart's accessible name. Its data range and values are read after it. */
@@ -146,11 +159,14 @@
146
159
  key: s.key,
147
160
  label: s.label ?? s.key,
148
161
  color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
162
+ axis: s.axis === "right" ? ("right" as const) : ("left" as const),
149
163
  }))
150
164
  : yKey
151
- ? [{ key: yKey, label: yKey, color }]
165
+ ? [{ key: yKey, label: yKey, color, axis: "left" as const }]
152
166
  : [],
153
167
  );
168
+ const leftSeries = $derived(series.filter((s) => s.axis === "left"));
169
+ const rightSeries = $derived(series.filter((s) => s.axis === "right"));
154
170
  const smoothed = $derived(
155
171
  smoothing
156
172
  ? smoothSeries(
@@ -167,12 +183,41 @@
167
183
  const averageLabel = $derived(mode === "rolling" ? averageName(smoothed.window) : null);
168
184
  const dataMax = $derived.by(() => {
169
185
  if (stacked) {
170
- return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
186
+ return Math.max(
187
+ 0,
188
+ ...data.map((d) => leftSeries.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)),
189
+ );
171
190
  }
172
- return Math.max(0, ...data.flatMap((d) => series.map((s) => Number(d[s.key] ?? 0))));
191
+ return Math.max(0, ...data.flatMap((d) => leftSeries.map((s) => Number(d[s.key] ?? 0))));
173
192
  });
174
193
  // A target above every point would sit off the plot.
175
194
  const yMax = $derived(Math.max(dataMax, reference?.value ?? 0));
195
+ // A right-axis series lives on the chart's one y scale, rescaled so its domain fills the same
196
+ // height; the right axis labels that height in the series' own unit.
197
+ const axes = $derived.by(() => {
198
+ if (rightSeries.length === 0) return null;
199
+ const rightMax = Math.max(
200
+ 0,
201
+ ...data.flatMap((d) => rightSeries.map((s) => Number(d[s.key] ?? 0))),
202
+ );
203
+ return dualAxis(yMax, rightMax);
204
+ });
205
+ const plotKey = (s: { key: string; axis: "left" | "right" }) =>
206
+ s.axis === "right" ? `__hr_right_${s.key}` : s.key;
207
+ const plotData = $derived.by((): TData[] => {
208
+ if (!axes) return data;
209
+ const factor = axes.left.max / axes.right.max;
210
+ return data.map((d) => {
211
+ const row: Record<string, unknown> = { ...d };
212
+ // A gap stays a gap: null times a factor would draw a 0.
213
+ for (const s of rightSeries)
214
+ row[plotKey(s)] = d[s.key] == null ? null : Number(d[s.key]) * factor;
215
+ return row as TData;
216
+ });
217
+ });
218
+ // An axis takes its series' colour only when one series owns it; with two on a side the colour would name one of them.
219
+ const leftTone = $derived(axes && leftSeries.length === 1 ? leftSeries[0].color : undefined);
220
+ const rightTone = $derived(rightSeries.length === 1 ? rightSeries[0].color : undefined);
176
221
  const curve = $derived(smooth ? curveMonotoneX : undefined);
177
222
  const fade = $derived(resolveEdgeFade(edgeFade));
178
223
  const fadeMask = $derived(fade > 0 ? `url(#${uid}-fade)` : undefined);
@@ -206,7 +251,16 @@
206
251
  <!-- WHY: leaving only resets the hover readout; the keyboard path is ChartPlot's. -->
207
252
  <!-- svelte-ignore a11y_no_static_element_interactions -->
208
253
  <div class="hr-area-chart {className ?? ''}" onpointerleave={() => (active = null)}>
209
- {#if resting && !active && series[0]}
254
+ {#if resting?.values && !active}
255
+ <Figure
256
+ series={series
257
+ .filter((s) => resting.values?.[s.key] != null)
258
+ .map((s) => ({ value: Number(resting.values?.[s.key]), label: s.label, color: s.color }))}
259
+ eyebrow={resting.label}
260
+ format={formatValue}
261
+ size="md"
262
+ />
263
+ {:else if resting && resting.value != null && !active && series[0]}
210
264
  <Figure
211
265
  series={[{ value: resting.value, label: series[0].label, color: series[0].color }]}
212
266
  eyebrow={resting.label}
@@ -226,21 +280,43 @@
226
280
  <div class="empty"><p>No data</p></div>
227
281
  {:else}
228
282
  <Chart
229
- {data}
283
+ data={plotData}
230
284
  x={xKey}
231
- y={series[0]?.key}
232
- yDomain={[0, yMax || 1]}
233
- yNice
234
- padding={{ left: 48, bottom: 28, top: 8, right: 12 }}
285
+ y={series[0] ? plotKey(series[0]) : undefined}
286
+ yDomain={[0, axes ? axes.left.max : yMax || 1]}
287
+ yNice={!axes}
288
+ padding={{ left: 48, bottom: 28, top: 8, right: axes ? 48 : 12 }}
235
289
  tooltipContext={{ mode: "quadtree-x" }}
236
290
  motion={DOMAIN_MOTION}
237
291
  bind:context
238
292
  >
239
293
  <Svg aria-hidden="true">
240
- <Axis placement="left" format={(v) => tick(Number(v))} ticks={4} />
294
+ <g class="hr-axis-tone" style:--hr-axis-tone={leftTone}>
295
+ <Axis
296
+ placement="left"
297
+ format={(v) => tick(Number(v))}
298
+ ticks={axes ? axes.left.ticks : 4}
299
+ />
300
+ </g>
301
+ {#if axes}
302
+ <g class="hr-axis-tone" style:--hr-axis-tone={rightTone}>
303
+ <Axis
304
+ placement="right"
305
+ domain={[0, axes.right.max]}
306
+ format={(v) => tick(Number(v))}
307
+ ticks={axes.right.ticks}
308
+ />
309
+ </g>
310
+ {/if}
241
311
  <Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
242
312
  <g class="hr-area-grid">
243
- <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
313
+ <Axis
314
+ placement="left"
315
+ grid
316
+ rule={false}
317
+ ticks={axes ? axes.left.ticks : 4}
318
+ format={() => ""}
319
+ />
244
320
  </g>
245
321
  {#if fade > 0}
246
322
  <EdgeFade id="{uid}-fade" amount={fade} />
@@ -261,17 +337,39 @@
261
337
  <!-- WHY `y1` and not `y`: layerchart's Area has no `y` prop, so every
262
338
  series drew the first series' shape and the gradients stacked on
263
339
  one path. See `Area.shared.svelte.js`. -->
264
- <Area y1={s.key} {curve} fill="url(#{uid}-{i})" mask={fadeMask} />
265
- <Spline y={s.key} {curve} style="stroke: {s.color}; fill: none" stroke-width="2" />
340
+ {#if s.axis === "left"}
341
+ <Area y1={s.key} {curve} fill="url(#{uid}-{i})" mask={fadeMask} />
342
+ {/if}
343
+ <Spline
344
+ y={plotKey(s)}
345
+ {curve}
346
+ style="stroke: {s.color}; fill: none"
347
+ stroke-width="2"
348
+ />
266
349
  {/each}
267
350
  </Reveal>
268
351
  {#snippet segment()}
269
352
  {#each series as s (s.key)}
270
- <Spline y={s.key} {curve} style="stroke: {s.color}; fill: none" stroke-width="2" />
353
+ <Spline
354
+ y={plotKey(s)}
355
+ {curve}
356
+ style="stroke: {s.color}; fill: none"
357
+ stroke-width="2"
358
+ />
271
359
  {/each}
272
360
  {/snippet}
273
361
  </HoverFocus>
274
- <Highlight points lines={{ style: `stroke: url(#${uid}-cross)` }} />
362
+ {#if axes}
363
+ <!-- WHY `data`: without it Highlight places dots from the tooltip's raw values, off the rescaled right series. -->
364
+ <Highlight lines={{ style: `stroke: url(#${uid}-cross)` }} />
365
+ {#if context?.tooltip.data}
366
+ {#each series as s (s.key)}
367
+ <Highlight data={context.tooltip.data} y={plotKey(s)} points={{ fill: s.color }} />
368
+ {/each}
369
+ {/if}
370
+ {:else}
371
+ <Highlight points lines={{ style: `stroke: url(#${uid}-cross)` }} />
372
+ {/if}
275
373
  </Svg>
276
374
  <Tooltip.Root x="data" y={TOOLTIP_TOP} xOffset={TOOLTIP_GAP}>
277
375
  {#snippet children({ data: d })}
@@ -11,6 +11,11 @@ declare function $$render<TData extends Record<string, unknown>>(): {
11
11
  key: keyof TData & string;
12
12
  label?: string;
13
13
  color?: string;
14
+ /**
15
+ * `"right"` plots the series on a second value axis with its own scale, for a measure in a
16
+ * different unit or about 10x larger than the rest. It draws as a line, without the fill.
17
+ */
18
+ axis?: "left" | "right";
14
19
  }[];
15
20
  color?: string;
16
21
  height?: number;
@@ -25,9 +30,13 @@ declare function $$render<TData extends Record<string, unknown>>(): {
25
30
  xTicks?: number;
26
31
  /** A target drawn across the plot, like the deal a cost runs up to. */
27
32
  reference?: ChartReference;
28
- /** The headline while no point is hovered, like a period total. Defaults to the last point. */
33
+ /**
34
+ * The headline while no point is hovered, like a period total. Defaults to the last point.
35
+ * `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
36
+ */
29
37
  resting?: {
30
- value: number;
38
+ value?: number;
39
+ values?: Partial<Record<keyof TData & string, number>>;
31
40
  label: string;
32
41
  };
33
42
  /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
@@ -4,8 +4,8 @@
4
4
  `@heyramzi/ui/charts/dashboard` keeps its card edge without the barrel. */
5
5
 
6
6
  @layer components {
7
- /* Shared chart tokens, held at zero specificity by `:where()`. The reveal pair
8
- is the entrance every chart shares; motion.ts mirrors its duration. */
7
+ /* Shared chart tokens, held at zero specificity by `:where()`. The tooltip portals to
8
+ `body`, outside its chart, so it is listed for its series dots to match the lines. */
9
9
  :where(
10
10
  .hr-area-chart,
11
11
  .hr-bar-chart,
@@ -17,7 +17,8 @@
17
17
  .hr-ts-chart,
18
18
  .hr-donut-chart,
19
19
  .hr-ring,
20
- .hr-gauge
20
+ .hr-gauge,
21
+ .lc-tooltip-container
21
22
  ) {
22
23
  /* Series palette: brand, then hues round the wheel, blue and orange first for colour
23
24
  blindness. Each is pulled 15% toward the text, clearing 3:1 on the card in both themes. */
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { dualAxis, niceStep } from "./dual-axis.js";
3
+
4
+ describe("niceStep", () => {
5
+ it("rounds up to 1, 2, 2.5 or 5 times a power of ten", () => {
6
+ expect(niceStep(40)).toBe(50);
7
+ expect(niceStep(1500)).toBe(2000);
8
+ expect(niceStep(2100)).toBe(2500);
9
+ expect(niceStep(0.3)).toBeCloseTo(0.5);
10
+ expect(niceStep(0)).toBe(1);
11
+ });
12
+ });
13
+
14
+ describe("dualAxis", () => {
15
+ it("gives clicks and impressions the same number of grid lines", () => {
16
+ const { left, right } = dualAxis(160, 6000);
17
+ expect(left).toEqual({ max: 200, ticks: [0, 50, 100, 150, 200] });
18
+ expect(right).toEqual({ max: 8000, ticks: [0, 2000, 4000, 6000, 8000] });
19
+ });
20
+
21
+ it("keeps every point inside both domains", () => {
22
+ for (const [l, r] of [
23
+ [7, 9_999],
24
+ [123_456, 3],
25
+ [1, 1],
26
+ [0.42, 87],
27
+ ]) {
28
+ const { left, right } = dualAxis(l, r);
29
+ expect(left.max).toBeGreaterThanOrEqual(l);
30
+ expect(right.max).toBeGreaterThanOrEqual(r);
31
+ expect(left.ticks.length).toBe(right.ticks.length);
32
+ }
33
+ });
34
+
35
+ it("draws one interval for an empty series instead of dividing by zero", () => {
36
+ const { left, right } = dualAxis(0, 0);
37
+ expect(left.ticks).toEqual([0, 1]);
38
+ expect(right.ticks).toEqual([0, 1]);
39
+ });
40
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Two value axes that share one set of grid lines.
3
+ *
4
+ * A right axis with its own nice ticks lands its labels between the left axis's grid lines, so a
5
+ * reader can't trace either. Both axes here get the same number of intervals: the left picks a nice
6
+ * step for its data, and the right picks the smallest nice step that fits its data in that many.
7
+ */
8
+
9
+ export type AxisScale = { max: number; ticks: number[] };
10
+
11
+ const NICE = [1, 2, 2.5, 5, 10];
12
+
13
+ /** The smallest 1, 2, 2.5 or 5 times a power of ten that is at least `raw`. */
14
+ export function niceStep(raw: number): number {
15
+ if (!(raw > 0) || !Number.isFinite(raw)) return 1;
16
+ const power = 10 ** Math.floor(Math.log10(raw));
17
+ // WHY the epsilon: 0.3 / 0.1 is 2.9999999999999996, which would skip 3 x 0.1 for the next step.
18
+ const multiple = NICE.find((m) => m * power >= raw * (1 - 1e-9)) ?? 10;
19
+ return multiple * power;
20
+ }
21
+
22
+ function ticksOf(step: number, count: number): number[] {
23
+ // Rounded so 0.1 x 3 reads 0.3 on the axis, not 0.30000000000000004.
24
+ return Array.from({ length: count + 1 }, (_, i) => Number((i * step).toPrecision(12)));
25
+ }
26
+
27
+ /**
28
+ * Left and right domains from 0, with the same tick count so both axes sit on one grid.
29
+ * `target` is the number of intervals wanted; the left data may round it to one more or less.
30
+ */
31
+ export function dualAxis(
32
+ leftMax: number,
33
+ rightMax: number,
34
+ target = 4,
35
+ ): { left: AxisScale; right: AxisScale } {
36
+ const leftStep = niceStep(Math.max(0, leftMax) / target);
37
+ const count = Math.max(1, Math.ceil(Math.max(0, leftMax) / leftStep - 1e-9));
38
+ const rightStep = niceStep(Math.max(0, rightMax) / count);
39
+ return {
40
+ left: { max: leftStep * count, ticks: ticksOf(leftStep, count) },
41
+ right: { max: rightStep * count, ticks: ticksOf(rightStep, count) },
42
+ };
43
+ }
@@ -58,6 +58,14 @@
58
58
  // =========================
59
59
  // FUNCTIONS
60
60
  // =========================
61
+ /**
62
+ * Active state for a toolbar button. `editor` is the same object every transaction, so a
63
+ * derived of it never re-runs the template; reading `tick` here does.
64
+ */
65
+ function isActive(name: string, attributes?: Record<string, unknown>): boolean {
66
+ return tick >= 0 && (editor?.isActive(name, attributes) ?? false);
67
+ }
68
+
61
69
  /** Opens the link field, or removes the link under the cursor. The editor binds Cmd+K to it. */
62
70
  export function toggleLink() {
63
71
  if (!editor) return;
@@ -128,7 +136,7 @@
128
136
  size="sm"
129
137
  icon
130
138
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
131
- aria-pressed={editor.isActive("heading", { level: 1 })}
139
+ aria-pressed={isActive("heading", { level: 1 })}
132
140
  onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
133
141
  title="Heading 1"
134
142
  >
@@ -139,7 +147,7 @@
139
147
  size="sm"
140
148
  icon
141
149
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
142
- aria-pressed={editor.isActive("heading", { level: 2 })}
150
+ aria-pressed={isActive("heading", { level: 2 })}
143
151
  onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
144
152
  title="Heading 2"
145
153
  >
@@ -150,7 +158,7 @@
150
158
  size="sm"
151
159
  icon
152
160
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
153
- aria-pressed={editor.isActive("heading", { level: 3 })}
161
+ aria-pressed={isActive("heading", { level: 3 })}
154
162
  onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
155
163
  title="Heading 3"
156
164
  >
@@ -164,7 +172,7 @@
164
172
  size="sm"
165
173
  icon
166
174
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
167
- aria-pressed={editor.isActive("bold")}
175
+ aria-pressed={isActive("bold")}
168
176
  onclick={() => editor?.chain().focus().toggleBold().run()}
169
177
  title="Bold (Cmd+B)"
170
178
  >
@@ -175,7 +183,7 @@
175
183
  size="sm"
176
184
  icon
177
185
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
178
- aria-pressed={editor.isActive("italic")}
186
+ aria-pressed={isActive("italic")}
179
187
  onclick={() => editor?.chain().focus().toggleItalic().run()}
180
188
  title="Italic (Cmd+I)"
181
189
  >
@@ -186,7 +194,7 @@
186
194
  size="sm"
187
195
  icon
188
196
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
189
- aria-pressed={editor.isActive("strike")}
197
+ aria-pressed={isActive("strike")}
190
198
  onclick={() => editor?.chain().focus().toggleStrike().run()}
191
199
  title="Strikethrough"
192
200
  >
@@ -195,7 +203,7 @@
195
203
 
196
204
  <div class="toolbar-divider"></div>
197
205
 
198
- {#if editor.isActive("link")}
206
+ {#if isActive("link")}
199
207
  <Button
200
208
  variant="ghost"
201
209
  size="sm"
@@ -227,7 +235,7 @@
227
235
  size="sm"
228
236
  icon
229
237
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
230
- aria-pressed={editor.isActive("bulletList")}
238
+ aria-pressed={isActive("bulletList")}
231
239
  onclick={() => editor?.chain().focus().toggleBulletList().run()}
232
240
  title="Bullet list"
233
241
  >
@@ -238,7 +246,7 @@
238
246
  size="sm"
239
247
  icon
240
248
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
241
- aria-pressed={editor.isActive("orderedList")}
249
+ aria-pressed={isActive("orderedList")}
242
250
  onclick={() => editor?.chain().focus().toggleOrderedList().run()}
243
251
  title="Ordered list"
244
252
  >
@@ -249,7 +257,7 @@
249
257
  size="sm"
250
258
  icon
251
259
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
252
- aria-pressed={editor.isActive("blockquote")}
260
+ aria-pressed={isActive("blockquote")}
253
261
  onclick={() => editor?.chain().focus().toggleBlockquote().run()}
254
262
  title="Quote"
255
263
  >
@@ -264,7 +272,7 @@
264
272
  size="sm"
265
273
  icon
266
274
  class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
267
- aria-pressed={editor.isActive("youtube")}
275
+ aria-pressed={isActive("youtube")}
268
276
  onclick={onInsertYoutube}
269
277
  title="Insert YouTube video"
270
278
  >
@@ -388,7 +388,7 @@
388
388
  <!-- =========================== -->
389
389
  <div
390
390
  class="editor-wrapper"
391
- class:focused={editor?.isFocused && !isPage}
391
+ class:focused={tick >= 0 && editor?.isFocused && !isPage}
392
392
  class:page={isPage}
393
393
  class:doc-surface={isPage}
394
394
  style="--editor-min-height: {minHeight};"
@@ -72,6 +72,7 @@
72
72
  return labels;
73
73
  },
74
74
  transition: viewTransition,
75
+ zoomMs,
75
76
  });
76
77
  controller = deck;
77
78
  setContext(DECK_KEY, deck);
@@ -88,6 +89,14 @@
88
89
  run.finished.finally(() => delete document.documentElement.dataset.deckVt);
89
90
  }
90
91
 
92
+ /* The overview zoom's length, read from the CSS so it follows the token. 0 under reduced motion. */
93
+ function zoomMs() {
94
+ if (!root || matchMedia("(prefers-reduced-motion: reduce)").matches) return 0;
95
+ const value = getComputedStyle(root).getPropertyValue("--deck-t-zoom").trim();
96
+ const ms = Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000);
97
+ return Number.isFinite(ms) ? ms : 0;
98
+ }
99
+
91
100
  onMount(() => deck.mount());
92
101
 
93
102
  $effect(() => deck.listen((position) => onIndexChange?.(position.index, position.step)));
@@ -175,7 +184,7 @@
175
184
  data-deck={id}
176
185
  data-view={deck.view}
177
186
  data-transition={transition}
178
- data-morphing={deck.morphing || undefined}
187
+ data-zoom={deck.zoom ? (deck.zoom.running ? "run" : "hold") : undefined}
179
188
  {onpointerdown}
180
189
  {onpointerup}
181
190
  >
@@ -185,7 +194,8 @@
185
194
  {:else}
186
195
  {@render children?.()}
187
196
  {/if}
188
- {#if deck.overview}
197
+ <!-- The tiles mount once the camera starts pulling back, and fade in as it lands. -->
198
+ {#if deck.overview && !deck.zoom?.framed}
189
199
  <Overview {deck} returnFocus={root} />
190
200
  {/if}
191
201
  </div>
@@ -60,7 +60,7 @@
60
60
  padding: 0;
61
61
  list-style: none;
62
62
  /* The ring and number appear once the thumbnails have landed. */
63
- animation: tiles-in var(--deck-t-quick) linear var(--deck-t-morph) backwards;
63
+ animation: tiles-in var(--deck-t-quick) linear var(--deck-t-zoom) backwards;
64
64
  }
65
65
  @media (prefers-reduced-motion: reduce) {
66
66
  .deck__tiles {
@@ -94,14 +94,20 @@
94
94
  .deck__tile:focus-visible {
95
95
  outline-color: var(--deck-fg);
96
96
  }
97
+ /*
98
+ * The number sits in the gap under its thumbnail, never on it: straddling the edge put
99
+ * half of it under the slide. 7px clears the 5px ring, and 7 + 18 fits the grid's 26px gap.
100
+ */
97
101
  .deck__tile-n {
98
102
  position: absolute;
99
- bottom: -12px;
103
+ top: calc(100% + 7px);
100
104
  left: 50%;
101
- min-width: 22px;
105
+ z-index: 1;
106
+ box-sizing: border-box;
107
+ min-width: 24px;
102
108
  transform: translateX(-50%);
103
109
  padding: 3px 6px;
104
- font: 600 11px/1 var(--deck-font);
110
+ font: 600 12px/1 var(--deck-font);
105
111
  font-variant-numeric: tabular-nums;
106
112
  text-align: center;
107
113
  color: var(--deck-fg);
@@ -43,7 +43,7 @@ Keys: arrows, Space (Shift+Space back), PageUp/PageDown, Home/End, `O` overview,
43
43
 
44
44
  ## Theming
45
45
 
46
- Set variables on `.deck`: `--deck-bg`, `--deck-fg`, `--deck-accent`, `--deck-on-accent`, `--deck-font`, `--deck-font-display`, `--deck-pad`, `--deck-text-*` (design px) and the timing tokens `--deck-t-slide`, `--deck-t-morph`, `--deck-stagger`, `--deck-ease`. They default to the `@heyramzi/ui` motion tokens when present. Motion is transform and opacity only and stops under `prefers-reduced-motion`.
46
+ Set variables on `.deck`: `--deck-bg`, `--deck-fg`, `--deck-accent`, `--deck-on-accent`, `--deck-font`, `--deck-font-display`, `--deck-pad`, `--deck-text-*` (design px) and the timing tokens `--deck-t-slide`, `--deck-t-morph`, `--deck-t-zoom` (the overview zoom), `--deck-stagger`, `--deck-ease`. They default to the `@heyramzi/ui` motion tokens when present. `--deck-accent` and `--deck-on-accent` follow the app's `--primary` and `--primary-foreground`, and fall back to the deck's ink. Motion is transform and opacity only and stops under `prefers-reduced-motion`.
47
47
 
48
48
  ## Data-driven decks
49
49
 
@@ -152,19 +152,19 @@
152
152
  transition-delay: 0s;
153
153
  }
154
154
 
155
- /* The overview shows every slide. They all leave the stack at once and settle in turn, later ones slower. */
156
- :global(.deck[data-view="overview"]) .slide {
155
+ /*
156
+ * The overview shows every slide. Opening and closing move them as one rigid grid, like a
157
+ * camera pulling back or pushing in, so no slide gets its own delay.
158
+ */
159
+ :global(.deck[data-view="overview"]) .slide,
160
+ :global(.deck[data-zoom]) .slide {
157
161
  visibility: visible;
162
+ transition-delay: 0s;
158
163
  }
159
- :global(.deck[data-morphing]) .slide {
164
+ :global(.deck[data-zoom="run"]) .slide {
160
165
  transition:
161
- visibility 0s linear calc(var(--deck-t-morph) + 10 * var(--deck-stagger)),
162
- transform calc(var(--deck-t-morph) + min(var(--i), 10) * var(--deck-stagger))
163
- var(--deck-ease);
164
- }
165
- /* Opening shows the slides at once, so only closing waits before hiding them. */
166
- :global(.deck[data-morphing][data-view="overview"]) .slide {
167
- transition-delay: 0s;
166
+ visibility 0s,
167
+ transform var(--deck-t-zoom) var(--deck-ease);
168
168
  }
169
169
 
170
170
  .slide__surface {
@@ -178,11 +178,17 @@
178
178
  font-family: var(--deck-font);
179
179
  animation: var(--deck-t-slide) var(--deck-ease) backwards;
180
180
  }
181
- :global(.deck[data-view="overview"]) .slide__surface {
181
+ /* Framed on one slide the grid looks like that slide, so it keeps a slide's square corners. */
182
+ :global(.deck[data-view="overview"]:not([data-zoom="hold"])) .slide__surface {
182
183
  /* Design px, so divide by the scale to get the same corner on every thumbnail. */
183
184
  border-radius: calc(10px / var(--slide-scale, 1));
184
185
  box-shadow: 0 0 0 calc(1px / var(--slide-scale, 1)) var(--deck-line);
185
186
  }
187
+ :global(.deck[data-zoom="run"]) .slide__surface {
188
+ transition:
189
+ border-radius var(--deck-t-zoom) var(--deck-ease),
190
+ box-shadow var(--deck-t-zoom) var(--deck-ease);
191
+ }
186
192
 
187
193
  .slide__surface[data-align="center"],
188
194
  .slide__surface[data-align="start"] {
@@ -44,16 +44,34 @@ export type ControllerOptions = {
44
44
  labels?: Partial<DeckLabels>;
45
45
  /** Wraps a position change so a View Transition can snapshot around it. */
46
46
  transition?: (update: () => void) => void;
47
+ /** How long the overview zoom plays, in ms. 0, or left out, swaps at once. */
48
+ zoomMs?: () => number;
47
49
  };
48
50
 
51
+ /**
52
+ * The overview zoom while it plays. `index` is the slide the camera frames. `framed` puts that
53
+ * slide in its full-size spot with the grid around it, and `running` lets the boxes transition.
54
+ */
55
+ export type Zoom = { index: number; framed: boolean; running: boolean };
56
+
49
57
  /** Height the controls bar takes at the bottom of the overview, in px. */
50
58
  const CONTROLS_ROOM = 56;
51
59
 
52
- /** How long the overview morph flag stays up, a little past the longest stagger. */
53
- const MORPH_MS = 900;
54
-
55
60
  const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
56
61
 
62
+ /**
63
+ * Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
64
+ * shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
65
+ */
66
+ function frame(cell: Box, target: Box, fit: Box): Box {
67
+ const k = fit.scale / target.scale;
68
+ return {
69
+ x: fit.x + (cell.x - target.x) * k,
70
+ y: fit.y + (cell.y - target.y) * k,
71
+ scale: cell.scale * k,
72
+ };
73
+ }
74
+
57
75
  function isEditable(el: HTMLElement) {
58
76
  return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
59
77
  }
@@ -71,12 +89,13 @@ export class DeckController {
71
89
  /** The highlighted thumbnail in the overview, as an index among the visible slides. */
72
90
  cursor = $state(0);
73
91
  direction = $state<1 | -1>(1);
74
- morphing = $state(false);
92
+ zoom = $state<Zoom | null>(null);
75
93
  size = $state<Size>({ w: 0, h: 0 });
76
94
 
77
95
  readonly options!: ControllerOptions;
78
96
  #listeners = new Set<Listener>();
79
97
  #timer: ReturnType<typeof setTimeout> | undefined;
98
+ #frame = 0;
80
99
 
81
100
  constructor(options: ControllerOptions) {
82
101
  this.options = options;
@@ -116,8 +135,13 @@ export class DeckController {
116
135
 
117
136
  /** Where slide `index` sits right now: centred to fit, or in its overview cell. */
118
137
  boxOf(index: number): Box | undefined {
119
- if (!this.overview) return this.fit;
120
- return this.grid.boxes[this.visible.indexOf(index)];
138
+ const zoom = this.zoom;
139
+ const cell = this.grid.boxes[this.visible.indexOf(index)];
140
+ if (zoom?.framed) {
141
+ const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
142
+ return cell && target ? frame(cell, target, this.fit) : this.fit;
143
+ }
144
+ return this.overview || zoom ? cell : this.fit;
121
145
  }
122
146
 
123
147
  register(entry: SlideEntry): () => void {
@@ -137,7 +161,7 @@ export class DeckController {
137
161
  }
138
162
 
139
163
  mount(): () => void {
140
- return () => clearTimeout(this.#timer);
164
+ return () => this.#stopZoom();
141
165
  }
142
166
 
143
167
  /** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
@@ -150,14 +174,16 @@ export class DeckController {
150
174
  this.options.step = position.step;
151
175
  }
152
176
 
153
- commit(target: Position | null, source: Source = "local") {
177
+ /** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
178
+ commit(target: Position | null, source: Source = "local", animate = true) {
154
179
  if (!target) return;
155
180
  const from = this.position;
156
181
  const to = settle(this.slides, target);
157
182
  if (to.index === from.index && to.step === from.step) return;
158
183
  this.direction = to.index >= from.index ? 1 : -1;
159
184
  const apply = () => this.#write(to);
160
- if (to.index !== from.index && this.options.transition) this.options.transition(apply);
185
+ if (animate && to.index !== from.index && this.options.transition)
186
+ this.options.transition(apply);
161
187
  else apply();
162
188
  for (const listener of this.#listeners) listener(to, source);
163
189
  }
@@ -174,25 +200,45 @@ export class DeckController {
174
200
  this.commit({ index, step }, source);
175
201
  }
176
202
 
203
+ /**
204
+ * Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
205
+ * the current slide, so `select` moves there first.
206
+ */
177
207
  toggleOverview(force?: boolean) {
178
208
  const open = force ?? !this.overview;
179
209
  if (open === this.overview) return;
180
- if (open) this.cursor = Math.max(0, this.visible.indexOf(this.position.index));
210
+ const index = this.position.index;
211
+ if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
181
212
  this.view = open ? "overview" : "slide";
182
- this.#morph();
213
+ this.#stopZoom();
214
+ const ms = this.options.zoomMs?.() ?? 0;
215
+ if (ms <= 0 || !this.visible.includes(index)) return;
216
+
217
+ /*
218
+ * Each way paints its start state first and moves on a later frame. Moved in the same
219
+ * flush, a style read in between (the overview handing focus back) commits the boxes
220
+ * before the transition rule is on, and the zoom jumps to its end.
221
+ */
222
+ this.zoom = { index, framed: open, running: !open };
223
+ this.#frame = requestAnimationFrame(() => {
224
+ this.#frame = requestAnimationFrame(() => {
225
+ this.zoom = { index, framed: !open, running: true };
226
+ this.#timer = setTimeout(() => this.#stopZoom(), ms);
227
+ });
228
+ });
183
229
  }
184
230
 
185
231
  /** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
186
232
  select(index: number) {
187
- this.toggleOverview(false);
188
233
  const keep = index === this.position.index;
189
- this.go(index, keep ? this.position.step : 0);
234
+ this.commit({ index, step: keep ? this.position.step : 0 }, "local", false);
235
+ this.toggleOverview(false);
190
236
  }
191
237
 
192
- #morph() {
193
- this.morphing = true;
238
+ #stopZoom() {
194
239
  clearTimeout(this.#timer);
195
- this.#timer = setTimeout(() => (this.morphing = false), MORPH_MS);
240
+ cancelAnimationFrame(this.#frame);
241
+ this.zoom = null;
196
242
  }
197
243
 
198
244
  #moveCursor(to: number) {
@@ -25,7 +25,8 @@ function scaleFor(
25
25
  ): number {
26
26
  const rows = Math.ceil(count / cols);
27
27
  const byWidth = (view.w - 2 * pad - (cols - 1) * gap) / cols / canvas.w;
28
- const byHeight = (view.h - 2 * pad - (rows - 1) * gap) / rows / canvas.h;
28
+ /* Every row, the last too, keeps a gap under it for its numbers. */
29
+ const byHeight = (view.h - 2 * pad - rows * gap) / rows / canvas.h;
29
30
  return Math.min(byWidth, byHeight);
30
31
  }
31
32
 
@@ -47,7 +48,7 @@ export function gridBoxes(view: Size, canvas: Canvas, count: number): Grid {
47
48
  const cellH = canvas.h * scale;
48
49
  const rows = Math.ceil(count / cols);
49
50
  const left = (view.w - (cols * cellW + (cols - 1) * gap)) / 2;
50
- const top = (view.h - (rows * cellH + (rows - 1) * gap)) / 2;
51
+ const top = (view.h - rows * (cellH + gap)) / 2;
51
52
 
52
53
  const boxes = Array.from({ length: count }, (_, i) => ({
53
54
  x: left + (i % cols) * (cellW + gap),
@@ -2,7 +2,8 @@
2
2
 
3
3
  /*
4
4
  * Everything global to a deck, in design px since the whole canvas scales, and in flat
5
- * hex because Chromium's PDF pipeline shifts oklch.
5
+ * hex because Chromium's PDF pipeline shifts oklch. The accent comes from the host app's
6
+ * `--primary`, which is often oklch, so it goes through `color-mix(in srgb)` to land as sRGB.
6
7
  */
7
8
 
8
9
  @layer components {
@@ -12,8 +13,30 @@
12
13
 
13
14
  --deck-bg: light-dark(#f2f1ec, #0e1014);
14
15
  --deck-fg: light-dark(#15171b, #f3f1ea);
15
- --deck-accent: light-dark(#3e6b00, #c8f15a);
16
- --deck-on-accent: light-dark(#f6f6f0, #0e1014);
16
+ /* Without a `--primary` the accent is the deck's own ink, so nothing ships a colour the app never chose. */
17
+ /*
18
+ * `--primary` follows the page's theme and the deck sets its own scheme: a dark deck on a
19
+ * light page got the page's dark primary on a dark slide (2.7:1). The brand pair is picked
20
+ * by the deck's scheme; `--primary` is the fallback for an app without the kit palette.
21
+ */
22
+ --deck-accent: color-mix(
23
+ in srgb,
24
+ light-dark(
25
+ var(--brand-primary, var(--primary, #15171b)),
26
+ var(--brand-primary-on-dark, var(--primary, #f3f1ea))
27
+ )
28
+ 100%,
29
+ #000
30
+ );
31
+ --deck-on-accent: color-mix(
32
+ in srgb,
33
+ light-dark(
34
+ var(--brand-primary-foreground, var(--primary-foreground, #f6f6f0)),
35
+ var(--brand-primary-foreground-on-dark, var(--primary-foreground, #0e1014))
36
+ )
37
+ 100%,
38
+ #000
39
+ );
17
40
  --deck-muted: color-mix(in srgb, var(--deck-fg) 66%, transparent);
18
41
  --deck-line: color-mix(in srgb, var(--deck-fg) 14%, transparent);
19
42
  --deck-surface: color-mix(in srgb, var(--deck-fg) 6%, transparent);
@@ -32,6 +55,8 @@
32
55
 
33
56
  --deck-t-slide: var(--duration-fast, 250ms);
34
57
  --deck-t-morph: var(--duration-slow, 300ms);
58
+ /* The overview zoom moves the whole canvas, so it takes the overlay length, not the UI ceiling. */
59
+ --deck-t-zoom: var(--duration-overlay, 500ms);
35
60
  --deck-t-quick: var(--duration-quick, 150ms);
36
61
  --deck-stagger: var(--duration-stagger, 40ms);
37
62
  --deck-ease: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
@@ -124,8 +149,12 @@
124
149
  animation-name: deck-zoom;
125
150
  }
126
151
 
127
- .deck[data-morphing] .slide__surface {
128
- animation: none !important;
152
+ /*
153
+ * The zoom already shows the slide arriving, so its enter animation finishes at once. A
154
+ * 0s duration, not `animation: none`, which would replay the enter when the zoom ends.
155
+ */
156
+ .deck[data-zoom] .slide__surface {
157
+ animation-duration: 0s !important;
129
158
  }
130
159
 
131
160
  /* A morph runs on the whole page, so it gets the deck's timing while it plays. */