contour-deck 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,15 +23,15 @@ The design is in [docs/DESIGN.md](docs/DESIGN.md) (Traditional Chinese); the HTM
23
23
 
24
24
  | | |
25
25
  | --- | --- |
26
- | ![跨區塊移動:翻到區塊盡頭時拉遠再推進,位置列的圓點直接飛到另一個區塊](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/move.gif) | ![3D 結構:輪廓傾斜、頁疊成層,仍可點進投影片](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/structure.gif) |
27
- | 跨區塊移動:拉遠再推進,位置列的圓點直接飛到別的區塊 | 3D 結構:按 3,頁疊成層 |
26
+ | ![跨區塊移動:翻到區塊盡頭時拉遠再推進,位置列的圓點直接飛到另一個區塊](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/move.gif) | ![縱深桌面:每個區塊一列往後排,上下鍵或滾輪把下一列推到面前,仍可點進投影片](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/structure.gif) |
27
+ | 跨區塊移動:拉遠再推進,位置列的圓點直接飛到別的區塊 | 3D 縱深桌面:按 3,整份簡報一個畫面,上下換列 |
28
28
  | ![步驟:表格一列一列出現,步驟用完才換頁](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/build.gif) | ![手機:講稿檢視、輪廓、舞台與往上滑的換頁](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/phone.gif) |
29
29
  | 步驟:空白鍵讓表格一列一列出現 | 手機:講稿檢視與往上滑換頁 |
30
30
 
31
31
  | | |
32
32
  | --- | --- |
33
- | ![輪廓:七個區塊的縮圖牆](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview.png) | ![3D 結構](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview-3d.png) |
34
- | 輪廓 | 3D 結構 |
33
+ | ![輪廓:七個區塊的縮圖牆](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview.png) | ![3D 縱深桌面](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview-3d.png) |
34
+ | 輪廓(講過的投影片變淡) | 3D 縱深桌面 |
35
35
  | ![舞台旁的講稿面板](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/notes.png) | ![淺色主題的舞台](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/light.png) |
36
36
  | 講稿面板(按 N) | 淺色主題 |
37
37
 
@@ -100,7 +100,7 @@ A page that is not Astro gets the same behaviour from `mountAll()` in `contour-d
100
100
  | Option | Default | Meaning |
101
101
  | --- | --- | --- |
102
102
  | `paging` | `"snap"` | `"snap"`: one wheel or trackpad gesture moves one stop. `"free"`: native scrolling with proximity snap. |
103
- | `overview` | `"flat"` | `"3d"` opens the outline as the tilted 3D structure. |
103
+ | `overview` | `"flat"` | `"3d"` opens the outline as the depth desk: one row per block, the current one in front, the rest receding behind it. |
104
104
  | `linear.skip` | `["appendix"]` | Block kinds the linear advance and the arrow keys never enter. |
105
105
  | `view` | from the URL | `"deck"` or `"notes"` (the speaker notes as a scrolling script, for a phone). |
106
106
  | `at` | from the URL | Entry position, such as `"4.4.2"` or a slide id. |
@@ -202,12 +202,12 @@ The phone must be able to open the page's URL, so a deck on `localhost` needs a
202
202
  | `Space`, `Enter`, `PageDown` | advance one step | open the current slide |
203
203
  | `Shift+Space`, `PageUp`, `Backspace` | back one step | |
204
204
  | `←` `→` | hide or show the current page's steps first, then the previous or next slide | move between thumbnails |
205
- | `↑` `↓` | previous, next stop | move between thumbnails |
206
- | `Home`, `End` | first, last slide of the block | |
205
+ | `↑` `↓` | previous, next stop | move between thumbnails; in 3D, the previous or next row to the front |
206
+ | `Home`, `End` | first, last slide of the block | in 3D, the first or last row to the front |
207
207
  | `N` | notes panel | |
208
208
  | `R` | remote panel (with the `remote` option) | remote panel |
209
209
  | `Esc`, `O` | outline | `Esc` leaves the deck (`exit`) |
210
- | `3` | | 3D structure |
210
+ | `3` | | 3D: the depth desk |
211
211
 
212
212
  ### URL
213
213
 
@@ -220,6 +220,7 @@ The core removes both parameters in `destroy()` and leaves the others alone.
220
220
 
221
221
  - Set `--ct-bg`, `--ct-fg`, `--ct-accent`, `--ct-accent-fg`, `--ct-border`, `--ct-muted`, `--ct-mono`, and `--ct-measure` on `.ct-app`.
222
222
  By default the colours follow the site's `--background`, `--foreground`, `--accent`, `--accent-foreground`, `--border`, and `--muted-foreground`.
223
+ - The depth desk (3D outline) reads `--ct-row-depth` (how far back each row stands, default `260px`) and `--ct-row-rise` (how much higher, default `26px`) on `.ct-app`.
223
224
  - A page keeps its content in a centred column of `--ct-measure` through its own side padding, so margins and widths the host sets on the content stay as written.
224
225
  The type of a page follows the size of the stage and never goes below `1rem`; sizes the host writes in `em` follow it, sizes in `rem` do not.
225
226
  - The open deck keeps its ancestors, so content styles that depend on them still apply.
package/dist/core/deck.js CHANGED
@@ -39,6 +39,10 @@ export function createDeck(root, options = {}) {
39
39
  /** A slide change the deck itself started and whose scroll has not arrived yet. */
40
40
  let pending = null;
41
41
  let wakeLock = null;
42
+ /** The depth desk: the row the camera is headed for, and the camera move under way. */
43
+ let aim = 0;
44
+ let move = 0;
45
+ let endMove = null;
42
46
  /* ---- shell: bar, outline, stage, notes panel, notes view ---- */
43
47
  const app = h("div", "ct-app");
44
48
  const bar = app.appendChild(h("header", "ct-bar"));
@@ -146,6 +150,8 @@ export function createDeck(root, options = {}) {
146
150
  // The title first: its width moves the rail, and the marker is placed by layout offsets.
147
151
  titleEl.textContent = mode === "stage" ? blocks[pos.b].title : deckTitle;
148
152
  rail.mark(pos);
153
+ if (mode === "stage")
154
+ overview.seen(pos.b, pos.s);
149
155
  overview.ring(pos.b, pos.s);
150
156
  const height = slide.el.clientHeight || 1;
151
157
  const pages = boxes(slide);
@@ -328,8 +334,17 @@ export function createDeck(root, options = {}) {
328
334
  };
329
335
  const thumbRect = (at) => {
330
336
  const face = overview.faces[at.b][at.s];
331
- face.scrollIntoView({ block: "nearest" });
332
- return face.getBoundingClientRect();
337
+ if (!depthOn()) {
338
+ face.scrollIntoView({ block: "nearest" });
339
+ return face.getBoundingClientRect();
340
+ }
341
+ // On the depth desk the block's row comes to the front at once, and the rectangle is read where its sheets end up.
342
+ front(at.b, true);
343
+ const plane = face.closest(".ct-plane");
344
+ plane.setAttribute("data-ct-still", "");
345
+ const rect = face.getBoundingClientRect();
346
+ plane.removeAttribute("data-ct-still");
347
+ return rect;
333
348
  };
334
349
  const cancel = (animations) => animations.forEach(animation => animation.cancel());
335
350
  /** Outline to stage: the stage grows out of the slide's thumbnail. */
@@ -374,9 +389,21 @@ export function createDeck(root, options = {}) {
374
389
  stage.style.opacity = "0";
375
390
  cancel(out);
376
391
  place(at, end);
377
- const thumb = thumbRect(pos);
378
- render();
379
- await wait(reduced() ? 80 : 400);
392
+ let thumb;
393
+ if (depthOn()) {
394
+ // On the depth desk the camera moves forward to the new block's row, then the stage grows out of it.
395
+ render();
396
+ await front(pos.b);
397
+ if (!alive)
398
+ return;
399
+ thumb = thumbRect(pos);
400
+ await wait(reduced() ? 80 : 160);
401
+ }
402
+ else {
403
+ thumb = thumbRect(pos);
404
+ render();
405
+ await wait(reduced() ? 80 : 400);
406
+ }
380
407
  if (!alive)
381
408
  return;
382
409
  const into = zoomStage(stage, root, thumb, "in", 460, reduced());
@@ -412,6 +439,8 @@ export function createDeck(root, options = {}) {
412
439
  if (mode === "stage")
413
440
  return close();
414
441
  mode = "overview";
442
+ if (depthOn())
443
+ front(pos.b, true);
415
444
  render();
416
445
  }
417
446
  function toScript() {
@@ -437,7 +466,62 @@ export function createDeck(root, options = {}) {
437
466
  }
438
467
  /* ---- input ---- */
439
468
  const press = (el, on) => el.setAttribute("aria-pressed", String(on));
440
- const toggle3d = () => press(threeDButton, app.toggleAttribute("data-ct-3d"));
469
+ const depthOn = () => app.hasAttribute("data-ct-3d");
470
+ function toggle3d() {
471
+ const on = app.toggleAttribute("data-ct-3d");
472
+ press(threeDButton, on);
473
+ stopMove();
474
+ overview.depth(on);
475
+ if (on)
476
+ front(pos.b, true);
477
+ else {
478
+ overview.el.scrollTop = 0;
479
+ overview.faces[pos.b][pos.s].scrollIntoView({ block: "nearest" });
480
+ }
481
+ }
482
+ function stopMove() {
483
+ cancelAnimationFrame(move);
484
+ move = 0;
485
+ endMove?.();
486
+ endMove = null;
487
+ }
488
+ /** Brings a block's row to the front of the depth desk: the camera glides there, or jumps under reduced motion. */
489
+ function front(b, instant = reduced()) {
490
+ b = clamp(b, 0, blocks.length - 1);
491
+ if (move && aim === b && !instant)
492
+ return Promise.resolve();
493
+ stopMove();
494
+ aim = b;
495
+ const ov = overview.el;
496
+ const from = overview.camera();
497
+ if (instant || from === b) {
498
+ overview.camera(b);
499
+ ov.scrollTop = b * overview.step;
500
+ return Promise.resolve();
501
+ }
502
+ const started = performance.now();
503
+ const ms = clamp(240 + 80 * Math.abs(b - from), 240, 640);
504
+ return new Promise(resolve => {
505
+ endMove = resolve;
506
+ const tick = (now) => {
507
+ const t = Math.min(1, (now - started) / ms);
508
+ overview.camera(from + (b - from) * (1 - (1 - t) ** 3));
509
+ if (t < 1)
510
+ return void (move = requestAnimationFrame(tick));
511
+ move = 0;
512
+ endMove = null;
513
+ ov.scrollTop = b * overview.step;
514
+ resolve();
515
+ };
516
+ move = requestAnimationFrame(tick);
517
+ });
518
+ }
519
+ /** A row on the depth desk, with the focus on its first thumbnail, or on the current one in the current block. */
520
+ function toRow(b) {
521
+ b = clamp(b, 0, blocks.length - 1);
522
+ overview.thumbs[b][b === pos.b ? pos.s : 0].focus({ preventScroll: true });
523
+ void front(b);
524
+ }
441
525
  /** The stage changed size: put the current slide and page back in view. */
442
526
  function align(p = pos.p) {
443
527
  if (busy)
@@ -460,11 +544,20 @@ export function createDeck(root, options = {}) {
460
544
  const exit = () => busy || listeners.exit.forEach(listener => listener());
461
545
  const remote = () => options.remote?.(api);
462
546
  const actions = { overview: toOverview, "3d": toggle3d, script: toScript, notes: toggleNotes, remote, prev, next, exit };
547
+ /** The row in front when the pointer went down: the focus that follows may already be bringing another row forward. */
548
+ let pressed = null;
549
+ overview.el.addEventListener("pointerdown", () => void (pressed = aim), { capture: true, signal });
463
550
  app.addEventListener("click", event => {
464
551
  const target = event.target;
465
552
  const action = target.closest("[data-ct-act]")?.dataset.ctAct;
466
553
  if (action)
467
554
  return void actions[action]?.();
555
+ // On the depth desk a click on a row behind brings it to the front; only the row in front opens.
556
+ const row = mode === "overview" && depthOn() ? overview.rowOf(target) : -1;
557
+ const wasFront = pressed ?? aim;
558
+ pressed = null;
559
+ if (row >= 0 && row !== wasFront)
560
+ return void front(row);
468
561
  const go = target.closest("[data-ct-go]")?.dataset.ctGo;
469
562
  if (!go)
470
563
  return;
@@ -473,7 +566,15 @@ export function createDeck(root, options = {}) {
473
566
  }, { signal });
474
567
  addEventListener("resize", () => {
475
568
  const p = pos.p;
476
- requestAnimationFrame(() => alive && align(p));
569
+ requestAnimationFrame(() => {
570
+ if (!alive)
571
+ return;
572
+ if (depthOn()) {
573
+ overview.depth(true);
574
+ front(aim, true);
575
+ }
576
+ align(p);
577
+ });
477
578
  }, { signal });
478
579
  // The browser drops the screen wake lock when the page is hidden; the notes view asks again on return.
479
580
  document.addEventListener("visibilitychange", () => {
@@ -485,9 +586,25 @@ export function createDeck(root, options = {}) {
485
586
  function focusThumb(step) {
486
587
  const all = overview.thumbs.flat();
487
588
  const focused = all.indexOf(document.activeElement);
488
- all[focused < 0 ? all.indexOf(overview.thumbs[pos.b][pos.s]) : clamp(focused + step, 0, all.length - 1)].focus();
589
+ all[focused < 0 ? all.indexOf(overview.thumbs[pos.b][pos.s]) : clamp(focused + step, 0, all.length - 1)].focus({ preventScroll: depthOn() });
489
590
  }
591
+ // On the depth desk the focus brings its row to the front: Tab, the arrow keys, a click.
592
+ overview.el.addEventListener("focusin", event => {
593
+ const row = depthOn() ? overview.rowOf(event.target) : -1;
594
+ if (row >= 0 && row !== aim)
595
+ void front(row);
596
+ }, { signal });
597
+ // Native scrolling (touch, the scroll bar) moves the camera; one snap stop per row.
598
+ overview.el.addEventListener("scroll", () => {
599
+ if (!depthOn() || move)
600
+ return;
601
+ const at = overview.el.scrollTop / overview.step;
602
+ const row = Math.round(at);
603
+ overview.camera(Math.abs(at - row) < 0.02 ? row : at);
604
+ aim = clamp(row, 0, blocks.length - 1);
605
+ }, { passive: true, signal });
490
606
  document.addEventListener("keydown", event => {
607
+ pressed = null;
491
608
  const target = event.target;
492
609
  if (event.metaKey || event.ctrlKey || event.altKey || busy || target.closest?.("input,textarea,select,[contenteditable]"))
493
610
  return;
@@ -498,10 +615,15 @@ export function createDeck(root, options = {}) {
498
615
  if (key === "r" && options.remote && mode !== "notes")
499
616
  run = remote;
500
617
  else if (mode === "overview") {
618
+ const rows = depthOn();
501
619
  if (key === "3")
502
620
  run = toggle3d;
503
621
  else if (key === "Escape")
504
622
  run = exit;
623
+ else if (rows && (key === "ArrowUp" || key === "ArrowDown"))
624
+ run = () => toRow(aim + (key === "ArrowDown" ? 1 : -1));
625
+ else if (rows && (key === "Home" || key === "End"))
626
+ run = () => toRow(key === "Home" ? 0 : blocks.length - 1);
505
627
  else if (key.startsWith("Arrow"))
506
628
  run = () => focusThumb(key === "ArrowRight" || key === "ArrowDown" ? 1 : -1);
507
629
  else if (key === "PageDown" || (activates && !onControl))
@@ -547,6 +669,16 @@ export function createDeck(root, options = {}) {
547
669
  stepV(step);
548
670
  }, { passive: false, signal });
549
671
  }
672
+ // The depth desk: one wheel gesture moves one row.
673
+ const rowStep = createWheelGate();
674
+ overview.el.addEventListener("wheel", event => {
675
+ if (!depthOn() || event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
676
+ return;
677
+ event.preventDefault();
678
+ const step = rowStep(event.deltaY * (event.deltaMode === 1 ? 16 : 1), performance.now(), busy);
679
+ if (step)
680
+ toRow(aim + step);
681
+ }, { passive: false, signal });
550
682
  /* ---- start ---- */
551
683
  const params = new URLSearchParams(useUrl ? location.search : "");
552
684
  const startAt = resolve(options.at ?? params.get("at") ?? "");
@@ -569,6 +701,7 @@ export function createDeck(root, options = {}) {
569
701
  alive = false;
570
702
  listeners.destroy.forEach(listener => listener());
571
703
  abort.abort();
704
+ stopMove();
572
705
  cancelAnimationFrame(frame);
573
706
  clearTimeout(idle);
574
707
  setCurrent(null);
@@ -7,5 +7,15 @@ export interface Overview {
7
7
  thumbs: HTMLButtonElement[][];
8
8
  /** Moves the "you are here" ring onto a thumbnail. */
9
9
  ring(b: number, s: number, instant?: boolean): void;
10
+ /** Records that a slide has been on the stage: the outline shows it faded. */
11
+ seen(b: number, s: number): void;
12
+ /** Switches the depth desk on or off and fits its rows to the outline's size. */
13
+ depth(on: boolean): void;
14
+ /** The depth desk's camera: the row it stands at, fractional while it moves. Without an argument, reads it. */
15
+ camera(at?: number): number;
16
+ /** The block whose row is under `el`, or -1. */
17
+ rowOf(el: Element): number;
18
+ /** Scroll distance of one row on the depth desk. */
19
+ readonly step: number;
10
20
  }
11
21
  export declare function buildOverview(blocks: BlockNode[], labels: Labels): Overview;
@@ -1,5 +1,5 @@
1
- /** The outline: one panel per block, one schematic thumbnail per slide. */
2
- import { h, offsetIn } from "./dom.js";
1
+ /** The outline: one panel per block, one schematic thumbnail per slide; in 3D, one row per block on the depth desk. */
2
+ import { clamp, h, offsetIn } from "./dom.js";
3
3
  const FIGURE = "figure,img,picture,svg,canvas,video,iframe";
4
4
  function sketchType(el) {
5
5
  if (el.dataset.ctSk)
@@ -36,11 +36,24 @@ function sketch(page) {
36
36
  }
37
37
  export function buildOverview(blocks, labels) {
38
38
  const el = h("div", "ct-ov");
39
- const plane = el.appendChild(h("div", "ct-plane"));
39
+ // In 3D the desk is the camera (sticky, clipped, with the perspective) and the plane the scene inside it;
40
+ // the snap points under them give each row a stop of native scrolling.
41
+ const desk = el.appendChild(h("div", "ct-desk"));
42
+ const plane = desk.appendChild(h("div", "ct-plane"));
43
+ const stops = el.appendChild(h("div", "ct-stops", "", { "aria-hidden": "true" }));
40
44
  const faces = [];
41
45
  const thumbs = [];
46
+ const tiles = [];
47
+ const visited = blocks.map(block => block.slides.map(() => false));
48
+ let depth = false;
49
+ let cam = 0;
50
+ let front = -1;
51
+ let here = null;
42
52
  blocks.forEach((block, b) => {
43
53
  const tile = plane.appendChild(h("section", "ct-tile"));
54
+ tile.style.setProperty("--i", String(b));
55
+ tiles.push(tile);
56
+ stops.append(h("i"));
44
57
  if (block.kind)
45
58
  tile.dataset.kind = block.kind;
46
59
  if (block.slides.length > 3)
@@ -80,13 +93,73 @@ export function buildOverview(blocks, labels) {
80
93
  });
81
94
  });
82
95
  const ringEl = plane.appendChild(h("div", "ct-ring", "", { "data-label": labels.here }));
83
- return {
96
+ let ringAt = [0, 0];
97
+ /** Rows the camera has passed slide out and take no pointer; the row in front shows its stacks in depth. */
98
+ function setFront(row) {
99
+ if (row === front)
100
+ return;
101
+ front = row;
102
+ tiles.forEach((tile, b) => {
103
+ tile.toggleAttribute("data-ct-front", depth && b === row);
104
+ tile.toggleAttribute("data-ct-past", depth && b < row);
105
+ });
106
+ }
107
+ /** In 3D a row taller than six tenths of the outline is scaled down; all rows hang from one line that leaves room for the tallest. */
108
+ function fit() {
109
+ if (!depth)
110
+ return;
111
+ const height = el.clientHeight;
112
+ let tallest = 0;
113
+ for (const tile of tiles) {
114
+ const scale = Math.min(1, (height * 0.6) / (tile.offsetHeight || 1));
115
+ tile.style.setProperty("--fit", scale.toFixed(3));
116
+ tallest = Math.max(tallest, tile.offsetHeight * scale);
117
+ }
118
+ plane.style.setProperty("--ct-top", `${Math.round(clamp(height - tallest - 40, height * 0.36, height * 0.6))}px`);
119
+ }
120
+ const api = {
84
121
  el,
85
122
  faces,
86
123
  thumbs,
124
+ get step() {
125
+ return stops.firstElementChild.offsetHeight || 1;
126
+ },
127
+ depth(on) {
128
+ depth = on;
129
+ front = -1;
130
+ if (on)
131
+ fit();
132
+ setFront(on ? clamp(Math.round(cam), 0, tiles.length - 1) : -1);
133
+ api.ring(...ringAt, true);
134
+ },
135
+ camera(at) {
136
+ if (at === undefined)
137
+ return cam;
138
+ cam = at;
139
+ plane.style.setProperty("--ct-cam", String(+at.toFixed(4)));
140
+ setFront(depth ? clamp(Math.round(at), 0, tiles.length - 1) : -1);
141
+ return cam;
142
+ },
143
+ rowOf: target => tiles.findIndex(tile => tile.contains(target)),
144
+ seen(b, s) {
145
+ visited[b][s] = true;
146
+ thumbs[b][s].setAttribute("data-ct-seen", "");
147
+ },
87
148
  ring(b, s, instant) {
149
+ ringAt = [b, s];
150
+ // The current thumbnail stands out of the faded ones; a block is faded once all its slides were shown and it is not current.
151
+ here?.removeAttribute("aria-current");
152
+ here = thumbs[b][s];
153
+ here.setAttribute("aria-current", "true");
154
+ tiles.forEach((tile, i) => tile.toggleAttribute("data-ct-seen", i !== b && visited[i].every(Boolean)));
88
155
  const face = faces[b][s];
89
- const { x, y } = offsetIn(face, plane);
156
+ // In 3D the ring rides in the row of its thumbnail, so it moves with that row.
157
+ const host = depth ? tiles[b] : plane;
158
+ if (ringEl.parentElement !== host) {
159
+ host.append(ringEl);
160
+ instant = true;
161
+ }
162
+ const { x, y } = offsetIn(face, host);
90
163
  const style = ringEl.style;
91
164
  if (instant)
92
165
  style.transition = "none";
@@ -101,4 +174,5 @@ export function buildOverview(blocks, labels) {
101
174
  }
102
175
  },
103
176
  };
177
+ return api;
104
178
  }
package/dist/style.css CHANGED
@@ -21,6 +21,8 @@ html[data-ct-open] {
21
21
  Menlo,
22
22
  Consolas,
23
23
  monospace;
24
+ --ct-row-depth: 260px;
25
+ --ct-row-rise: 26px;
24
26
  }
25
27
  .ct-app {
26
28
  --ct-panel: color-mix(in srgb, var(--ct-fg) 4%, var(--ct-bg));
@@ -186,7 +188,6 @@ html[data-ct-open] {
186
188
  max-width: 86rem;
187
189
  margin: 0 auto;
188
190
  transform-style: preserve-3d;
189
- transition: transform .7s var(--ct-ease);
190
191
  }
191
192
  .ct-tile {
192
193
  border: 1px solid var(--ct-border);
@@ -383,23 +384,98 @@ html[data-ct-open] {
383
384
  color: var(--ct-accent-fg);
384
385
  white-space: nowrap;
385
386
  }
387
+ .ct-plane {
388
+ --ct-ink: var(--ct-fg);
389
+ --ct-line: var(--ct-border);
390
+ --ct-dim: var(--ct-muted);
391
+ }
392
+ .ct-plane :is(.ct-tile, .ct-thumb) {
393
+ --ct-fade: min(80%, (var(--ct-fog, 0) + var(--ct-was, 0)) * 100%);
394
+ --ct-fg: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-ink));
395
+ --ct-border: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-line));
396
+ --ct-muted: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-dim));
397
+ }
398
+ .ct-app .ct-plane :is(.ct-tile, .ct-thumb) {
399
+ color: var(--ct-fg);
400
+ }
401
+ .ct-plane .ct-thumb {
402
+ --ct-was: 0;
403
+ }
404
+ .ct-plane :is(.ct-tile[data-ct-seen], .ct-thumb[data-ct-seen]:not([aria-current])) {
405
+ --ct-was: .5;
406
+ }
407
+ .ct-stops {
408
+ display: none;
409
+ }
386
410
  .ct-app[data-ct-3d] .ct-ov {
387
- overflow: hidden;
411
+ container-type: size;
412
+ padding: 0;
413
+ overflow: hidden auto;
414
+ scroll-snap-type: y mandatory;
415
+ overscroll-behavior: contain;
416
+ scrollbar-width: none;
417
+ }
418
+ .ct-app[data-ct-3d] .ct-ov::-webkit-scrollbar {
419
+ display: none;
420
+ }
421
+ .ct-app[data-ct-3d] .ct-desk {
422
+ position: sticky;
423
+ top: 0;
424
+ height: 100cqh;
425
+ overflow: clip;
426
+ perspective: 1000px;
427
+ perspective-origin: 50% 0;
428
+ z-index: 1;
429
+ }
430
+ .ct-app[data-ct-3d] .ct-stops {
431
+ display: block;
432
+ margin-top: -100cqh;
433
+ pointer-events: none;
434
+ }
435
+ .ct-stops i {
436
+ display: block;
437
+ height: 120px;
438
+ scroll-snap-align: start;
439
+ scroll-snap-stop: always;
440
+ }
441
+ .ct-stops i:last-child {
442
+ height: 100cqh;
388
443
  }
389
444
  .ct-app[data-ct-3d] .ct-plane {
390
- transform: translateY(-4%) rotateX(52deg) rotateZ(-24deg) scale(.86);
445
+ position: absolute;
446
+ inset: 0;
447
+ display: block;
448
+ max-width: none;
449
+ margin: 0;
450
+ pointer-events: none;
451
+ }
452
+ .ct-app[data-ct-3d] .ct-tile {
453
+ --ahead: max(var(--i) - var(--ct-cam, 0), 0);
454
+ --past: min(var(--i) - var(--ct-cam, 0), 0);
455
+ --ct-fog: min(var(--ahead) * .14, .56);
456
+ position: absolute;
457
+ left: 50%;
458
+ top: var(--ct-top, 40%);
459
+ width: min(100% - 2.4rem, 60rem);
460
+ transform-origin: 50% 0;
461
+ transform: translate3d(-50%, calc(var(--past) * -45cqh - min(var(--ahead), 4) * var(--ct-row-rise)), calc(var(--past) * -240px - var(--ahead) * var(--ct-row-depth))) scale(var(--fit, 1));
462
+ opacity: calc(1 + var(--past) * 1.6);
463
+ pointer-events: auto;
464
+ }
465
+ .ct-app[data-ct-3d] .ct-tile[data-ct-past] {
466
+ pointer-events: none;
391
467
  }
392
- .ct-app[data-ct-3d] .ct-layer {
393
- transform: translateZ(calc((var(--n) - 1 - var(--k)) * var(--ct-lift)));
468
+ .ct-app[data-ct-3d] .ct-tile:not([data-ct-front]) {
469
+ cursor: pointer;
394
470
  }
395
- .ct-app[data-ct-3d] .ct-face {
396
- transform: translateZ(calc((var(--n) - 1) * var(--ct-lift)));
471
+ .ct-app[data-ct-3d] [data-ct-front] .ct-layer {
472
+ transform: translate3d(calc(var(--k) * 3px), calc(var(--k) * 3px), calc((var(--n) - 1 - var(--k)) * var(--ct-lift)));
397
473
  }
398
- .ct-app[data-ct-3d] .ct-thumb:is(:hover, :focus-visible) {
399
- transform: translateZ(22px);
474
+ .ct-app[data-ct-3d] [data-ct-front] :is(.ct-face, .ct-ring) {
475
+ transform: translateZ(calc((var(--n) - 1) * var(--ct-lift)));
400
476
  }
401
- .ct-app[data-ct-3d] .ct-ring {
402
- transform: translateZ(calc((var(--n) - 1) * var(--ct-lift) + 1px));
477
+ .ct-plane[data-ct-still] :is(.ct-layer, .ct-face, .ct-ring, .ct-thumb) {
478
+ transition: none;
403
479
  }
404
480
  .ct-stage {
405
481
  position: absolute;
@@ -621,7 +697,7 @@ html[data-ct-open] {
621
697
  }
622
698
  }
623
699
  @media (prefers-reduced-motion: reduce) {
624
- .ct-app :is(.ct-plane, .ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) {
700
+ .ct-app :is(.ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) {
625
701
  transition-duration: .01ms !important;
626
702
  }
627
703
  }
package/docs/CONTRACT.md CHANGED
@@ -146,7 +146,7 @@ The core does not read them, and a host that calls `createDeck` itself needs non
146
146
  | `data-ct-host` | the wrapper of one deck element | The element the host script mounts. The deck may sit at any depth inside it. |
147
147
  | `data-ct-doc-class` | the wrapper | Space-separated classes the wrapper carries only while the deck is closed; the script removes them on opening and restores them on closing. |
148
148
  | `data-ct-paging` | the wrapper | `snap` or `free`: the `paging` option of the core. |
149
- | `data-ct-overview` | the wrapper | `flat` or `3d`: the `overview` option of the core. |
149
+ | `data-ct-overview` | the wrapper | `flat` or `3d`: the `overview` option of the core. `3d` opens the outline as the depth desk, one row per block. |
150
150
  | `data-ct-legacy` | the wrapper | Accept old reveal.js links: `?view=slides&slide=h.v` is rewritten to `?view=deck&at=…` through `data-legacy` and `data-legacy-v`. |
151
151
  | `data-ct-start` | any control in the page | A click opens the deck; with the value `notes`, its notes view. Inside a wrapper it opens that deck, elsewhere the first deck of the page. |
152
152
  | `data-ct-remote` | the wrapper | The deck has the remote button and the `R` key (`contour-deck/remote`, loaded on first use). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contour-deck",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Outline-first slide decks on real DOM: blocks, slides, pages.",
5
5
  "keywords": [
6
6
  "slides",
package/styles/deck.css CHANGED
@@ -8,6 +8,9 @@
8
8
  --ct-muted: var(--muted-foreground, color-mix(in srgb, var(--ct-fg) 62%, var(--ct-bg)));
9
9
  --ct-measure: 62rem;
10
10
  --ct-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
11
+ /* the depth desk (3D outline): how far back each row stands, and how much higher */
12
+ --ct-row-depth: 260px;
13
+ --ct-row-rise: 26px;
11
14
  }
12
15
  .ct-app {
13
16
  --ct-panel: color-mix(in srgb, var(--ct-fg) 4%, var(--ct-bg));
@@ -51,7 +54,7 @@
51
54
 
52
55
  /* ---- outline ---- */
53
56
  .ct-ov { position: absolute; inset: 0; overflow: auto; padding: 1.1rem; }
54
- .ct-plane { position: relative; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 1rem; max-width: 86rem; margin: 0 auto; transform-style: preserve-3d; transition: transform .7s var(--ct-ease); }
57
+ .ct-plane { position: relative; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 1rem; max-width: 86rem; margin: 0 auto; transform-style: preserve-3d; }
55
58
  .ct-tile { border: 1px solid var(--ct-border); background: var(--ct-bg); padding: .7rem .8rem .9rem; transform-style: preserve-3d; min-width: 0; }
56
59
  .ct-tile[data-kind="appendix"] { background: var(--ct-panel); }
57
60
  @media (min-width: 900px) { .ct-tile[data-wide] { grid-column: span 2; } }
@@ -84,13 +87,44 @@
84
87
  .ct-ring { position: absolute; left: 0; top: 0; pointer-events: none; outline: 2px solid var(--ct-accent); outline-offset: 3px; z-index: 3; transition: left .38s var(--ct-ease), top .38s var(--ct-ease), width .38s var(--ct-ease), height .38s var(--ct-ease), transform .7s var(--ct-ease); }
85
88
  .ct-ring::after { content: attr(data-label); position: absolute; left: -5px; top: calc(100% + 5px); font: 650 .56rem/1 var(--ct-mono); letter-spacing: .08em; padding: .2rem .3rem; background: var(--ct-accent); color: var(--ct-accent-fg); white-space: nowrap; }
86
89
 
87
- /* 3D structure: the plane tilts and every slide becomes a stack as high as its page count */
88
- .ct-app[data-ct-3d] .ct-ov { overflow: hidden; }
89
- .ct-app[data-ct-3d] .ct-plane { transform: translateY(-4%) rotateX(52deg) rotateZ(-24deg) scale(.86); }
90
- .ct-app[data-ct-3d] .ct-layer { transform: translateZ(calc((var(--n) - 1 - var(--k)) * var(--ct-lift))); }
91
- .ct-app[data-ct-3d] .ct-face { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift))); }
92
- .ct-app[data-ct-3d] .ct-thumb:is(:hover, :focus-visible) { transform: translateZ(22px); }
93
- .ct-app[data-ct-3d] .ct-ring { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift) + 1px)); }
90
+ /* Faded: a slide already shown, a block already finished, and on the depth desk the rows far behind.
91
+ Colours mix toward the background instead of using opacity, which would flatten the 3D of a row. */
92
+ .ct-plane { --ct-ink: var(--ct-fg); --ct-line: var(--ct-border); --ct-dim: var(--ct-muted); }
93
+ .ct-plane :is(.ct-tile, .ct-thumb) {
94
+ --ct-fade: min(80%, (var(--ct-fog, 0) + var(--ct-was, 0)) * 100%);
95
+ --ct-fg: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-ink));
96
+ --ct-border: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-line));
97
+ --ct-muted: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-dim));
98
+ }
99
+ .ct-app .ct-plane :is(.ct-tile, .ct-thumb) { color: var(--ct-fg); }
100
+ .ct-plane .ct-thumb { --ct-was: 0; }
101
+ .ct-plane :is(.ct-tile[data-ct-seen], .ct-thumb[data-ct-seen]:not([aria-current])) { --ct-was: .5; }
102
+
103
+ /* 3D, the depth desk: every block is a row facing the viewer. The camera stands at row --ct-cam (the scroll position, one
104
+ snap stop per row); rows ahead recede and rise, rows passed slide out toward the viewer. Only the row in front stacks its pages in depth. */
105
+ .ct-stops { display: none; }
106
+ .ct-app[data-ct-3d] .ct-ov { container-type: size; padding: 0; overflow: hidden auto; scroll-snap-type: y mandatory; overscroll-behavior: contain; scrollbar-width: none; }
107
+ .ct-app[data-ct-3d] .ct-ov::-webkit-scrollbar { display: none; }
108
+ .ct-app[data-ct-3d] .ct-desk { position: sticky; top: 0; height: 100cqh; overflow: clip; perspective: 1000px; perspective-origin: 50% 0; z-index: 1; }
109
+ .ct-app[data-ct-3d] .ct-stops { display: block; margin-top: -100cqh; pointer-events: none; }
110
+ .ct-stops i { display: block; height: 120px; scroll-snap-align: start; scroll-snap-stop: always; }
111
+ .ct-stops i:last-child { height: 100cqh; }
112
+ /* The scene's own surface stands at depth 0, in front of every row behind: only the rows take the pointer. */
113
+ .ct-app[data-ct-3d] .ct-plane { position: absolute; inset: 0; display: block; max-width: none; margin: 0; pointer-events: none; }
114
+ .ct-app[data-ct-3d] .ct-tile {
115
+ --ahead: max(var(--i) - var(--ct-cam, 0), 0);
116
+ --past: min(var(--i) - var(--ct-cam, 0), 0);
117
+ --ct-fog: min(var(--ahead) * .14, .56);
118
+ position: absolute; left: 50%; top: var(--ct-top, 40%); width: min(100% - 2.4rem, 60rem); transform-origin: 50% 0;
119
+ transform: translate3d(-50%, calc(var(--past) * -45cqh - min(var(--ahead), 4) * var(--ct-row-rise)), calc(var(--past) * -240px - var(--ahead) * var(--ct-row-depth))) scale(var(--fit, 1));
120
+ opacity: calc(1 + var(--past) * 1.6);
121
+ pointer-events: auto;
122
+ }
123
+ .ct-app[data-ct-3d] .ct-tile[data-ct-past] { pointer-events: none; }
124
+ .ct-app[data-ct-3d] .ct-tile:not([data-ct-front]) { cursor: pointer; }
125
+ .ct-app[data-ct-3d] [data-ct-front] .ct-layer { transform: translate3d(calc(var(--k) * 3px), calc(var(--k) * 3px), calc((var(--n) - 1 - var(--k)) * var(--ct-lift))); }
126
+ .ct-app[data-ct-3d] [data-ct-front] :is(.ct-face, .ct-ring) { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift))); }
127
+ .ct-plane[data-ct-still] :is(.ct-layer, .ct-face, .ct-ring, .ct-thumb) { transition: none; }
94
128
 
95
129
  /* ---- stage: one block is a left-right track; each slide scrolls by pages ---- */
96
130
  .ct-stage { position: absolute; inset: 0; background: var(--ct-bg); border: 1px solid var(--ct-border); transform-origin: 0 0; z-index: 2; overflow: hidden; outline: none; container-type: size; }
@@ -147,5 +181,5 @@
147
181
  }
148
182
 
149
183
  @media (prefers-reduced-motion: reduce) {
150
- .ct-app :is(.ct-plane, .ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) { transition-duration: .01ms !important; }
184
+ .ct-app :is(.ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) { transition-duration: .01ms !important; }
151
185
  }