@abreen/tada 1.13.2 → 1.13.7

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/build/bundle.ts CHANGED
@@ -9,10 +9,20 @@ import {
9
9
  getDistDir,
10
10
  toPosix,
11
11
  } from './utils/paths';
12
- import { deriveTheme, deriveLinkHue } from './utils/derive-theme';
12
+ import {
13
+ deriveTheme,
14
+ deriveLinkHue,
15
+ deriveTraceLineActiveHue,
16
+ } from './utils/derive-theme';
13
17
  import type { PluginBuilder } from 'bun';
14
18
  import type { SiteVariables } from './types';
15
19
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
20
+ import pkg from '../package.json' with { type: 'json' };
21
+
22
+ export function getBundleNaming(): string {
23
+ const version = pkg.version.replace(/[^a-zA-Z0-9.-]/g, '-');
24
+ return `[name].bundle.tada-${version}.[ext]`;
25
+ }
16
26
 
17
27
  function formatCssNumber(value: number, precision = 4): string {
18
28
  if (Number.isInteger(value)) {
@@ -40,6 +50,9 @@ function renderThemeScss(siteVariables: SiteVariables): string {
40
50
  const linkColorHover = `hsl(${linkHue}deg 34% 60%)`;
41
51
  const linkColorDark = `hsl(${linkHue}deg 50% 72%)`;
42
52
  const linkColorHoverDark = `hsl(${linkHue}deg 40% 80%)`;
53
+ const traceLineActiveHue = formatCssNumber(deriveTraceLineActiveHue(tintHue));
54
+ const bgTraceLineActive = `hsl(${traceLineActiveHue}deg 100% 86%)`;
55
+ const bgTraceLineActiveDark = `hsl(${traceLineActiveHue}deg 90% 18%)`;
43
56
 
44
57
  const rendered = _.template(template)({
45
58
  ...theme,
@@ -54,6 +67,8 @@ function renderThemeScss(siteVariables: SiteVariables): string {
54
67
  linkColorHover,
55
68
  linkColorDark,
56
69
  linkColorHoverDark,
70
+ bgTraceLineActive,
71
+ bgTraceLineActiveDark,
57
72
  });
58
73
 
59
74
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
@@ -109,7 +124,7 @@ export async function bundle(
109
124
  const result = await Bun.build({
110
125
  entrypoints,
111
126
  outdir: resolvedDistDir,
112
- naming: '[name].bundle.[ext]',
127
+ naming: getBundleNaming(),
113
128
  minify: mode === 'production',
114
129
  sourcemap: isDev ? 'inline' : 'none',
115
130
  define: createDefine(siteVariables, isDev),
@@ -35,6 +35,7 @@ export class ContentRenderer {
35
35
  string,
36
36
  {
37
37
  manifestUrl: string;
38
+ artifactId: string;
38
39
  highlightedSource: string;
39
40
  totalSteps: number;
40
41
  mtime: number;
package/build/types.d.ts CHANGED
@@ -97,6 +97,7 @@ export interface RenderPlainTextOptions {
97
97
  string,
98
98
  {
99
99
  manifestUrl: string;
100
+ artifactId: string;
100
101
  highlightedSource: string;
101
102
  totalSteps: number;
102
103
  mtime: number;
@@ -16,6 +16,8 @@ const DARK_TEXT_L_MAX = 0.82;
16
16
 
17
17
  const LINK_ANCHOR_HUE = 212;
18
18
  const LINK_TINT_BLEND = 0.05;
19
+ const TRACE_LINE_ACTIVE_ANCHOR_HUE = 52;
20
+ const TRACE_LINE_ACTIVE_TINT_BLEND = 0.1;
19
21
 
20
22
  function clamp(v: number, min: number, max: number): number {
21
23
  return Math.min(Math.max(v, min), max);
@@ -83,6 +85,14 @@ export function deriveLinkHue(tintHue: number): number {
83
85
  return (LINK_ANCHOR_HUE + diff * LINK_TINT_BLEND + 360) % 360;
84
86
  }
85
87
 
88
+ export function deriveTraceLineActiveHue(tintHue: number): number {
89
+ const diff = ((tintHue - TRACE_LINE_ACTIVE_ANCHOR_HUE + 540) % 360) - 180;
90
+ return (
91
+ (TRACE_LINE_ACTIVE_ANCHOR_HUE + diff * TRACE_LINE_ACTIVE_TINT_BLEND + 360) %
92
+ 360
93
+ );
94
+ }
95
+
86
96
  export function getTextOnColor(cssColor: string): '#fff' | '#000' {
87
97
  const parsed = parse(cssColor);
88
98
  if (!parsed) {
@@ -432,6 +432,7 @@ function renderPlainTextContent(
432
432
  string,
433
433
  {
434
434
  manifestUrl: string;
435
+ artifactId: string;
435
436
  highlightedSource: string;
436
437
  totalSteps: number;
437
438
  mtime: number;
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { createHash } from 'crypto';
3
4
  import { renderCodeSegment } from './code';
4
5
  import { splitLines } from './literate-java';
5
6
  import { normalizeOutputPath } from './paths';
@@ -14,18 +15,46 @@ export interface ChunkTraceOutputOptions {
14
15
  ignoreFields?: Record<string, string[]>;
15
16
  }
16
17
 
18
+ export interface ChunkTraceOutputResult {
19
+ manifest: TraceManifest;
20
+ artifactId: string;
21
+ outputPaths: string[];
22
+ }
23
+
24
+ function hashTraceFiles(files: { name: string; content: string }[]): string {
25
+ const hasher = createHash('sha256');
26
+ for (const file of files) {
27
+ hasher.update(file.name, 'utf8');
28
+ hasher.update('\0', 'utf8');
29
+ hasher.update(file.content, 'utf8');
30
+ hasher.update('\0', 'utf8');
31
+ }
32
+ return `sha256-${hasher.digest('hex').slice(0, 16)}`;
33
+ }
34
+
35
+ function buildTraceOutputPath(
36
+ relDir: string,
37
+ traceName: string,
38
+ artifactId: string,
39
+ fileName: string,
40
+ ): string {
41
+ return [relDir, '_traces', traceName, artifactId, fileName]
42
+ .filter(Boolean)
43
+ .join('/');
44
+ }
45
+
17
46
  export function chunkTraceOutput(
18
47
  output: string,
19
48
  traceOutputDir: string,
49
+ relDir: string,
50
+ traceName: string,
20
51
  sourceFile: string,
21
52
  source: string,
22
53
  options: ChunkTraceOutputOptions = {},
23
- ): TraceManifest {
54
+ ): ChunkTraceOutputResult {
24
55
  const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
25
56
  const ignoreFields = options.ignoreFields ?? {};
26
57
 
27
- fs.mkdirSync(traceOutputDir, { recursive: true });
28
-
29
58
  const lines = output
30
59
  .trim()
31
60
  .split('\n')
@@ -46,26 +75,27 @@ export function chunkTraceOutput(
46
75
 
47
76
  let chunkEntries: TraceChunkEntry[] = [];
48
77
  let chunkIndex = 0;
78
+ const chunkFiles: { name: string; content: string }[] = [];
49
79
 
50
80
  for (const step of allSteps) {
51
81
  const svg = generateStepSvg(step, layout);
52
82
  chunkEntries.push({ line: step.line, stdout: step.stdout, svg });
53
83
 
54
84
  if (chunkEntries.length >= chunkSize) {
55
- fs.writeFileSync(
56
- path.join(traceOutputDir, `chunk-${chunkIndex}.json`),
57
- JSON.stringify(chunkEntries),
58
- );
85
+ chunkFiles.push({
86
+ name: `chunk-${chunkIndex}.json`,
87
+ content: JSON.stringify(chunkEntries),
88
+ });
59
89
  chunkEntries = [];
60
90
  chunkIndex++;
61
91
  }
62
92
  }
63
93
 
64
94
  if (chunkEntries.length > 0) {
65
- fs.writeFileSync(
66
- path.join(traceOutputDir, `chunk-${chunkIndex}.json`),
67
- JSON.stringify(chunkEntries),
68
- );
95
+ chunkFiles.push({
96
+ name: `chunk-${chunkIndex}.json`,
97
+ content: JSON.stringify(chunkEntries),
98
+ });
69
99
  }
70
100
 
71
101
  const manifest: TraceManifest = {
@@ -76,12 +106,26 @@ export function chunkTraceOutput(
76
106
  lineToSteps,
77
107
  };
78
108
 
79
- fs.writeFileSync(
80
- path.join(traceOutputDir, 'manifest.json'),
81
- JSON.stringify(manifest),
82
- );
109
+ const manifestFile = {
110
+ name: 'manifest.json',
111
+ content: JSON.stringify(manifest),
112
+ };
113
+ const files = [manifestFile, ...chunkFiles];
114
+ const artifactId = hashTraceFiles(files);
115
+ const artifactDir = path.join(traceOutputDir, artifactId);
83
116
 
84
- return manifest;
117
+ fs.mkdirSync(artifactDir, { recursive: true });
118
+ for (const file of files) {
119
+ fs.writeFileSync(path.join(artifactDir, file.name), file.content);
120
+ }
121
+
122
+ return {
123
+ manifest,
124
+ artifactId,
125
+ outputPaths: files.map(file =>
126
+ buildTraceOutputPath(relDir, traceName, artifactId, file.name),
127
+ ),
128
+ };
85
129
  }
86
130
 
87
131
  export function highlightTraceSource(
@@ -96,16 +140,25 @@ export function highlightTraceSource(
96
140
  export function getTraceOutputPaths(
97
141
  relDir: string,
98
142
  traceName: string,
143
+ artifactId: string,
99
144
  totalSteps: number,
100
145
  ): string[] {
101
- const baseDir = [relDir, '_traces', traceName].filter(Boolean).join('/');
102
- const outputPaths = [`${baseDir}/manifest.json`];
146
+ const outputPaths = [
147
+ buildTraceOutputPath(relDir, traceName, artifactId, 'manifest.json'),
148
+ ];
103
149
  for (
104
150
  let chunkIndex = 0;
105
151
  chunkIndex * DEFAULT_CHUNK_SIZE < totalSteps;
106
152
  chunkIndex++
107
153
  ) {
108
- outputPaths.push(`${baseDir}/chunk-${chunkIndex}.json`);
154
+ outputPaths.push(
155
+ buildTraceOutputPath(
156
+ relDir,
157
+ traceName,
158
+ artifactId,
159
+ `chunk-${chunkIndex}.json`,
160
+ ),
161
+ );
109
162
  }
110
163
  return outputPaths;
111
164
  }
@@ -148,18 +201,18 @@ export function materializeTraceOutputs({
148
201
  export function buildManifestUrl({
149
202
  relDir,
150
203
  traceName,
204
+ artifactId,
151
205
  applyBasePath,
152
206
  }: {
153
207
  relDir: string;
154
208
  traceName: string;
209
+ artifactId: string;
155
210
  applyBasePath: (subPath: string) => string;
156
211
  }): string {
157
212
  return applyBasePath(
158
213
  normalizeOutputPath(
159
214
  '/' +
160
- [relDir, '_traces', traceName, 'manifest.json']
161
- .filter(Boolean)
162
- .join('/'),
215
+ buildTraceOutputPath(relDir, traceName, artifactId, 'manifest.json'),
163
216
  ),
164
217
  );
165
218
  }
@@ -187,17 +240,13 @@ export function renderTraceWidgetHtml({
187
240
  `<button class="trace-btn trace-first" disabled tabindex="-1" aria-label="First step" title="First step">${iconFirst}</button>` +
188
241
  `<button class="trace-btn trace-prev" disabled tabindex="-1" aria-label="Previous step" title="Previous step">${iconPrev}</button>` +
189
242
  `<span class="trace-step-counter"${counterStyle}>${counterContent}</span>` +
190
- (disabled
191
- ? `<button class="trace-btn trace-next" disabled tabindex="-1" aria-label="Next step" title="Next step">${iconNext}</button>`
192
- : `<button class="trace-btn trace-next" aria-label="Next step" title="Next step">${iconNext}</button>`) +
193
- (disabled
194
- ? `<button class="trace-btn trace-last" disabled tabindex="-1" aria-label="Last step" title="Last step">${iconLast}</button>`
195
- : `<button class="trace-btn trace-last" tabindex="-1" aria-label="Last step" title="Last step">${iconLast}</button>`);
243
+ `<button class="trace-btn trace-next" disabled tabindex="-1" aria-label="Next step" title="Next step">${iconNext}</button>` +
244
+ `<button class="trace-btn trace-last" disabled tabindex="-1" aria-label="Last step" title="Last step">${iconLast}</button>`;
196
245
  const wrapperAttrs = manifestUrl
197
246
  ? ` data-trace-manifest="${manifestUrl}"`
198
247
  : '';
199
248
  const wrapperClass = disabled
200
249
  ? 'trace-widget trace-disabled'
201
250
  : 'trace-widget';
202
- return `<div class="${wrapperClass}"${wrapperAttrs}><noscript><p>This interactive trace requires JavaScript.</p></noscript><div class="trace-body"><div class="trace-toolbar"><div class="trace-controls" role="toolbar" aria-label="Trace navigation">${controls}</div></div><div class="trace-content"><div class="trace-diagram"></div><div class="trace-source-wrapper"><div class="trace-source">${highlightedSource}</div></div><pre class="trace-output"></pre></div></div></div>`;
251
+ return `<div class="${wrapperClass}"${wrapperAttrs}><noscript><p>This interactive trace requires JavaScript.</p></noscript><div class="trace-body"><div class="trace-toolbar"><div class="trace-controls" role="toolbar" aria-label="Trace navigation">${controls}</div></div><div class="trace-content"><div class="trace-diagram"></div><div class="trace-source-wrapper"><div class="trace-source">${highlightedSource}</div></div></div></div></div>`;
203
252
  }
@@ -42,6 +42,7 @@ export interface TraceContext {
42
42
 
43
43
  export interface TraceResult {
44
44
  manifestUrl: string;
45
+ artifactId: string;
45
46
  highlightedSource: string;
46
47
  totalSteps: number;
47
48
  mtime: number;
@@ -141,6 +142,7 @@ export function createTraceHelpers(context: TraceContext): {
141
142
  const outputPaths = getTraceOutputPaths(
142
143
  relDir,
143
144
  traceName,
145
+ cached.artifactId,
144
146
  cached.totalSteps,
145
147
  );
146
148
  if (
@@ -155,7 +157,12 @@ export function createTraceHelpers(context: TraceContext): {
155
157
  }
156
158
  return {
157
159
  ...cached,
158
- manifestUrl: buildManifestUrl({ relDir, traceName, applyBasePath }),
160
+ manifestUrl: buildManifestUrl({
161
+ relDir,
162
+ traceName,
163
+ artifactId: cached.artifactId,
164
+ applyBasePath,
165
+ }),
159
166
  };
160
167
  }
161
168
  }
@@ -176,32 +183,42 @@ export function createTraceHelpers(context: TraceContext): {
176
183
  );
177
184
  const traceOutputDir = path.join(distDir, relDir, '_traces', traceName);
178
185
 
179
- const manifest = chunkTraceOutput(
186
+ const traceOutput = chunkTraceOutput(
180
187
  output,
181
188
  traceOutputDir,
189
+ relDir,
190
+ traceName,
182
191
  sourceFileNameForTrace(sourceFile),
183
192
  source,
184
193
  { chunkSize: DEFAULT_CHUNK_SIZE, ignoreFields },
185
194
  );
186
- for (const outputPath of getTraceOutputPaths(
187
- relDir,
188
- traceName,
189
- manifest.totalSteps,
190
- )) {
195
+ for (const outputPath of traceOutput.outputPaths) {
191
196
  dependencyCollector?.generatedOutputPaths?.add(outputPath);
192
197
  }
193
198
 
194
- const manifestUrl = buildManifestUrl({ relDir, traceName, applyBasePath });
199
+ const manifestUrl = buildManifestUrl({
200
+ relDir,
201
+ traceName,
202
+ artifactId: traceOutput.artifactId,
203
+ applyBasePath,
204
+ });
195
205
 
196
- const totalSteps = manifest.totalSteps;
206
+ const totalSteps = traceOutput.manifest.totalSteps;
197
207
  const mtime = fs.statSync(sourceFilePath).mtimeMs;
198
208
  cache.set(sourceFilePath, {
199
209
  manifestUrl,
210
+ artifactId: traceOutput.artifactId,
200
211
  highlightedSource,
201
212
  totalSteps,
202
213
  mtime,
203
214
  });
204
- return { manifestUrl, highlightedSource, totalSteps, mtime };
215
+ return {
216
+ manifestUrl,
217
+ artifactId: traceOutput.artifactId,
218
+ highlightedSource,
219
+ totalSteps,
220
+ mtime,
221
+ };
205
222
  }
206
223
 
207
224
  return {
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { bundle } from '../bundle';
3
+ import { bundle, getBundleNaming } from '../bundle';
4
4
  import { copyFonts } from '../generate-fonts';
5
5
  import { copyKatexAssets } from '../generate-katex-assets';
6
6
  import { generateFavicons } from '../generate-favicon';
@@ -42,7 +42,7 @@ export async function bundleWatchAssets(
42
42
  const reloadAssets = await Bun.build({
43
43
  entrypoints: [RELOAD_CLIENT_PATH],
44
44
  outdir: outputDir,
45
- naming: '[name].bundle.[ext]',
45
+ naming: getBundleNaming(),
46
46
  sourcemap: 'inline',
47
47
  });
48
48
  return [
@@ -6,6 +6,7 @@ import type {
6
6
 
7
7
  export interface TraceCacheEntry {
8
8
  manifestUrl: string;
9
+ artifactId: string;
9
10
  highlightedSource: string;
10
11
  totalSteps: number;
11
12
  mtime: number;
@@ -18,6 +18,6 @@ def selection(nums):
18
18
  swap(nums, i, index_smallest(nums, i))
19
19
 
20
20
 
21
- input1 = [4, 9, 2, 10, 19, 5, 7]
21
+ input1 = [25, 31, 12, 9]
22
22
  selection(input1)
23
23
  print(input1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.13.2",
3
+ "version": "1.13.7",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
package/src/_base.scss CHANGED
@@ -38,12 +38,13 @@ body {
38
38
  * Enable Inter's features:
39
39
  * liga - standard ligatures
40
40
  * calt - contextual alternates
41
- * ss03 - round quotes & commas (legibility)
42
- * cv01 - alternate 1 (style)
43
- * cv05 - lower case L with tail (legibility)
44
- * cv10 - capital G with spur (style)
41
+ * ss07 - square punctuation
42
+ * ss08 - square quotes
43
+ * cv01 - alternate 1
44
+ * cv08 - uppercase I with serif
45
+ * cv10 - capital G with spur
45
46
  */
46
- font-feature-settings: 'liga', 'calt', 'ss03', 'cv01', 'cv05', 'cv10';
47
+ font-feature-settings: 'liga', 'calt', 'ss07', 'ss08', 'cv01', 'cv08', 'cv10';
47
48
  line-height: var(--line-height);
48
49
  color: var(--fg-color);
49
50
  background-color: var(--bg-color);
@@ -180,6 +181,73 @@ input[type='search'] {
180
181
  }
181
182
  }
182
183
 
184
+ label:has(> input[type='checkbox']) {
185
+ display: inline-flex;
186
+ gap: 0.45em;
187
+ align-items: center;
188
+ font-size: var(--font-size-small);
189
+ font-weight: 500;
190
+
191
+ @include user-select(none);
192
+ }
193
+
194
+ input[type='checkbox'] {
195
+ display: inline-grid;
196
+ flex: 0 0 auto;
197
+ place-content: center;
198
+ width: 1.5em;
199
+ height: 1.25em;
200
+ padding: 0;
201
+ margin: 0;
202
+ font: inherit;
203
+ color: var(--fg-color);
204
+ appearance: none;
205
+ cursor: pointer;
206
+ background: var(--bg-color);
207
+ border: var(--control-border-width) solid var(--fg2-color);
208
+ border-radius: 0.25em;
209
+
210
+ &::after {
211
+ display: block;
212
+ width: 1.1em;
213
+ height: 1.1em;
214
+ content: '';
215
+ background-color: currentcolor;
216
+ opacity: 0;
217
+
218
+ @include contained-mask(var(--icon-check));
219
+ }
220
+
221
+ &:checked {
222
+ &::after {
223
+ opacity: 1;
224
+ }
225
+ }
226
+
227
+ &:disabled {
228
+ cursor: not-allowed;
229
+ opacity: 0.4;
230
+ }
231
+
232
+ &:active:not(:disabled) {
233
+ color: var(--bg-color) !important;
234
+ background: var(--link-active-color) !important;
235
+ }
236
+
237
+ &:focus {
238
+ outline: 2px solid var(--theme-color);
239
+ outline-offset: 1px;
240
+ background-color: var(--bg2-color);
241
+ border-color: var(--fg-color);
242
+ }
243
+ }
244
+
245
+ @media (hover: hover) {
246
+ input[type='checkbox']:hover:not(:disabled) {
247
+ background: var(--bg2-color);
248
+ }
249
+ }
250
+
183
251
  figure {
184
252
  margin: 1rem 0;
185
253
 
@@ -4,3 +4,12 @@ Append a link to each heading having an `id` attribute.
4
4
 
5
5
  When the link is clicked, `window.location` is updated and the heading is
6
6
  highlighted using the same function as the `toc` component.
7
+
8
+ The heading link includes a real inline SVG hash icon, revealed on heading
9
+ hover or link focus.
10
+
11
+ For slide title headings (`div.slide > h2:first-child`), append a
12
+ `Present from this slide` icon button. Clicking the button dispatches a
13
+ bubbling `tada:slides-present` custom event with the slide index so the slides
14
+ component can open presentation mode from that slide using the current
15
+ `Full screen` checkbox state.
@@ -2,32 +2,131 @@ function getElements(parent: HTMLElement): HTMLHeadingElement[] {
2
2
  return Array.from(parent.querySelectorAll('h1, h2, h3, h4, h5, h6'));
3
3
  }
4
4
 
5
+ function createIcon(window: Window, type: 'hash' | 'present'): SVGSVGElement {
6
+ const svg = window.document.createElementNS(
7
+ 'http://www.w3.org/2000/svg',
8
+ 'svg',
9
+ );
10
+ svg.setAttribute('aria-hidden', 'true');
11
+ svg.setAttribute('viewBox', '0 0 24 24');
12
+ svg.setAttribute('width', '16');
13
+ svg.setAttribute('height', '16');
14
+ svg.setAttribute('fill', 'none');
15
+ svg.setAttribute('stroke', 'currentColor');
16
+ svg.setAttribute('stroke-width', '1.5');
17
+ svg.setAttribute('stroke-linecap', 'round');
18
+ svg.setAttribute('stroke-linejoin', 'round');
19
+
20
+ const paths =
21
+ type === 'hash'
22
+ ? ['M10 3 8 21', 'M16 3l-2 18', 'M4 9h17', 'M3 15h17']
23
+ : [
24
+ 'M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z',
25
+ 'M9 20h6',
26
+ ];
27
+
28
+ for (const d of paths) {
29
+ const path = window.document.createElementNS(
30
+ 'http://www.w3.org/2000/svg',
31
+ 'path',
32
+ );
33
+ path.setAttribute('d', d);
34
+ svg.appendChild(path);
35
+ }
36
+
37
+ return svg;
38
+ }
39
+
40
+ function getSlideIndex(slide: HTMLElement): number {
41
+ const rawIndex = slide.getAttribute('data-slide-index');
42
+ if (rawIndex != null) {
43
+ const parsed = Number.parseInt(rawIndex, 10);
44
+ if (Number.isFinite(parsed)) {
45
+ return parsed;
46
+ }
47
+ }
48
+
49
+ const root = slide.closest('[data-slides-root]');
50
+ if (!root) {
51
+ return 0;
52
+ }
53
+
54
+ return Array.from(root.querySelectorAll('.slide')).indexOf(slide);
55
+ }
56
+
57
+ function isSlideTitleHeading(
58
+ heading: HTMLHeadingElement,
59
+ ): heading is HTMLHeadingElement & { parentElement: HTMLElement } {
60
+ const slide = heading.parentElement;
61
+ return (
62
+ heading.tagName === 'H2' &&
63
+ slide?.tagName === 'DIV' &&
64
+ slide.classList.contains('slide') &&
65
+ slide.firstElementChild === heading
66
+ );
67
+ }
68
+
5
69
  export default (window: Window) => {
70
+ const view = window as Window & typeof globalThis;
6
71
  const elements = getElements(window.document.body);
7
- if (elements == null) {
8
- return;
9
- }
72
+ const cleanups: (() => void)[] = [];
10
73
 
11
74
  elements.forEach(el => {
12
- if (!el.id) {
75
+ if (el.id) {
76
+ const link = window.document.createElement('a');
77
+ link.className = 'heading-anchor';
78
+ link.href = `#${el.id}`;
79
+ link.title = 'Link to this heading';
80
+
81
+ // Move all existing child nodes into the link.
82
+ while (el.firstChild) {
83
+ link.appendChild(el.firstChild);
84
+ }
85
+ link.appendChild(createIcon(window, 'hash'));
86
+ el.appendChild(link);
87
+
88
+ const handleClick = () => {
89
+ el.focus();
90
+ };
91
+ link.addEventListener('click', handleClick);
92
+ cleanups.push(() => {
93
+ link.removeEventListener('click', handleClick);
94
+ });
95
+ }
96
+
97
+ if (!isSlideTitleHeading(el)) {
13
98
  return;
14
99
  }
15
100
 
16
- const link = window.document.createElement('a');
17
- link.className = 'heading-anchor';
18
- link.href = `#${el.id}`;
19
- link.title = 'Link to this heading';
101
+ const button = window.document.createElement('button');
102
+ button.type = 'button';
103
+ button.className = 'heading-present-button';
104
+ button.setAttribute('aria-label', 'Present from this slide');
105
+ button.title = 'Present from this slide';
106
+ button.appendChild(createIcon(window, 'present'));
107
+ el.appendChild(button);
20
108
 
21
- // Move all existing child nodes into the link
22
- while (el.firstChild) {
23
- link.appendChild(el.firstChild);
24
- }
25
- el.appendChild(link);
109
+ const slide = el.parentElement;
110
+ const slideIndex = getSlideIndex(slide);
26
111
 
27
- link.addEventListener('click', () => {
28
- el.focus();
112
+ const handlePresentClick = () => {
113
+ button.dispatchEvent(
114
+ new view.CustomEvent('tada:slides-present', {
115
+ bubbles: true,
116
+ detail: { slideIndex },
117
+ }),
118
+ );
119
+ };
120
+ button.addEventListener('click', handlePresentClick);
121
+
122
+ cleanups.push(() => {
123
+ button.removeEventListener('click', handlePresentClick);
29
124
  });
30
125
  });
31
126
 
32
- return () => {};
127
+ return () => {
128
+ for (const cleanup of cleanups) {
129
+ cleanup();
130
+ }
131
+ };
33
132
  };
@@ -7,29 +7,89 @@ a.heading-anchor {
7
7
  @include subdued-link;
8
8
  }
9
9
 
10
- a.heading-anchor::after {
10
+ .heading-anchor svg,
11
+ .heading-present-button svg {
11
12
  display: inline-block;
12
13
  width: 1em;
13
14
  height: 1em;
15
+ vertical-align: middle;
16
+ }
17
+
18
+ .heading-anchor svg {
14
19
  margin-left: 4px;
15
20
  vertical-align: middle;
16
- content: '';
17
21
  opacity: 0;
22
+ }
18
23
 
19
- @include contained-mask(var(--icon-anchor));
24
+ .heading-present-button {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ width: 1em;
29
+ height: 1em;
30
+ padding: 0;
31
+ margin-left: 4px;
32
+ font: inherit;
33
+ line-height: 1;
34
+ vertical-align: middle;
35
+ color: var(--fg-color);
36
+ background: transparent;
37
+ border: none;
38
+ border-radius: 0;
39
+ opacity: 0;
20
40
  }
21
41
 
22
- a.heading-anchor:focus-visible::after {
42
+ a.heading-anchor:focus-visible svg,
43
+ .heading-present-button:focus {
23
44
  opacity: 1;
24
45
  }
25
46
 
47
+ .heading-present-button:focus {
48
+ outline-offset: 2px;
49
+ background-color: transparent;
50
+ border-color: transparent;
51
+ }
52
+
53
+ button.heading-present-button:active:not(:disabled) {
54
+ color: var(--link-active-color) !important;
55
+ background: transparent !important;
56
+ }
57
+
58
+ body.is-presenting .heading-present-button {
59
+ display: none;
60
+ }
61
+
26
62
  @media (hover: hover) {
27
- h1:hover a.heading-anchor::after,
28
- h2:hover a.heading-anchor::after,
29
- h3:hover a.heading-anchor::after,
30
- h4:hover a.heading-anchor::after,
31
- h5:hover a.heading-anchor::after,
32
- h6:hover a.heading-anchor::after {
63
+ button.heading-present-button:hover:not(:disabled) {
64
+ color: var(--fg2-color);
65
+ background: transparent;
66
+ }
67
+
68
+ h1:hover a.heading-anchor svg,
69
+ h2:hover a.heading-anchor svg,
70
+ h3:hover a.heading-anchor svg,
71
+ h4:hover a.heading-anchor svg,
72
+ h5:hover a.heading-anchor svg,
73
+ h6:hover a.heading-anchor svg,
74
+ h1:hover .heading-present-button,
75
+ h2:hover .heading-present-button,
76
+ h3:hover .heading-present-button,
77
+ h4:hover .heading-present-button,
78
+ h5:hover .heading-present-button,
79
+ h6:hover .heading-present-button {
33
80
  opacity: 1;
34
81
  }
35
82
  }
83
+
84
+ @media (hover: none) {
85
+ .heading-present-button {
86
+ display: none;
87
+ }
88
+ }
89
+
90
+ @media print {
91
+ .heading-anchor svg,
92
+ .heading-present-button {
93
+ display: none;
94
+ }
95
+ }
@@ -82,10 +82,6 @@
82
82
  border: none;
83
83
  }
84
84
 
85
- a.heading-anchor::after {
86
- display: none;
87
- }
88
-
89
85
  .external-link-tail::after,
90
86
  a.external:not(:has(.external-link-tail))::after {
91
87
  content: none;
@@ -5,6 +5,12 @@ interface TraceToolbarState {
5
5
 
6
6
  type PresentationMode = 'normal' | 'fullscreen';
7
7
 
8
+ interface SlidesPresentDetail {
9
+ slideIndex?: number;
10
+ }
11
+
12
+ const FULLSCREEN_STORAGE_KEY = 'slidesFullscreen';
13
+
8
14
  function isInteractive(
9
15
  view: Window & typeof globalThis,
10
16
  target: EventTarget | null,
@@ -45,17 +51,17 @@ export default function mountSlides(window: Window): void | (() => void) {
45
51
  const presentButton = document.querySelector(
46
52
  '[data-slides-present]',
47
53
  ) as HTMLButtonElement | null;
48
- const presentFullscreenButton = document.querySelector(
49
- '[data-slides-present-fullscreen]',
50
- ) as HTMLButtonElement | null;
51
- const rootScopeButton = presentButton ?? presentFullscreenButton;
54
+ const fullscreenCheckbox = document.querySelector(
55
+ '[data-slides-fullscreen]',
56
+ ) as HTMLInputElement | null;
57
+ const rootScopeButton = presentButton ?? fullscreenCheckbox;
52
58
  const scopedRoot = rootScopeButton
53
59
  ?.closest('main')
54
60
  ?.querySelector('[data-slides-root]');
55
61
  const root = (scopedRoot ??
56
62
  document.querySelector('[data-slides-root]')) as HTMLElement | null;
57
63
 
58
- if (!view || !root || (!presentButton && !presentFullscreenButton)) {
64
+ if (!view || !root) {
59
65
  return;
60
66
  }
61
67
 
@@ -78,8 +84,18 @@ export default function mountSlides(window: Window): void | (() => void) {
78
84
  if (presentButton) {
79
85
  presentButton.disabled = false;
80
86
  }
81
- if (presentFullscreenButton) {
82
- presentFullscreenButton.disabled = false;
87
+ if (fullscreenCheckbox) {
88
+ try {
89
+ const stored = window.localStorage.getItem(FULLSCREEN_STORAGE_KEY);
90
+ if (stored === 'false') {
91
+ fullscreenCheckbox.checked = false;
92
+ } else if (stored === 'true') {
93
+ fullscreenCheckbox.checked = true;
94
+ }
95
+ } catch {
96
+ // ignored
97
+ }
98
+ fullscreenCheckbox.disabled = false;
83
99
  }
84
100
 
85
101
  const overlay = document.createElement('div');
@@ -252,7 +268,7 @@ export default function mountSlides(window: Window): void | (() => void) {
252
268
  }
253
269
  }
254
270
 
255
- function enterPresentation(mode: PresentationMode): void {
271
+ function enterPresentation(mode: PresentationMode, slideIndex = 0): void {
256
272
  isPresenting = true;
257
273
  presentationMode = mode;
258
274
  hidePresentationControls();
@@ -260,8 +276,8 @@ export default function mountSlides(window: Window): void | (() => void) {
260
276
  document.body.classList.add('is-presenting');
261
277
  setTraceToolbarVisibility(true);
262
278
  resetReadyTraces();
263
- setActiveSlide(0);
264
- focusSlide(slides[0]);
279
+ setActiveSlide(slideIndex);
280
+ focusSlide(slides[activeIndex]);
265
281
  }
266
282
 
267
283
  function driveTrace(delta: 1 | -1): boolean {
@@ -329,12 +345,8 @@ export default function mountSlides(window: Window): void | (() => void) {
329
345
  focusSlide(slides[activeIndex]);
330
346
  }
331
347
 
332
- function handlePresentClick(): void {
333
- enterPresentation('normal');
334
- }
335
-
336
- async function handlePresentFullscreenClick(): Promise<void> {
337
- enterPresentation('fullscreen');
348
+ async function handlePresentFullscreenClick(slideIndex = 0): Promise<void> {
349
+ enterPresentation('fullscreen', slideIndex);
338
350
 
339
351
  if (typeof fullscreenTarget.requestFullscreen !== 'function') {
340
352
  presentationMode = 'normal';
@@ -348,14 +360,37 @@ export default function mountSlides(window: Window): void | (() => void) {
348
360
  }
349
361
  }
350
362
 
351
- function handlePresentFullscreenButtonClick(): void {
352
- void handlePresentFullscreenClick();
363
+ function startPresentation(slideIndex = 0): void {
364
+ if (fullscreenCheckbox?.checked === true) {
365
+ void handlePresentFullscreenClick(slideIndex);
366
+ return;
367
+ }
368
+
369
+ enterPresentation('normal', slideIndex);
370
+ }
371
+
372
+ function handlePresentClick(): void {
373
+ startPresentation(0);
353
374
  }
354
375
 
355
376
  function handleCloseClick(): void {
356
377
  exitPresentation();
357
378
  }
358
379
 
380
+ function handleFullscreenPreferenceChange(): void {
381
+ if (!fullscreenCheckbox) {
382
+ return;
383
+ }
384
+ try {
385
+ window.localStorage.setItem(
386
+ FULLSCREEN_STORAGE_KEY,
387
+ String(fullscreenCheckbox.checked),
388
+ );
389
+ } catch {
390
+ // Ignore storage errors so the checkbox remains usable.
391
+ }
392
+ }
393
+
359
394
  function handleFullscreenChange(): void {
360
395
  if (
361
396
  isPresenting &&
@@ -439,29 +474,42 @@ export default function mountSlides(window: Window): void | (() => void) {
439
474
  }
440
475
  }
441
476
 
477
+ function handleSlidesPresent(event: Event): void {
478
+ const detail = (event as CustomEvent<SlidesPresentDetail>).detail ?? {};
479
+ const slideIndex =
480
+ typeof detail.slideIndex === 'number' &&
481
+ Number.isFinite(detail.slideIndex)
482
+ ? detail.slideIndex
483
+ : 0;
484
+
485
+ startPresentation(slideIndex);
486
+ }
487
+
442
488
  presentButton?.addEventListener('click', handlePresentClick);
443
- presentFullscreenButton?.addEventListener(
444
- 'click',
445
- handlePresentFullscreenButtonClick,
489
+ fullscreenCheckbox?.addEventListener(
490
+ 'change',
491
+ handleFullscreenPreferenceChange,
446
492
  );
447
493
  closeButton.addEventListener('click', handleCloseClick);
448
494
  document.addEventListener('fullscreenchange', handleFullscreenChange);
449
495
  window.addEventListener('keydown', handleKeydown);
450
496
  window.addEventListener('mousemove', handleMouseMove);
451
497
  slidesRoot.addEventListener('click', handleSlideClick);
498
+ slidesRoot.addEventListener('tada:slides-present', handleSlidesPresent);
452
499
 
453
500
  return () => {
454
501
  exitPresentation();
455
502
  presentButton?.removeEventListener('click', handlePresentClick);
456
- presentFullscreenButton?.removeEventListener(
457
- 'click',
458
- handlePresentFullscreenButtonClick,
503
+ fullscreenCheckbox?.removeEventListener(
504
+ 'change',
505
+ handleFullscreenPreferenceChange,
459
506
  );
460
507
  closeButton.removeEventListener('click', handleCloseClick);
461
508
  document.removeEventListener('fullscreenchange', handleFullscreenChange);
462
509
  window.removeEventListener('keydown', handleKeydown);
463
510
  window.removeEventListener('mousemove', handleMouseMove);
464
511
  slidesRoot.removeEventListener('click', handleSlideClick);
512
+ slidesRoot.removeEventListener('tada:slides-present', handleSlidesPresent);
465
513
  overlay.remove();
466
514
  };
467
515
  }
package/src/style.scss CHANGED
@@ -121,7 +121,7 @@ a.footnote-ref {
121
121
 
122
122
  a.footnote-ref,
123
123
  .footnote-marker {
124
- font-feature-settings: 'ss06';
124
+ font-feature-settings: 'ss06'; // squared numbers
125
125
  }
126
126
 
127
127
  .footnote-item {
@@ -11,7 +11,7 @@ interface WidgetElements {
11
11
  root: HTMLElement;
12
12
  source: HTMLElement;
13
13
  controls: HTMLElement;
14
- output: HTMLElement;
14
+ content: HTMLElement;
15
15
  diagram: HTMLElement;
16
16
  }
17
17
 
@@ -58,13 +58,20 @@ async function goToStep(
58
58
  }
59
59
 
60
60
  function updateSourceHighlight(panel: HTMLElement, currentLine: number): void {
61
- panel.querySelectorAll('.line-number').forEach(ln => {
62
- ln.classList.remove('trace-line-active');
63
- });
64
- const el = panel.querySelector(`.line-number[data-line="${currentLine}"]`);
65
- if (el) {
66
- el.classList.add('trace-line-active');
67
- const elRect = el.getBoundingClientRect();
61
+ panel
62
+ .querySelectorAll(
63
+ '.code-row.trace-line-active, .line-number.trace-line-active',
64
+ )
65
+ .forEach(el => {
66
+ el.classList.remove('trace-line-active');
67
+ });
68
+ const lineNumber = panel.querySelector(
69
+ `.line-number[data-line="${currentLine}"]`,
70
+ );
71
+ const row = lineNumber?.closest('.code-row') as HTMLElement | null;
72
+ if (row) {
73
+ row.classList.add('trace-line-active');
74
+ const elRect = row.getBoundingClientRect();
68
75
  const panelRect = panel.getBoundingClientRect();
69
76
  panel.scrollTop += elRect.top - panelRect.top - panel.clientHeight / 2;
70
77
  }
@@ -193,6 +200,27 @@ function scaleDiagramSvg(diagram: HTMLElement, doc: Document): void {
193
200
  svg.setAttribute('height', String(baseHeight * scale));
194
201
  }
195
202
 
203
+ function updateOutput(
204
+ content: HTMLElement,
205
+ output: string,
206
+ doc: Document,
207
+ ): void {
208
+ const existing = content.querySelector(
209
+ ':scope > .trace-output',
210
+ ) as HTMLPreElement | null;
211
+ if (!output) {
212
+ existing?.remove();
213
+ return;
214
+ }
215
+
216
+ const outputElement = existing ?? doc.createElement('pre');
217
+ if (!existing) {
218
+ outputElement.className = 'trace-output';
219
+ content.append(outputElement);
220
+ }
221
+ outputElement.textContent = output;
222
+ }
223
+
196
224
  function renderWidgetState(
197
225
  state: WidgetState,
198
226
  elements: WidgetElements,
@@ -215,7 +243,7 @@ function renderWidgetState(
215
243
  output += s.stdout;
216
244
  }
217
245
  }
218
- elements.output.textContent = output;
246
+ updateOutput(elements.content, output, doc);
219
247
 
220
248
  const prevHeight = elements.diagram.scrollHeight;
221
249
  elements.diagram.innerHTML = entry.svg;
@@ -249,12 +277,9 @@ async function initWidget(root: HTMLElement, doc: Document): Promise<void> {
249
277
 
250
278
  await loadChunk(state, manifestUrl, 0);
251
279
 
252
- const entry = getStep(state);
253
-
254
280
  const source = root.querySelector('.trace-source') as HTMLElement;
281
+ const content = root.querySelector('.trace-content') as HTMLElement;
255
282
  const diagram = root.querySelector('.trace-diagram') as HTMLElement;
256
- const output = root.querySelector('.trace-output') as HTMLPreElement;
257
- output.textContent = entry.stdout || '';
258
283
 
259
284
  const controls = root.querySelector('.trace-controls') as HTMLElement;
260
285
  const firstBtn = controls.querySelector('.trace-first') as HTMLButtonElement;
@@ -296,7 +321,7 @@ async function initWidget(root: HTMLElement, doc: Document): Promise<void> {
296
321
  next.focus();
297
322
  });
298
323
 
299
- const elements: WidgetElements = { root, source, controls, output, diagram };
324
+ const elements: WidgetElements = { root, source, controls, content, diagram };
300
325
 
301
326
  renderWidgetState(state, elements, doc);
302
327
  }
@@ -112,16 +112,17 @@
112
112
  }
113
113
  }
114
114
 
115
- .line-number.trace-line-active {
116
- font-weight: bold;
117
- color: var(--fg-color);
118
-
119
- &::before {
120
- position: absolute;
121
- left: 0;
122
- margin-right: 0.25em;
123
- line-height: 1.3;
124
- content: '\25B6';
115
+ .code-row.trace-line-active {
116
+ background: var(--bg-trace-line-active);
117
+
118
+ .line-number {
119
+ font-weight: bold;
120
+ color: var(--fg-color);
121
+ background: var(--bg-trace-line-active);
122
+ }
123
+
124
+ code {
125
+ background: none;
125
126
  }
126
127
  }
127
128
  }
@@ -32,6 +32,7 @@
32
32
  --bg2-color-translucent: hsl(
33
33
  var(--tint-hue) calc(12% * var(--tint-amount)) 92% / 66%
34
34
  );
35
+ --bg-trace-line-active: <%= bgTraceLineActive %>;
35
36
  --box-shadow: 0 2px 9px
36
37
  hsl(var(--tint-hue) calc(85% * var(--tint-amount)) 10% / 25%);
37
38
  --backdrop-filter: blur(10px);
@@ -58,6 +59,7 @@
58
59
  --icon-anchor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 28'><path d='M11 4 L8 20 M17 4 L14 20 M5 9 H21 M3 15 H19' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
59
60
  --icon-warning: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='black' stroke-width='1.5' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='black' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='black' stroke-width='2' stroke-linecap='round'/></svg>");
60
61
  --icon-note: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='black' stroke-width='1.5'/><line x1='12' y1='10' x2='12' y2='16' stroke='black' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='black' stroke-width='2' stroke-linecap='round'/></svg>");
62
+ --icon-check: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M5 12.5 10 17 19 7' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
61
63
  --icon-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><circle cx='11' cy='11' r='7' stroke='<%= iconColor %>' stroke-width='2' fill='none'/><line x1='16.5' y1='16.5' x2='22' y2='22' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
62
64
  --icon-chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><polyline points='9 18 15 12 9 6' stroke='<%= iconColor %>' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
63
65
  --icon-breadcrumb: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M3 7 H17 V22' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/><polyline points='11 16 17 22 23 16' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
@@ -86,6 +88,7 @@
86
88
  --bg2-color-translucent: hsl(
87
89
  var(--tint-hue) calc(12% * var(--tint-amount)) 12% / 66%
88
90
  );
91
+ --bg-trace-line-active: <%= bgTraceLineActiveDark %>;
89
92
  --box-shadow: 0 3px 8px
90
93
  hsl(var(--tint-hue) calc(33% * var(--tint-amount)) 8% / 70%);
91
94
  --link-active-color: #fff;
@@ -6,9 +6,10 @@
6
6
  <% if (page.slides) { %>
7
7
  <div class="slides-header" data-pagefind-ignore>
8
8
  <button type="button" data-slides-present disabled>Present</button>
9
- <button type="button" data-slides-present-fullscreen disabled>
10
- Present (Full Screen)
11
- </button>
9
+ <label>
10
+ <input id="slides-fullscreen" type="checkbox" data-slides-fullscreen checked disabled>
11
+ Full screen
12
+ </label>
12
13
  </div>
13
14
  <% } %>
14
15
  <%= content %>