thibaud frere commited on
Commit
c1d1666
·
1 Parent(s): 8e480d5
.gitignore CHANGED
@@ -22,4 +22,5 @@ node_modules/
22
  # PDF export
23
  app/public/*.pdf
24
  app/public/*.png
25
- app/public/*.jpg
 
 
22
  # PDF export
23
  app/public/*.pdf
24
  app/public/*.png
25
+ app/public/*.jpg
26
+ app/public/data/**/*
app/.astro/astro/content.d.ts CHANGED
@@ -1,206 +0,0 @@
1
- declare module 'astro:content' {
2
- interface Render {
3
- '.mdx': Promise<{
4
- Content: import('astro').MarkdownInstance<{}>['Content'];
5
- headings: import('astro').MarkdownHeading[];
6
- remarkPluginFrontmatter: Record<string, any>;
7
- components: import('astro').MDXInstance<{}>['components'];
8
- }>;
9
- }
10
- }
11
-
12
- declare module 'astro:content' {
13
- interface RenderResult {
14
- Content: import('astro/runtime/server/index.js').AstroComponentFactory;
15
- headings: import('astro').MarkdownHeading[];
16
- remarkPluginFrontmatter: Record<string, any>;
17
- }
18
- interface Render {
19
- '.md': Promise<RenderResult>;
20
- }
21
-
22
- export interface RenderedContent {
23
- html: string;
24
- metadata?: {
25
- imagePaths: Array<string>;
26
- [key: string]: unknown;
27
- };
28
- }
29
- }
30
-
31
- declare module 'astro:content' {
32
- type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
33
-
34
- export type CollectionKey = keyof AnyEntryMap;
35
- export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
36
-
37
- export type ContentCollectionKey = keyof ContentEntryMap;
38
- export type DataCollectionKey = keyof DataEntryMap;
39
-
40
- type AllValuesOf<T> = T extends any ? T[keyof T] : never;
41
- type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
42
- ContentEntryMap[C]
43
- >['slug'];
44
-
45
- /** @deprecated Use `getEntry` instead. */
46
- export function getEntryBySlug<
47
- C extends keyof ContentEntryMap,
48
- E extends ValidContentEntrySlug<C> | (string & {}),
49
- >(
50
- collection: C,
51
- // Note that this has to accept a regular string too, for SSR
52
- entrySlug: E,
53
- ): E extends ValidContentEntrySlug<C>
54
- ? Promise<CollectionEntry<C>>
55
- : Promise<CollectionEntry<C> | undefined>;
56
-
57
- /** @deprecated Use `getEntry` instead. */
58
- export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
59
- collection: C,
60
- entryId: E,
61
- ): Promise<CollectionEntry<C>>;
62
-
63
- export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
64
- collection: C,
65
- filter?: (entry: CollectionEntry<C>) => entry is E,
66
- ): Promise<E[]>;
67
- export function getCollection<C extends keyof AnyEntryMap>(
68
- collection: C,
69
- filter?: (entry: CollectionEntry<C>) => unknown,
70
- ): Promise<CollectionEntry<C>[]>;
71
-
72
- export function getEntry<
73
- C extends keyof ContentEntryMap,
74
- E extends ValidContentEntrySlug<C> | (string & {}),
75
- >(entry: {
76
- collection: C;
77
- slug: E;
78
- }): E extends ValidContentEntrySlug<C>
79
- ? Promise<CollectionEntry<C>>
80
- : Promise<CollectionEntry<C> | undefined>;
81
- export function getEntry<
82
- C extends keyof DataEntryMap,
83
- E extends keyof DataEntryMap[C] | (string & {}),
84
- >(entry: {
85
- collection: C;
86
- id: E;
87
- }): E extends keyof DataEntryMap[C]
88
- ? Promise<DataEntryMap[C][E]>
89
- : Promise<CollectionEntry<C> | undefined>;
90
- export function getEntry<
91
- C extends keyof ContentEntryMap,
92
- E extends ValidContentEntrySlug<C> | (string & {}),
93
- >(
94
- collection: C,
95
- slug: E,
96
- ): E extends ValidContentEntrySlug<C>
97
- ? Promise<CollectionEntry<C>>
98
- : Promise<CollectionEntry<C> | undefined>;
99
- export function getEntry<
100
- C extends keyof DataEntryMap,
101
- E extends keyof DataEntryMap[C] | (string & {}),
102
- >(
103
- collection: C,
104
- id: E,
105
- ): E extends keyof DataEntryMap[C]
106
- ? Promise<DataEntryMap[C][E]>
107
- : Promise<CollectionEntry<C> | undefined>;
108
-
109
- /** Resolve an array of entry references from the same collection */
110
- export function getEntries<C extends keyof ContentEntryMap>(
111
- entries: {
112
- collection: C;
113
- slug: ValidContentEntrySlug<C>;
114
- }[],
115
- ): Promise<CollectionEntry<C>[]>;
116
- export function getEntries<C extends keyof DataEntryMap>(
117
- entries: {
118
- collection: C;
119
- id: keyof DataEntryMap[C];
120
- }[],
121
- ): Promise<CollectionEntry<C>[]>;
122
-
123
- export function render<C extends keyof AnyEntryMap>(
124
- entry: AnyEntryMap[C][string],
125
- ): Promise<RenderResult>;
126
-
127
- export function reference<C extends keyof AnyEntryMap>(
128
- collection: C,
129
- ): import('astro/zod').ZodEffects<
130
- import('astro/zod').ZodString,
131
- C extends keyof ContentEntryMap
132
- ? {
133
- collection: C;
134
- slug: ValidContentEntrySlug<C>;
135
- }
136
- : {
137
- collection: C;
138
- id: keyof DataEntryMap[C];
139
- }
140
- >;
141
- // Allow generic `string` to avoid excessive type errors in the config
142
- // if `dev` is not running to update as you edit.
143
- // Invalid collection names will be caught at build time.
144
- export function reference<C extends string>(
145
- collection: C,
146
- ): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
147
-
148
- type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
149
- type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
150
- ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
151
- >;
152
-
153
- type ContentEntryMap = {
154
- "chapters": {
155
- "available-blocks.mdx": {
156
- id: "available-blocks.mdx";
157
- slug: "available-blocks";
158
- body: string;
159
- collection: "chapters";
160
- data: any
161
- } & { render(): Render[".mdx"] };
162
- "best-pratices.mdx": {
163
- id: "best-pratices.mdx";
164
- slug: "best-pratices";
165
- body: string;
166
- collection: "chapters";
167
- data: any
168
- } & { render(): Render[".mdx"] };
169
- "getting-started.mdx": {
170
- id: "getting-started.mdx";
171
- slug: "getting-started";
172
- body: string;
173
- collection: "chapters";
174
- data: any
175
- } & { render(): Render[".mdx"] };
176
- "writing-your-content.mdx": {
177
- id: "writing-your-content.mdx";
178
- slug: "writing-your-content";
179
- body: string;
180
- collection: "chapters";
181
- data: any
182
- } & { render(): Render[".mdx"] };
183
- };
184
-
185
- };
186
-
187
- type DataEntryMap = {
188
- "assets": {
189
- "data/mnist-variant-model": {
190
- id: "data/mnist-variant-model";
191
- collection: "assets";
192
- data: any
193
- };
194
- };
195
- "embeds": Record<string, {
196
- id: string;
197
- collection: "embeds";
198
- data: any;
199
- }>;
200
-
201
- };
202
-
203
- type AnyEntryMap = ContentEntryMap & DataEntryMap;
204
-
205
- export type ContentConfig = never;
206
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/astro.config.mjs CHANGED
@@ -151,6 +151,15 @@ export default defineConfig({
151
  devToolbar: {
152
  enabled: false
153
  },
 
 
 
 
 
 
 
 
 
154
  markdown: {
155
  shikiConfig: {
156
  themes: {
 
151
  devToolbar: {
152
  enabled: false
153
  },
154
+ // Expose a public flag to control TOC auto-collapse behavior at runtime
155
+ // This can be overridden via environment variable PUBLIC_TOC_AUTO_COLLAPSE
156
+ // (Vite define provides a default fallback when env is not set.)
157
+ vite: {
158
+ define: {
159
+ 'import.meta.env.PUBLIC_TOC_AUTO_COLLAPSE': JSON.stringify(false),
160
+ 'import.meta.env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE': JSON.stringify(false)
161
+ }
162
+ },
163
  markdown: {
164
  shikiConfig: {
165
  themes: {
app/public/scripts/color-palettes.js ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Global color palettes generator and watcher
2
+ // - Observes CSS variable --primary-color and theme changes
3
+ // - Generates categorical, sequential, and diverging palettes (OKLCH/OKLab)
4
+ // - Exposes results as CSS variables on :root
5
+ // - Supports variable color counts per palette via CSS vars
6
+ // - Dispatches a 'palettes:updated' CustomEvent after each update
7
+
8
+ (() => {
9
+ const MODE = { cssRoot: document.documentElement };
10
+
11
+ const getCssVar = (name) => {
12
+ try { return getComputedStyle(MODE.cssRoot).getPropertyValue(name).trim(); } catch { return ''; }
13
+ };
14
+ const getIntFromCssVar = (name, fallback) => {
15
+ const raw = getCssVar(name);
16
+ if (!raw) return fallback;
17
+ const v = parseInt(String(raw), 10);
18
+ if (Number.isNaN(v)) return fallback;
19
+ return v;
20
+ };
21
+ const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
22
+
23
+ // Color math (OKLab/OKLCH)
24
+ const srgbToLinear = (u) => (u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4));
25
+ const linearToSrgb = (u) => (u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055);
26
+ const rgbToOklab = (r, g, b) => {
27
+ const rl = srgbToLinear(r), gl = srgbToLinear(g), bl = srgbToLinear(b);
28
+ const l = Math.cbrt(0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl);
29
+ const m = Math.cbrt(0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl);
30
+ const s = Math.cbrt(0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl);
31
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
32
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
33
+ const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
34
+ return { L, a, b: b2 };
35
+ };
36
+ const oklabToRgb = (L, a, b) => {
37
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
38
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
39
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
40
+ const l = l_ * l_ * l_;
41
+ const m = m_ * m_ * m_;
42
+ const s = s_ * s_ * s_;
43
+ const r = linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
44
+ const g = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
45
+ const b3 = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s);
46
+ return { r, g, b: b3 };
47
+ };
48
+ const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
49
+ const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a*a + b*b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
50
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
51
+ const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
52
+ const toHex = ({ r, g, b }) => { const R = Math.round(clamp01(r)*255), G = Math.round(clamp01(g)*255), B = Math.round(clamp01(b)*255); const h = (n) => n.toString(16).padStart(2,'0'); return `#${h(R)}${h(G)}${h(B)}`.toUpperCase(); };
53
+ const oklchToHexSafe = (L, C, h) => { let c = C; for (let i=0;i<12;i++){ const { a, b } = oklchToOklab(L,c,h); const rgb = oklabToRgb(L,a,b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c-0.02);} return toHex(oklabToRgb(L,0,0)); };
54
+ const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1])/255, g: Number(m[2])/255, b: Number(m[3])/255 }; } catch { return null; } };
55
+
56
+ const getPrimaryHex = () => {
57
+ const css = getCssVar('--primary-color');
58
+ if (!css) return '#E889AB';
59
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(css)) return css.toUpperCase();
60
+ const rgb = parseCssColorToRgb(css);
61
+ if (rgb) return toHex(rgb);
62
+ return '#E889AB';
63
+ };
64
+ const getCounts = () => {
65
+ const Fallback = 6;
66
+ const globalCount = clamp(getIntFromCssVar('--palette-count', Fallback), 1, 12);
67
+ return {
68
+ categorical: clamp(getIntFromCssVar('--palette-categorical-count', globalCount), 1, 12),
69
+ sequential: clamp(getIntFromCssVar('--palette-sequential-count', globalCount), 1, 12),
70
+ diverging: clamp(getIntFromCssVar('--palette-diverging-count', globalCount), 1, 12),
71
+ };
72
+ };
73
+
74
+ const generators = {
75
+ categorical: (baseHex, count) => {
76
+ const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
77
+ const { r, g, b } = parseHex(baseHex);
78
+ const { L, a, b: bb } = rgbToOklab(r,g,b);
79
+ const { C, h } = oklabToOklch(L,a,bb);
80
+ const L0 = Math.min(0.85, Math.max(0.4, L));
81
+ const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
82
+ const total = Math.max(1, Math.min(12, count || 6));
83
+ const hueStep = 360 / total;
84
+ const results = [];
85
+ for (let i=0;i<total;i++) { const hDeg = (h + i*hueStep) % 360; const lVar = ((i % 3) - 1) * 0.04; results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg)); }
86
+ return results;
87
+ },
88
+ sequential: (baseHex, count) => {
89
+ const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
90
+ const { r, g, b } = parseHex(baseHex);
91
+ const { L, a, b: bb } = rgbToOklab(r,g,b);
92
+ const { C, h } = oklabToOklch(L,a,bb);
93
+ const total = Math.max(1, Math.min(12, count || 6));
94
+ const startL = Math.max(0.25, L - 0.18);
95
+ const endL = Math.min(0.92, L + 0.18);
96
+ const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
97
+ const out = [];
98
+ for (let i=0;i<total;i++) { const t = total===1 ? 0 : i/(total-1); const lNow = startL*(1-t)+endL*t; const cNow = cBase*(0.85 + 0.15*(1 - Math.abs(0.5 - t)*2)); out.push(oklchToHexSafe(lNow, cNow, h)); }
99
+ return out;
100
+ },
101
+ diverging: (baseHex, count) => {
102
+ const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
103
+ const { r, g, b } = parseHex(baseHex);
104
+ const baseLab = rgbToOklab(r,g,b);
105
+ const baseLch = oklabToOklch(baseLab.L, baseLab.a, baseLab.b);
106
+ const total = Math.max(1, Math.min(12, count || 6));
107
+
108
+ // Left endpoint: EXACT primary color (no darkening)
109
+ const leftLab = baseLab;
110
+ // Right endpoint: complement with same L and similar C (clamped safe)
111
+ const compH = (baseLch.h + 180) % 360;
112
+ const cSafe = Math.min(0.35, Math.max(0.08, baseLch.C));
113
+ const rightLab = oklchToOklab(baseLab.L, cSafe, compH);
114
+ const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
115
+
116
+ const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
117
+ const lerp = (a, b, t) => a + (b - a) * t;
118
+ const lerpOKLabHex = (A, B, t) => hexFromOKLab(lerp(A.L, B.L, t), lerp(A.a, B.a, t), lerp(A.b, B.b, t));
119
+
120
+ const out = [];
121
+ if (total % 2 === 1) {
122
+ const nSide = (total - 1) >> 1; // items on each side
123
+ // Left side: include left endpoint exactly at index 0
124
+ for (let i = 0; i < nSide; i++) {
125
+ const t = nSide <= 1 ? 0 : (i / (nSide - 1)); // 0 .. 1
126
+ // Move from leftLab to a value close (but not equal) to white; ensure last before center is lighter
127
+ const tt = t * 0.9; // keep some distance from pure white before center
128
+ out.push(lerpOKLabHex(leftLab, whiteLab, tt));
129
+ }
130
+ // Center
131
+ out.push(hexFromOKLab(whiteLab.L, whiteLab.a, whiteLab.b));
132
+ // Right side: start near white and end EXACTLY at rightLab
133
+ for (let i = 0; i < nSide; i++) {
134
+ const t = nSide <= 1 ? 1 : ((i + 1) / nSide); // (1/n)..1
135
+ const tt = Math.max(0.1, t); // avoid starting at pure white
136
+ out.push(lerpOKLabHex(whiteLab, rightLab, tt));
137
+ }
138
+ // Ensure first and last are exact endpoints
139
+ if (out.length) { out[0] = hexFromOKLab(leftLab.L, leftLab.a, leftLab.b); out[out.length - 1] = hexFromOKLab(rightLab.L, rightLab.a, rightLab.b); }
140
+ } else {
141
+ const nSide = total >> 1;
142
+ // Left half including left endpoint, approaching white but not reaching it
143
+ for (let i = 0; i < nSide; i++) {
144
+ const t = nSide <= 1 ? 0 : (i / (nSide - 1)); // 0 .. 1
145
+ const tt = t * 0.9;
146
+ out.push(lerpOKLabHex(leftLab, whiteLab, tt));
147
+ }
148
+ // Right half: mirror from near white to exact right endpoint
149
+ for (let i = 0; i < nSide; i++) {
150
+ const t = nSide <= 1 ? 1 : ((i + 1) / nSide); // (1/n)..1
151
+ const tt = Math.max(0.1, t);
152
+ out.push(lerpOKLabHex(whiteLab, rightLab, tt));
153
+ }
154
+ if (out.length) { out[0] = hexFromOKLab(leftLab.L, leftLab.a, leftLab.b); out[out.length - 1] = hexFromOKLab(rightLab.L, rightLab.a, rightLab.b); }
155
+ }
156
+ return out;
157
+ }
158
+ };
159
+
160
+ const setCssVar = (name, value) => { try { MODE.cssRoot.style.setProperty(name, value); } catch {} };
161
+ const removeCssVar = (name) => { try { MODE.cssRoot.style.removeProperty(name); } catch {} };
162
+
163
+ let lastSignature = '';
164
+ let lastCounts = { categorical: 0, sequential: 0, diverging: 0 };
165
+
166
+ const updatePalettes = () => {
167
+ const primary = getPrimaryHex();
168
+ const counts = getCounts();
169
+ const signature = `${primary}|${counts.categorical}|${counts.sequential}|${counts.diverging}`;
170
+ if (signature === lastSignature) return;
171
+
172
+ const out = {};
173
+ out.categorical = generators.categorical(primary, counts.categorical);
174
+ out.sequential = generators.sequential(primary, counts.sequential);
175
+ out.diverging = generators.diverging(primary, counts.diverging);
176
+
177
+ setCssVar('--primary-hex', primary);
178
+ setCssVar('--palette-categorical-count-current', String(out.categorical.length));
179
+ setCssVar('--palette-sequential-count-current', String(out.sequential.length));
180
+ setCssVar('--palette-diverging-count-current', String(out.diverging.length));
181
+
182
+ const applyList = (key, list, prevCount) => {
183
+ for (let i=0;i<list.length;i++) setCssVar(`--palette-${key}-${i+1}`, list[i]);
184
+ for (let i=list.length;i<prevCount;i++) removeCssVar(`--palette-${key}-${i+1}`);
185
+ setCssVar(`--palette-${key}-json`, JSON.stringify(list));
186
+ };
187
+ applyList('categorical', out.categorical, lastCounts.categorical);
188
+ applyList('sequential', out.sequential, lastCounts.sequential);
189
+ applyList('diverging', out.diverging, lastCounts.diverging);
190
+
191
+ lastCounts = { categorical: out.categorical.length, sequential: out.sequential.length, diverging: out.diverging.length };
192
+ lastSignature = signature;
193
+
194
+ try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary, counts, palettes: out } })); } catch {}
195
+ };
196
+
197
+ const bootstrap = () => {
198
+ updatePalettes();
199
+ const mo = new MutationObserver(() => updatePalettes());
200
+ mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
201
+ setInterval(updatePalettes, 400);
202
+ window.ColorPalettes = {
203
+ refresh: updatePalettes,
204
+ getColors: (key) => {
205
+ const count = Number(getCssVar(`--palette-${key}-count-current`)) || 0;
206
+ const arr = [];
207
+ for (let i=0;i<count;i++) arr.push(getCssVar(`--palette-${key}-${i+1}`));
208
+ return arr.filter(Boolean);
209
+ }
210
+ };
211
+ };
212
+
213
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
214
+ else bootstrap();
215
+ })();
216
+
217
+
app/src/components/HtmlEmbed.astro CHANGED
@@ -31,6 +31,12 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
31
  <div><!-- Fragment not found: {src} --></div>
32
  ) }
33
 
 
 
 
 
 
 
34
  <script>
35
  // Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
36
  const scriptEl = document.currentScript;
@@ -45,7 +51,7 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
45
  old.dataset.executed = 'true';
46
  if (old.src) {
47
  const s = document.createElement('script');
48
- Array.from(old.attributes).forEach(({ name, value }) => s.setAttribute(name, value));
49
  document.body.appendChild(s);
50
  } else {
51
  try {
@@ -57,10 +63,9 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
57
  }
58
  });
59
  };
60
- // Ensure execution when ready: run now if Plotly or D3 is present, or when document is ready; otherwise wait for 'load'
61
- // @ts-expect-error: Plotly/d3 are attached globally at runtime via embeds
62
- if (window.Plotly || window.d3 || document.readyState === 'complete') execute();
63
- else window.addEventListener('load', execute, { once: true });
64
  </script>
65
 
66
  <style>
 
31
  <div><!-- Fragment not found: {src} --></div>
32
  ) }
33
 
34
+ <script type="module" is:inline>
35
+ // Ensure global color palettes generator is loaded once per page
36
+ import '../scripts/color-palettes.js';
37
+ export {};
38
+ </script>
39
+
40
  <script>
41
  // Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
42
  const scriptEl = document.currentScript;
 
51
  old.dataset.executed = 'true';
52
  if (old.src) {
53
  const s = document.createElement('script');
54
+ Array.from(old.attributes).forEach(attr => s.setAttribute(attr.name, attr.value));
55
  document.body.appendChild(s);
56
  } else {
57
  try {
 
63
  }
64
  });
65
  };
66
+ // Execute after DOM is parsed (ensures deferred module scripts are executed first)
67
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', execute, { once: true });
68
+ else execute();
 
69
  </script>
70
 
71
  <style>
app/src/components/Note.astro CHANGED
@@ -1,6 +1,12 @@
1
  ---
2
- const { title, emoji, class: className, ...props } = Astro.props;
3
- const wrapperClass = ["note", className].filter(Boolean).join(" ");
 
 
 
 
 
 
4
  const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
5
  ---
6
  <div class={wrapperClass} {...props}>
@@ -21,5 +27,11 @@ const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title)
21
  .note__content { color: var(--text-color); font-size: 0.95rem; }
22
  /* Ensure the very last slotted element has no bottom margin */
23
  .note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
 
 
 
 
 
 
24
  </style>
25
 
 
1
  ---
2
+ interface Props {
3
+ title?: string;
4
+ emoji?: string;
5
+ class?: string;
6
+ variant?: 'neutral' | 'info' | 'success' | 'danger';
7
+ }
8
+ const { title, emoji, class: className, variant = 'neutral', ...props } = Astro.props as Props;
9
+ const wrapperClass = ["note", `note--${variant}`, className].filter(Boolean).join(" ");
10
  const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
11
  ---
12
  <div class={wrapperClass} {...props}>
 
27
  .note__content { color: var(--text-color); font-size: 0.95rem; }
28
  /* Ensure the very last slotted element has no bottom margin */
29
  .note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
30
+
31
+ /* Variants */
32
+ .note.note--neutral { border-left-color: var(--border-color); background: var(--surface-bg); }
33
+ .note.note--info { border-left-color: #f39c12; background: color-mix(in oklab, #f39c12 10%, var(--surface-bg)); }
34
+ .note.note--success { border-left-color: #2ecc71; background: color-mix(in oklab, #2ecc71 8%, var(--surface-bg)); }
35
+ .note.note--danger { border-left-color: #e74c3c; background: color-mix(in oklab, #e74c3c 8%, var(--surface-bg)); }
36
  </style>
37
 
app/src/components/ResponsiveImage.astro CHANGED
@@ -11,28 +11,157 @@ interface Props {
11
  caption?: string;
12
  /** Optional class to apply on the <figure> wrapper when caption is used */
13
  figureClass?: string;
 
 
 
 
 
 
 
 
14
  /** Any additional attributes should be forwarded to the underlying <Image> */
15
  [key: string]: any;
16
  }
17
 
18
- const { caption, figureClass, ...imgProps } = Astro.props as Props;
19
  const hasCaptionSlot = Astro.slots.has('caption');
20
  const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
 
 
 
21
  ---
22
-
23
- {hasCaption ? (
24
- <figure class={figureClass}>
25
- <Image {...imgProps} />
26
- <figcaption>
27
- {hasCaptionSlot ? (
28
- <slot name="caption" />
 
 
 
29
  ) : (
30
- caption && <span set:html={caption} />
31
  )}
32
- </figcaption>
33
- </figure>
34
- ) : (
35
- <Image {...imgProps} />
36
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
 
 
11
  caption?: string;
12
  /** Optional class to apply on the <figure> wrapper when caption is used */
13
  figureClass?: string;
14
+ /** Enable medium-zoom behavior on this image */
15
+ zoomable?: boolean;
16
+ /** Show a download button overlay and enable download flow */
17
+ downloadable?: boolean;
18
+ /** Optional explicit file name to use on download */
19
+ downloadName?: string;
20
+ /** Optional explicit source URL to download instead of currentSrc */
21
+ downloadSrc?: string;
22
  /** Any additional attributes should be forwarded to the underlying <Image> */
23
  [key: string]: any;
24
  }
25
 
26
+ const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, ...imgProps } = Astro.props as Props;
27
  const hasCaptionSlot = Astro.slots.has('caption');
28
  const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
29
+ const uid = `ri_${Math.random().toString(36).slice(2)}`;
30
+ const dataZoomable = (zoomable === true || (imgProps as any)['data-zoomable']) ? '1' : undefined;
31
+ const dataDownloadable = (downloadable === true || (imgProps as any)['data-downloadable']) ? '1' : undefined;
32
  ---
33
+ <div class="ri-root" data-ri-root={uid}>
34
+ {hasCaption ? (
35
+ <figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}>
36
+ {dataDownloadable ? (
37
+ <span class="img-dl-wrap">
38
+ <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
39
+ <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
40
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
41
+ </button>
42
+ </span>
43
  ) : (
44
+ <Image {...imgProps} data-zoomable={dataZoomable} />
45
  )}
46
+ <figcaption>
47
+ {hasCaptionSlot ? (
48
+ <slot name="caption" />
49
+ ) : (
50
+ caption && <span set:html={caption} />
51
+ )}
52
+ </figcaption>
53
+ </figure>
54
+ ) : (
55
+ dataDownloadable ? (
56
+ <span class="img-dl-wrap">
57
+ <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
58
+ <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
59
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
60
+ </button>
61
+ </span>
62
+ ) : (
63
+ <Image {...imgProps} data-zoomable={dataZoomable} />
64
+ )
65
+ )}
66
+ </div>
67
+
68
+ <script is:inline>
69
+ (() => {
70
+ const scriptEl = document.currentScript;
71
+ const root = scriptEl ? scriptEl.previousElementSibling : null;
72
+ if (!root) return;
73
+ const img = (root.tagName === 'IMG' ? root : (root.querySelector ? root.querySelector('img') : null));
74
+ if (!img) return;
75
+
76
+ // medium-zoom integration scoped to this image only
77
+ const ensureMediumZoomReady = (cb) => {
78
+ // @ts-ignore
79
+ if (window.mediumZoom) return cb();
80
+ const retry = () => {
81
+ // @ts-ignore
82
+ if (window.mediumZoom) cb(); else setTimeout(retry, 30);
83
+ };
84
+ retry();
85
+ };
86
+
87
+ const initZoomIfNeeded = () => {
88
+ if (img.getAttribute('data-zoomable') !== '1') return;
89
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
90
+ const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
91
+ ensureMediumZoomReady(() => {
92
+ // @ts-ignore
93
+ const instance = window.mediumZoom ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 }) : null;
94
+ if (!instance) return;
95
+ let onScrollLike;
96
+ const attachCloseOnScroll = () => {
97
+ if (onScrollLike) return;
98
+ onScrollLike = () => { try { instance.close && instance.close(); } catch {} };
99
+ window.addEventListener('wheel', onScrollLike, { passive: true });
100
+ window.addEventListener('touchmove', onScrollLike, { passive: true });
101
+ window.addEventListener('scroll', onScrollLike, { passive: true });
102
+ };
103
+ const detachCloseOnScroll = () => {
104
+ if (!onScrollLike) return;
105
+ window.removeEventListener('wheel', onScrollLike);
106
+ window.removeEventListener('touchmove', onScrollLike);
107
+ window.removeEventListener('scroll', onScrollLike);
108
+ onScrollLike = null;
109
+ };
110
+ try { instance.on && instance.on('open', attachCloseOnScroll); } catch {}
111
+ try { instance.on && instance.on('close', detachCloseOnScroll); } catch {}
112
+ const themeObserver = new MutationObserver(() => {
113
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark';
114
+ try { instance.update && instance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' }); } catch {}
115
+ });
116
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
117
+ });
118
+ };
119
+
120
+ // Download button handler
121
+ const dlBtn = (root.querySelector ? root.querySelector('.img-dl-btn') : null);
122
+ if (dlBtn) {
123
+ dlBtn.addEventListener('click', async (ev) => {
124
+ try {
125
+ ev.preventDefault();
126
+ ev.stopPropagation();
127
+ const pickHrefAndName = () => {
128
+ const current = img.currentSrc || img.src || '';
129
+ let href = img.getAttribute('data-download-src') || current;
130
+ const deriveName = () => {
131
+ try {
132
+ const u = new URL(current, location.href);
133
+ const rawHref = u.searchParams.get('href');
134
+ const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
135
+ const last = String(candidate).split('/').pop() || '';
136
+ const base = last.split('?')[0].split('#')[0];
137
+ const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
138
+ if (m && m[1]) return m[1];
139
+ return base || 'image';
140
+ } catch { return 'image'; }
141
+ };
142
+ const name = img.getAttribute('data-download-name') || deriveName();
143
+ return { href, name };
144
+ };
145
+ const picked = pickHrefAndName();
146
+ const res = await fetch(picked.href, { credentials: 'same-origin' });
147
+ const blob = await res.blob();
148
+ const objectUrl = URL.createObjectURL(blob);
149
+ const tmp = document.createElement('a');
150
+ tmp.href = objectUrl;
151
+ tmp.download = picked.name || 'image';
152
+ tmp.target = '_self';
153
+ tmp.rel = 'noopener';
154
+ tmp.style.display = 'none';
155
+ document.body.appendChild(tmp);
156
+ tmp.click();
157
+ setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
158
+ } catch {}
159
+ });
160
+ }
161
+
162
+ if (document.readyState === 'complete') initZoomIfNeeded();
163
+ else window.addEventListener('load', initZoomIfNeeded, { once: true });
164
+ })();
165
+ </script>
166
 
167
 
app/src/components/TableOfContent.astro ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ export interface Props { tableOfContentAutoCollapse?: boolean }
3
+ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
4
+ ---
5
+ <aside class="toc" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
6
+ <div class="title">Table of Contents</div>
7
+ <div id="article-toc-placeholder"></div>
8
+
9
+ </aside>
10
+ <details class="toc-mobile">
11
+ <summary>Table of Contents</summary>
12
+ <div id="article-toc-mobile-placeholder"></div>
13
+ </details>
14
+
15
+ <script is:inline>
16
+ // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
17
+ const buildTOC = () => {
18
+ const holder = document.getElementById('article-toc-placeholder');
19
+ const holderMobile = document.getElementById('article-toc-mobile-placeholder');
20
+ // Always rebuild TOC to avoid stale entries
21
+ if (holder) holder.innerHTML = '';
22
+ if (holderMobile) holderMobile.innerHTML = '';
23
+ const articleRoot = document.querySelector('section.content-grid main');
24
+ if (!articleRoot) return;
25
+ const headings = articleRoot.querySelectorAll('h2, h3, h4');
26
+ if (!headings.length) return;
27
+
28
+ // Filter out headings that should not appear in TOC
29
+ const normalize = (s) => String(s || '')
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, ' ')
32
+ .trim();
33
+ const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
34
+ const shouldSkip = (h) => {
35
+ const t = h.textContent || '';
36
+ const id = String(h.id || '');
37
+ const slug = normalize(t).replace(/\s+/g, '_');
38
+ if (isTocLabel(t)) return true;
39
+ if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
40
+ if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
41
+ return false;
42
+ };
43
+ const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
44
+ if (!headingsArr.length) return;
45
+
46
+ // Ensure unique ids for headings (deduplicate duplicates)
47
+ const usedIds = new Set();
48
+ const slugify = (s) => String(s || '')
49
+ .toLowerCase()
50
+ .trim()
51
+ .replace(/\s+/g, '_')
52
+ .replace(/[^a-z0-9_\-]/g, '');
53
+ headingsArr.forEach((h) => {
54
+ let id = (h.id || '').trim();
55
+ if (!id) {
56
+ const base = slugify(h.textContent || '');
57
+ id = base || 'section';
58
+ }
59
+ let candidate = id;
60
+ let n = 2;
61
+ while (usedIds.has(candidate)) {
62
+ candidate = `${id}-${n++}`;
63
+ }
64
+ if (h.id !== candidate) h.id = candidate;
65
+ usedIds.add(candidate);
66
+ });
67
+
68
+ const nav = document.createElement('nav');
69
+ let ulStack = [document.createElement('ul')];
70
+ nav.appendChild(ulStack[0]);
71
+
72
+ const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
73
+ let prev = 2;
74
+ let h2Count = -1;
75
+ const h2List = headingsArr.filter(h => h.tagName === 'H2');
76
+ headingsArr.forEach((h) => {
77
+ const lvl = levelOf(h.tagName);
78
+ // adjust depth
79
+ while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
80
+ while (lvl < prev) { ulStack.pop(); prev--; }
81
+ const li = document.createElement('li');
82
+ const a = document.createElement('a');
83
+ a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';
84
+ li.appendChild(a);
85
+ if (lvl === 2) {
86
+ h2Count += 1;
87
+ li.setAttribute('data-h2-idx', String(h2Count));
88
+ }
89
+ ulStack[ulStack.length-1].appendChild(li);
90
+ });
91
+
92
+ if (holder) holder.appendChild(nav);
93
+ const navClone = nav.cloneNode(true);
94
+ if (holderMobile) holderMobile.appendChild(navClone);
95
+
96
+ // active link on scroll
97
+ const links = [
98
+ ...(holder ? holder.querySelectorAll('a') : []),
99
+ ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
100
+ ];
101
+ const autoCollapse = (document.querySelector('aside.toc')?.getAttribute('data-auto-collapse') === '1');
102
+
103
+ // Inject styles for collapsible & animation
104
+ const ensureStyles = () => {
105
+ if (document.getElementById('toc-collapse-style')) return;
106
+ const style = document.createElement('style');
107
+ style.id = 'toc-collapse-style';
108
+ style.textContent = `
109
+ aside.toc nav.toc-collapsible > ul > li > ul,
110
+ details.toc-mobile nav.toc-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
111
+ aside.toc nav.toc-collapsible > ul > li.collapsed > ul,
112
+ details.toc-mobile nav.toc-collapsible > ul > li.collapsed > ul { display: block; }
113
+ `;
114
+ document.head.appendChild(style);
115
+ };
116
+ ensureStyles();
117
+
118
+ const getTopLevelItems = () => {
119
+ const sideNav = holder ? holder.querySelector('nav') : null;
120
+ const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
121
+ const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : [];
122
+ return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) };
123
+ };
124
+
125
+ const setNavCollapsible = () => {
126
+ const sideNav = holder ? holder.querySelector('nav') : null;
127
+ const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
128
+ if (sideNav) sideNav.classList.add('toc-collapsible');
129
+ if (mobileNav) mobileNav.classList.add('toc-collapsible');
130
+ };
131
+
132
+ const measure = (el) => {
133
+ if (!el) return 0;
134
+ // Temporarily set height to auto to measure scrollHeight reliably
135
+ const prev = el.style.height;
136
+ el.style.height = 'auto';
137
+ const h = el.scrollHeight;
138
+ el.style.height = prev || '';
139
+ return h;
140
+ };
141
+
142
+ const animateTo = (el, target) => {
143
+ if (!el) return;
144
+ const current = parseFloat(getComputedStyle(el).height) || 0;
145
+ if (Math.abs(current - target) < 1) {
146
+ el.style.height = target ? 'auto' : '0px';
147
+ return;
148
+ }
149
+ el.style.height = current + 'px';
150
+ // Force reflow
151
+ void el.offsetHeight;
152
+ el.style.height = target + 'px';
153
+ const onEnd = (e) => {
154
+ if (e.propertyName !== 'height') return;
155
+ el.removeEventListener('transitionend', onEnd);
156
+ if (target > 0) el.style.height = 'auto';
157
+ };
158
+ el.addEventListener('transitionend', onEnd);
159
+ };
160
+
161
+ let prevActiveIdx = -1;
162
+ const setCollapsedState = (activeIdx) => {
163
+ if (!autoCollapse) return;
164
+ if (activeIdx == null || activeIdx < 0) activeIdx = 0;
165
+ const { sideTop, mobileTop } = getTopLevelItems();
166
+ const update = (items) => items.forEach((li) => {
167
+ const idx = Number(li.getAttribute('data-h2-idx') || '-1');
168
+ const sub = li.querySelector(':scope > ul');
169
+ if (!sub) return;
170
+ if (idx === activeIdx) {
171
+ li.classList.remove('collapsed');
172
+ const target = measure(sub);
173
+ animateTo(sub, target);
174
+ } else {
175
+ li.classList.add('collapsed');
176
+ animateTo(sub, 0);
177
+ }
178
+ });
179
+ update(sideTop);
180
+ update(mobileTop);
181
+ setNavCollapsible();
182
+ prevActiveIdx = activeIdx;
183
+ };
184
+
185
+ const onScroll = () => {
186
+ // active link highlight
187
+ let activeIdx = -1;
188
+ for (let i = headingsArr.length - 1; i >= 0; i--) {
189
+ const top = headingsArr[i].getBoundingClientRect().top;
190
+ if (top - 60 <= 0) {
191
+ links.forEach(l => l.classList.remove('active'));
192
+ const id = '#' + headingsArr[i].id;
193
+ const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
194
+ actives.forEach(a => a.classList.add('active'));
195
+ if (headingsArr[i].tagName === 'H2') {
196
+ activeIdx = h2List.indexOf(headingsArr[i]);
197
+ } else {
198
+ for (let j = i; j >= 0; j--) {
199
+ if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; }
200
+ }
201
+ }
202
+ break;
203
+ }
204
+ }
205
+ if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
206
+ };
207
+
208
+ window.addEventListener('scroll', onScroll);
209
+ // Initialize state
210
+ onScroll();
211
+
212
+ // Close mobile accordion when a link inside it is clicked
213
+ if (holderMobile) {
214
+ const details = holderMobile.closest('details');
215
+ holderMobile.addEventListener('click', (ev) => {
216
+ const target = ev.target;
217
+ const anchor = target && 'closest' in target ? target.closest('a') : null;
218
+ if (anchor instanceof HTMLAnchorElement && details && details.open) {
219
+ details.open = false;
220
+ }
221
+ });
222
+ }
223
+ };
224
+
225
+ if (document.readyState === 'loading') {
226
+ document.addEventListener('DOMContentLoaded', buildTOC, { once: true });
227
+ } else { buildTOC(); }
228
+ </script>
229
+
230
+
app/src/content/article.mdx CHANGED
@@ -10,84 +10,16 @@ published: "Sep. 01, 2025"
10
  tags:
11
  - research
12
  - template
13
- ogImage: "/thumb.jpg"
14
  ---
15
 
16
- import HtmlEmbed from "../components/HtmlEmbed.astro";
17
- import Wide from "../components/Wide.astro";
18
- import FullWidth from "../components/FullWidth.astro";
19
- import { Image } from 'astro:assets';
20
- import placeholder from "./assets/images/placeholder.png";
21
- import audioDemo from "./assets/audio/audio-example.wav";
22
- import Sidenote from "../components/Sidenote.astro";
23
- import visualPoster from "./assets/images/visual-vocabulary-poster.png";
24
-
25
  import BestPractices from "./chapters/best-pratices.mdx";
26
  import WritingYourContent from "./chapters/writing-your-content.mdx";
27
  import AvailableBlocks from "./chapters/available-blocks.mdx";
28
  import GettingStarted from "./chapters/getting-started.mdx";
29
 
30
- <Sidenote>
31
- Welcome to this **single‑page** research article template. It helps you publish **clear**, **modern**, and **interactive** technical writing with **minimal setup**. Grounded in up to date good practices in web dev, it favors interactive explanations, clear notation, and inspectable examples over static snapshots.
32
-
33
- It offers a **ready‑to‑publish**, **all‑in‑one** workflow so you can focus on ideas rather than infrastructure.
34
- <Fragment slot="aside">
35
- Reading time: 20–25 minutes.
36
- </Fragment>
37
- </Sidenote>
38
-
39
- #### Features
40
-
41
- <Sidenote>
42
- <div className="tag-list">
43
- <span className="tag">Markdown based</span>
44
- <span className="tag">KaTeX math</span>
45
- <span className="tag">Syntax highlighting</span>
46
- <span className="tag">Citations & footnotes</span>
47
- <span className="tag">Automatic build</span>
48
- <span className="tag">Table of contents</span>
49
- <span className="tag">Dark theme</span>
50
- <span className="tag">HTML Embeds</span>
51
- <span className="tag">Plotly ready</span>
52
- <span className="tag">D3.js ready</span>
53
- <span className="tag">SEO Friendly</span>
54
- <span className="tag">Mermaid diagrams</span>
55
- <span className="tag">Lightweight bundle</span>
56
- <span className="tag">Mobile friendly</span>
57
- <span className="tag">Optimized images</span>
58
- <span className="tag">Automatic PDF export</span>
59
- <span className="tag">Dataviz color palettes</span>
60
- <span className="tag">Embed gradio apps</span>
61
- </div>
62
- <Fragment slot="aside">
63
- If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
64
- </Fragment>
65
- </Sidenote>
66
-
67
- ## Introduction
68
- The web enables what static PDFs can’t: **interactive diagrams**, **progressive notation**, and **exploratory views** that reveal how ideas behave. This template makes **interactive artifacts**—figures, math, code and **inspectable experiments**—first‑class alongside prose, so readers **build intuition** rather than skim results.
69
-
70
- If you write technical blogs or research notes, you’ll get a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**. **Start below and iterate quickly.**
71
-
72
- This project is heavily inspired by [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship.
73
-
74
- ### Notable examples of excellent scientific articles
75
-
76
-
77
- <Sidenote>
78
- A short, curated list of well‑designed and often interactive work:
79
-
80
- - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
81
- - [Activation Atlas](https://distill.pub/2019/activation-atlas/)
82
- - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
83
- - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
84
-
85
- <Fragment slot="aside">
86
- I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
87
- </Fragment>
88
- </Sidenote>
89
-
90
-
91
 
92
  <GettingStarted />
93
 
 
10
  tags:
11
  - research
12
  - template
13
+ tocAutoCollapse: true
14
  ---
15
 
16
+ import Introduction from "./chapters/introduction.mdx";
 
 
 
 
 
 
 
 
17
  import BestPractices from "./chapters/best-pratices.mdx";
18
  import WritingYourContent from "./chapters/writing-your-content.mdx";
19
  import AvailableBlocks from "./chapters/available-blocks.mdx";
20
  import GettingStarted from "./chapters/getting-started.mdx";
21
 
22
+ <Introduction />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  <GettingStarted />
25
 
app/src/content/assets/images/placeholder.png CHANGED

Git LFS Details

  • SHA256: 92f1591b62f4f7eb8a059b973a379784523915386ee9f682e17e3ab43d4f494d
  • Pointer size: 130 Bytes
  • Size of remote file: 89.8 kB

Git LFS Details

  • SHA256: af82403ec775e8ed0139a70d034e000cb567b6766a3187d246800f0384d57ea9
  • Pointer size: 131 Bytes
  • Size of remote file: 677 kB
app/src/content/chapters/available-blocks.mdx CHANGED
@@ -111,19 +111,19 @@ Here are a few variations using the same bibliography:
111
 
112
  | Prop | Required | Description
113
  |------------------------|----------|-------------------------------------------------------
114
- | `data-zoomable` | No | Adds a zoomable lightbox (Medium-like).
115
- | `data-downloadable` | No | Adds a download button to fetch the image file.
116
  | `loading="lazy"` | No | Lazy loads the image.
117
  | `caption` | No | Adds a caption and credit.
118
 
119
 
120
  <ResponsiveImage
121
  src={placeholder}
122
- data-zoomable
123
- data-downloadable
124
  layout="fixed"
125
- alt="Tensor parallelism in a transformer block"
126
- caption={'Tensor parallelism in a transformer block <span class="image-credit">Original work on <a target="_blank" href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=tensor_parallelism_in_a_transformer_block">Ultrascale Playbook</a></span>'}
127
  />
128
 
129
  <small className="muted">Example</small>
@@ -136,8 +136,8 @@ import myImage from './assets/images/placeholder.jpg'
136
  <ResponsiveImage
137
  src={myImage}
138
  layout="fixed"
139
- data-zoomable
140
- data-downloadable
141
  loading="lazy"
142
  alt="Example with caption and credit"
143
  caption={'Optimized image with a descriptive caption. <span class="image-credit">Credit: Photo by <a href="https://example.com">Author</a></span>'}
@@ -282,15 +282,25 @@ greet("Astro");
282
 
283
  Small contextual callout for tips, caveats, or emphasis.
284
 
285
- Props (optional)
286
- - `title`: short title displayed in the header.
287
- - `emoji`: emoji displayed before the title.
288
- - `class`: extra classes for custom styling.
 
 
289
 
290
- <Note title="Heads‑up" emoji="💡">
291
  Use notes to surface context without breaking reading flow.
292
  </Note>
293
 
 
 
 
 
 
 
 
 
294
  <Note>
295
  Plain note without header. Useful for short clarifications.
296
  </Note>
@@ -299,10 +309,18 @@ Props (optional)
299
  ```mdx
300
  import Note from '../../components/Note.astro'
301
 
302
- <Note title="Heads‑up" emoji="💡">
303
  Use notes to surface context without breaking reading flow.
304
  </Note>
305
 
 
 
 
 
 
 
 
 
306
  <Note>
307
  Plain note without header. Useful for short clarifications.
308
  </Note>
@@ -369,18 +387,22 @@ import HtmlEmbed from '../components/HtmlEmbed.astro'
369
 
370
  #### Data
371
 
372
- If you need to link your HTML embeds to data files, there is an **`assets/data`** folder for this.
373
  As long as your files are there, they will be served from the **`public/data`** folder.
374
  You can fetch them with this address: **`[domain]/data/your-data.ext`**
375
 
376
  <Note>⚠️ <b>Be careful</b>, unlike images, <b>data files are not optimized</b> by Astro. You need to optimize them manually.</Note>
377
 
378
- #### Real world examples
 
 
 
 
 
379
 
380
- Here are some real world examples to inspire you.
381
 
382
  <HtmlEmbed src="d3-line.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
383
- <HtmlEmbed src="d3-line-example.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
384
  ---
385
  <HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
386
  ---
 
111
 
112
  | Prop | Required | Description
113
  |------------------------|----------|-------------------------------------------------------
114
+ | `zoomable` | No | Adds a zoomable lightbox (Medium-like).
115
+ | `downloadable` | No | Adds a download button to fetch the image file.
116
  | `loading="lazy"` | No | Lazy loads the image.
117
  | `caption` | No | Adds a caption and credit.
118
 
119
 
120
  <ResponsiveImage
121
  src={placeholder}
122
+ zoomable
123
+ downloadable
124
  layout="fixed"
125
+ alt="A placeholder image alt text"
126
+ caption={'A placeholder image description <span class="image-credit">From the <a target="_blank" href="https://commons.wikimedia.org/wiki/File:RCA_Indian_Head_Test_Pattern.svg">RCA Indian Head Test Pattern</a></span>'}
127
  />
128
 
129
  <small className="muted">Example</small>
 
136
  <ResponsiveImage
137
  src={myImage}
138
  layout="fixed"
139
+ zoomable
140
+ downloadable
141
  loading="lazy"
142
  alt="Example with caption and credit"
143
  caption={'Optimized image with a descriptive caption. <span class="image-credit">Credit: Photo by <a href="https://example.com">Author</a></span>'}
 
282
 
283
  Small contextual callout for tips, caveats, or emphasis.
284
 
285
+ | Prop | Required | Type | Description |
286
+ |----------|----------|------------------------------|-------------------------------------|
287
+ | `title` | No | string | Short title displayed in header |
288
+ | `emoji` | No | string | Emoji displayed before the title |
289
+ | `class` | No | string | Extra classes for custom styling |
290
+ | `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note |
291
 
292
+ <Note title="Heads‑up" emoji="💡" variant="info">
293
  Use notes to surface context without breaking reading flow.
294
  </Note>
295
 
296
+ <Note variant="success">
297
+ Operation completed successfully.
298
+ </Note>
299
+
300
+ <Note variant="danger">
301
+ Be careful: this action cannot be undone.
302
+ </Note>
303
+
304
  <Note>
305
  Plain note without header. Useful for short clarifications.
306
  </Note>
 
309
  ```mdx
310
  import Note from '../../components/Note.astro'
311
 
312
+ <Note title="Heads‑up" emoji="💡" variant="info">
313
  Use notes to surface context without breaking reading flow.
314
  </Note>
315
 
316
+ <Note variant="success">
317
+ Operation completed successfully.
318
+ </Note>
319
+
320
+ <Note variant="danger">
321
+ Be careful: this action cannot be undone.
322
+ </Note>
323
+
324
  <Note>
325
  Plain note without header. Useful for short clarifications.
326
  </Note>
 
387
 
388
  #### Data
389
 
390
+ If you need to link your **HTML embeds** to **data files**, there is an **`assets/data`** folder for this.
391
  As long as your files are there, they will be served from the **`public/data`** folder.
392
  You can fetch them with this address: **`[domain]/data/your-data.ext`**
393
 
394
  <Note>⚠️ <b>Be careful</b>, unlike images, <b>data files are not optimized</b> by Astro. You need to optimize them manually.</Note>
395
 
396
+ #### Vibe coding charts
397
+
398
+ If you are from the research field, it can be difficult to use **D3.js** charts instead of **Plotly**.
399
+ Happily, **LLM's** are here to help you! In the `app/src/content/embeds` folder, there is a `vibe-code-d3-embeds-directives.md` file that you can use to vibe code **D3.js** charts.
400
+
401
+ Inside this file, you will find every directives you need to code your own **HtmlEmbed** proof **D3.js** chart.
402
 
403
+ Here are some **real world examples** that were vibe coded to inspire you.
404
 
405
  <HtmlEmbed src="d3-line.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
 
406
  ---
407
  <HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
408
  ---
app/src/content/chapters/best-pratices.mdx CHANGED
@@ -4,7 +4,7 @@ import Note from '../../components/Note.astro';
4
  import ResponsiveImage from '../../components/ResponsiveImage.astro';
5
 
6
 
7
- ## Best Practices
8
 
9
  ### Short sections
10
  Break content into **small, purpose‑driven sections**. Each section should answer a **single question** or support one idea. This improves **scanability**, helps readers navigate with the TOC, and makes later edits safer.
 
4
  import ResponsiveImage from '../../components/ResponsiveImage.astro';
5
 
6
 
7
+ ## Writing best practices
8
 
9
  ### Short sections
10
  Break content into **small, purpose‑driven sections**. Each section should answer a **single question** or support one idea. This improves **scanability**, helps readers navigate with the TOC, and makes later edits safer.
app/src/content/chapters/getting-started.mdx CHANGED
@@ -14,11 +14,26 @@ The recommended way is to **duplicate this Space** on **Hugging Face** rather th
14
  git clone git@hf.co:spaces/<your-username>/<your-space>
15
  cd <your-space>
16
  ```
 
 
 
 
17
 
18
- <Sidenote>
 
 
 
 
 
 
19
  ```bash
20
  git lfs install
21
  git lfs pull
 
 
 
 
 
22
  cd app
23
  npm install
24
  ```
@@ -28,12 +43,16 @@ npm install
28
  </Sidenote>
29
 
30
 
 
 
31
  ### Development
32
 
33
  ```bash
34
  npm run dev
35
  ```
36
 
 
 
37
  ### Build
38
 
39
  ```bash
@@ -43,7 +62,7 @@ npm run build
43
 
44
  ### Deploy
45
 
46
- Push your changes to your new Space repo.<br/>**Every push automatically triggers a build and deploy** on Spaces.
47
  ```bash
48
  # Make edits locally, then:
49
  git add .
@@ -55,5 +74,5 @@ git push
55
  Serving the `dist/` directory on any static host is enough to deliver the site.
56
  </Note>
57
  <Note title="Note">
58
- A [slugified-title].pdf and thumb.jpg are also generated at build time.<br/>You can find them in the public folder.
59
  </Note>
 
14
  git clone git@hf.co:spaces/<your-username>/<your-space>
15
  cd <your-space>
16
  ```
17
+ <br/>
18
+ 4. Use **Node.js 20 or newer**. <br/>To manage versions, consider using **nvm**
19
+ - macOS/Linux: see [nvm-sh](https://github.com/nvm-sh/nvm)
20
+ - Windows: see [nvm-windows](https://github.com/coreybutler/nvm-windows)
21
 
22
+ ```bash
23
+ nvm install 20
24
+ nvm use 20
25
+ node -v
26
+ ```
27
+
28
+ 5. Install lfs and pull files from the repository.
29
  ```bash
30
  git lfs install
31
  git lfs pull
32
+ ```
33
+
34
+ 6. Install dependencies.
35
+ <Sidenote>
36
+ ```bash
37
  cd app
38
  npm install
39
  ```
 
43
  </Sidenote>
44
 
45
 
46
+ <br/>And that's it!
47
+
48
  ### Development
49
 
50
  ```bash
51
  npm run dev
52
  ```
53
 
54
+ Once started, the dev server is available at `http://localhost:4321`.
55
+
56
  ### Build
57
 
58
  ```bash
 
62
 
63
  ### Deploy
64
 
65
+ **Every push** automatically triggers a **build** and **deploy** on Spaces.
66
  ```bash
67
  # Make edits locally, then:
68
  git add .
 
74
  Serving the `dist/` directory on any static host is enough to deliver the site.
75
  </Note>
76
  <Note title="Note">
77
+ A [slugified-title].pdf and thumb.jpg are also generated at build time.<br/>You can find them in the public folder and point to them at `[domain]/public/thumb.jpg`.
78
  </Note>
app/src/content/chapters/introduction.mdx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Sidenote from "../../components/Sidenote.astro";
2
+
3
+ <Sidenote>
4
+ Welcome to this **single‑page** research article template. It helps you publish **clear**, **modern**, and **interactive** technical writing with **minimal setup**. Grounded in up to date good practices in web dev, it favors interactive explanations, clear notation, and inspectable examples over static snapshots.
5
+
6
+ It offers a **ready‑to‑publish**, **all‑in‑one** workflow so you can focus on ideas rather than infrastructure.
7
+ <Fragment slot="aside">
8
+ Reading time: 20–25 minutes.
9
+ </Fragment>
10
+ </Sidenote>
11
+
12
+ #### Features
13
+
14
+ <Sidenote>
15
+ <div className="tag-list">
16
+ <span className="tag">Markdown based</span>
17
+ <span className="tag">KaTeX math</span>
18
+ <span className="tag">Syntax highlighting</span>
19
+ <span className="tag">Citations & footnotes</span>
20
+ <span className="tag">Automatic build</span>
21
+ <span className="tag">Table of contents</span>
22
+ <span className="tag">Dark theme</span>
23
+ <span className="tag">HTML Embeds</span>
24
+ <span className="tag">Plotly ready</span>
25
+ <span className="tag">D3.js ready</span>
26
+ <span className="tag">SEO Friendly</span>
27
+ <span className="tag">Mermaid diagrams</span>
28
+ <span className="tag">Lightweight bundle</span>
29
+ <span className="tag">Mobile friendly</span>
30
+ <span className="tag">Optimized images</span>
31
+ <span className="tag">Automatic PDF export</span>
32
+ <span className="tag">Dataviz color palettes</span>
33
+ <span className="tag">Embed gradio apps</span>
34
+ </div>
35
+ <Fragment slot="aside">
36
+ If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
37
+ </Fragment>
38
+ </Sidenote>
39
+
40
+ ## Introduction
41
+ The web enables what static PDFs can’t: **interactive diagrams**, **progressive notation**, and **exploratory views** that reveal how ideas behave. This template makes **interactive artifacts**—figures, math, code and **inspectable experiments**—first‑class alongside prose, so readers **build intuition** rather than skim results.
42
+
43
+ If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
44
+
45
+ This project draws strong inspiration from [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship and set a high standard for interactive scientific communication.
46
+
47
+ <Sidenote>
48
+ To give you a sense of what inspired this template, here is a short, curated list of well‑designed and often interactive works from Distill:
49
+
50
+ - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
51
+ - [Activation Atlas](https://distill.pub/2019/activation-atlas/)
52
+ - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
53
+ - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
54
+
55
+ <Fragment slot="aside">
56
+ I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
57
+ </Fragment>
58
+ </Sidenote>
app/src/content/chapters/writing-your-content.mdx CHANGED
@@ -3,6 +3,7 @@ import { Image } from 'astro:assets';
3
  import placeholder from '../assets/images/placeholder.png';
4
  import Sidenote from '../../components/Sidenote.astro';
5
  import Wide from '../../components/Wide.astro';
 
6
  import FullWidth from '../../components/FullWidth.astro';
7
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
8
  import audioDemo from '../assets/audio/audio-example.wav';
@@ -15,12 +16,12 @@ Your **article** lives in **one place**
15
 
16
  Everything is **self-contained** under `app/src/content/`:
17
  - **MDX**: `article.mdx` and [**optional chapters**](#chapters) in `chapters/`
18
- - **Assets**: `assets/` **images**, **audios**, **datas**, etc. (tracked via **Git LFS**)
19
- - **Embeds**: `embed/` [**HtmlEmbed**](#htmlembed) for **Plotly/D3**, etc.
20
 
21
  The `article.mdx` file is the **main entry point** of your article.
22
 
23
- <small className="muted">Example</small>
24
  ```mdx
25
  {/* HEADER */}
26
  ---
@@ -38,6 +39,8 @@ tags:
38
  - template
39
  {/* Optional override of the default Open Graph image */}
40
  ogImage: "https://override-example.com/your-og-image.png"
 
 
41
  ---
42
 
43
  {/* IMPORTS */}
@@ -129,6 +132,42 @@ Here is a suggestion of **color palettes** for your **data visualizations** that
129
  Color should rarely be the only channel of meaning.
130
  Always pair it with text, icons, shape or position. The simulation helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies.
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  ### Placement
133
 
134
  Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
@@ -152,7 +191,6 @@ import Sidenote from '../components/Sidenote.astro'
152
  </Sidenote>
153
  ```
154
 
155
- Use these helpers to expand content beyond the main column when needed. They will always be centered and displayed above every other content.
156
 
157
  #### Wide example
158
 
 
3
  import placeholder from '../assets/images/placeholder.png';
4
  import Sidenote from '../../components/Sidenote.astro';
5
  import Wide from '../../components/Wide.astro';
6
+ import Note from '../../components/Note.astro';
7
  import FullWidth from '../../components/FullWidth.astro';
8
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
9
  import audioDemo from '../assets/audio/audio-example.wav';
 
16
 
17
  Everything is **self-contained** under `app/src/content/`:
18
  - **MDX**: `article.mdx` and [**optional chapters**](#chapters) in `chapters/`
19
+ - **Assets**: `assets/` images, audios, datas, etc. (tracked via **Git LFS**)
20
+ - **Embeds**: `embed/` [**HtmlEmbed**](#htmlembed) for Plotly/D3, etc.
21
 
22
  The `article.mdx` file is the **main entry point** of your article.
23
 
24
+ <small className="muted">app/src/content/article.mdx</small>
25
  ```mdx
26
  {/* HEADER */}
27
  ---
 
39
  - template
40
  {/* Optional override of the default Open Graph image */}
41
  ogImage: "https://override-example.com/your-og-image.png"
42
+ {/* By default the table of contents is not collapsing */}
43
+ tocAutoCollapse: true
44
  ---
45
 
46
  {/* IMPORTS */}
 
132
  Color should rarely be the only channel of meaning.
133
  Always pair it with text, icons, shape or position. The simulation helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies.
134
 
135
+ #### Using the palettes
136
+
137
+ ##### CSS
138
+ Use the generated **CSS variables** to style elements.
139
+
140
+ ```css
141
+ /* Usage */
142
+ .series-a { color: var(--palette-categorical-1); }
143
+ .series-b { color: var(--palette-categorical-2); }
144
+ .heatmap-low { background: var(--palette-sequential-1); }
145
+ .heatmap-high { background: var(--palette-sequential-6); }
146
+ .neg { color: var(--palette-diverging-1); } /* left = primary */
147
+ .pos { color: var(--palette-diverging-6); } /* right = complement */
148
+
149
+ /* Override size */
150
+ :root { --palette-count: 8; } /* global */
151
+ :root { --palette-diverging-count: 7; } /* per palette */
152
+ ```
153
+
154
+ <Note variant="info">⚠️ **CSS overrides** automatically trigger a **palette regeneration** via JS observers; no manual call is needed.</Note>
155
+
156
+ ##### JS
157
+ Fetch arrays of colors via **`window.ColorPalettes.getColors(type)`**. To change sizes **at runtime**, set CSS vars and call **`window.ColorPalettes.refresh()`**.
158
+
159
+ ```js
160
+ // Usage
161
+ const cat = window.ColorPalettes.getColors('categorical');
162
+ const seq = window.ColorPalettes.getColors('sequential');
163
+ const div = window.ColorPalettes.getColors('diverging');
164
+
165
+ // Override size (runtime)
166
+ document.documentElement.style.setProperty('--palette-count', '8');
167
+ document.documentElement.style.setProperty('--palette-diverging-count', '7');
168
+ window.ColorPalettes.refresh();
169
+ ```
170
+
171
  ### Placement
172
 
173
  Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
 
191
  </Sidenote>
192
  ```
193
 
 
194
 
195
  #### Wide example
196
 
app/src/content/embeds/d3-line-example.html CHANGED
@@ -1,6 +1,6 @@
1
- <div class="d3-line" style="width:100%;margin:10px 0;"></div>
2
  <style>
3
- .d3-line .d3-line__controls select {
4
  font-size: 12px;
5
  padding: 8px 28px 8px 10px;
6
  border: 1px solid var(--border-color);
@@ -17,21 +17,21 @@
17
  cursor: pointer;
18
  transition: border-color .15s ease, box-shadow .15s ease;
19
  }
20
- [data-theme="dark"] .d3-line .d3-line__controls select {
21
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
22
  }
23
- .d3-line .d3-line__controls select:hover {
24
  border-color: var(--primary-color);
25
  }
26
- .d3-line .d3-line__controls select:focus {
27
  border-color: var(--primary-color);
28
  box-shadow: 0 0 0 3px rgba(232,137,171,.25);
29
  outline: none;
30
  }
31
- .d3-line .d3-line__controls label { gap: 8px; }
32
 
33
  /* Range slider themed with --primary-color */
34
- .d3-line .d3-line__controls input[type="range"] {
35
  -webkit-appearance: none;
36
  appearance: none;
37
  width: 100%;
@@ -40,12 +40,12 @@
40
  background: var(--border-color);
41
  outline: none;
42
  }
43
- .d3-line .d3-line__controls input[type="range"]::-webkit-slider-runnable-track {
44
  height: 6px;
45
  background: transparent;
46
  border-radius: 999px;
47
  }
48
- .d3-line .d3-line__controls input[type="range"]::-webkit-slider-thumb {
49
  -webkit-appearance: none;
50
  appearance: none;
51
  width: 16px;
@@ -56,12 +56,12 @@
56
  margin-top: -5px;
57
  cursor: pointer;
58
  }
59
- .d3-line .d3-line__controls input[type="range"]::-moz-range-track {
60
  height: 6px;
61
  background: transparent;
62
  border-radius: 999px;
63
  }
64
- .d3-line .d3-line__controls input[type="range"]::-moz-range-thumb {
65
  width: 16px;
66
  height: 16px;
67
  border-radius: 50%;
@@ -70,7 +70,7 @@
70
  cursor: pointer;
71
  }
72
  /* Improved line color via CSS */
73
- .d3-line .lines path.improved { stroke: var(--primary-color); }
74
  </style>
75
  <script>
76
  (() => {
@@ -94,7 +94,7 @@
94
  const getLocalPrev = () => {
95
  if (!scriptEl) return null;
96
  let el = scriptEl.previousElementSibling;
97
- while (el && !(el.classList && el.classList.contains('d3-line'))) {
98
  el = el.previousElementSibling;
99
  }
100
  return el || null;
@@ -103,7 +103,7 @@
103
  const localTarget = getLocalPrev();
104
  const targets = localTarget
105
  ? [localTarget]
106
- : Array.from(document.querySelectorAll('.d3-line'))
107
  .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
108
 
109
  targets.forEach((container) => {
 
1
+ <div class="d3-line-example" style="width:100%;margin:10px 0;"></div>
2
  <style>
3
+ .d3-line-example .d3-line__controls select {
4
  font-size: 12px;
5
  padding: 8px 28px 8px 10px;
6
  border: 1px solid var(--border-color);
 
17
  cursor: pointer;
18
  transition: border-color .15s ease, box-shadow .15s ease;
19
  }
20
+ [data-theme="dark"] .d3-line-example .d3-line__controls select {
21
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
22
  }
23
+ .d3-line-example .d3-line__controls select:hover {
24
  border-color: var(--primary-color);
25
  }
26
+ .d3-line-example .d3-line__controls select:focus {
27
  border-color: var(--primary-color);
28
  box-shadow: 0 0 0 3px rgba(232,137,171,.25);
29
  outline: none;
30
  }
31
+ .d3-line-example .d3-line__controls label { gap: 8px; }
32
 
33
  /* Range slider themed with --primary-color */
34
+ .d3-line-example .d3-line__controls input[type="range"] {
35
  -webkit-appearance: none;
36
  appearance: none;
37
  width: 100%;
 
40
  background: var(--border-color);
41
  outline: none;
42
  }
43
+ .d3-line-example .d3-line__controls input[type="range"]::-webkit-slider-runnable-track {
44
  height: 6px;
45
  background: transparent;
46
  border-radius: 999px;
47
  }
48
+ .d3-line-example .d3-line__controls input[type="range"]::-webkit-slider-thumb {
49
  -webkit-appearance: none;
50
  appearance: none;
51
  width: 16px;
 
56
  margin-top: -5px;
57
  cursor: pointer;
58
  }
59
+ .d3-line-example .d3-line__controls input[type="range"]::-moz-range-track {
60
  height: 6px;
61
  background: transparent;
62
  border-radius: 999px;
63
  }
64
+ .d3-line-example .d3-line__controls input[type="range"]::-moz-range-thumb {
65
  width: 16px;
66
  height: 16px;
67
  border-radius: 50%;
 
70
  cursor: pointer;
71
  }
72
  /* Improved line color via CSS */
73
+ .d3-line-example .lines path.improved { stroke: var(--primary-color); }
74
  </style>
75
  <script>
76
  (() => {
 
94
  const getLocalPrev = () => {
95
  if (!scriptEl) return null;
96
  let el = scriptEl.previousElementSibling;
97
+ while (el && !(el.classList && el.classList.contains('d3-line-example'))) {
98
  el = el.previousElementSibling;
99
  }
100
  return el || null;
 
103
  const localTarget = getLocalPrev();
104
  const targets = localTarget
105
  ? [localTarget]
106
+ : Array.from(document.querySelectorAll('.d3-line-example'))
107
  .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
108
 
109
  targets.forEach((container) => {
app/src/content/embeds/palettes.html CHANGED
@@ -8,7 +8,7 @@
8
  .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
9
  .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
10
  .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
11
- .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 16px; border-right: 1px solid var(--border-color); }
12
  .palettes .palette-card__content__info { display: flex; flex-direction: column; }
13
  .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
14
  .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
@@ -97,128 +97,24 @@
97
  </div>
98
  <script>
99
  (() => {
100
- const loadScript = (id, src, onload, onerror) => {
101
- let s = document.getElementById(id);
102
- if (s) { return onload && onload(); }
103
- s = document.createElement('script'); s.id = id; s.src = src; s.async = true;
104
- if (onload) s.addEventListener('load', onload, { once: true });
105
- if (onerror) s.addEventListener('error', onerror, { once: true });
106
- document.head.appendChild(s);
107
- };
108
- const ensureChroma = (next) => {
109
- if (window.chroma) return next();
110
- const tryReady = () => { if (window.chroma) next(); else setTimeout(tryReady, 25); };
111
- const existing = document.getElementById('chroma-cdn') || document.getElementById('chroma-cdn-fallback');
112
- if (existing) { tryReady(); return; }
113
- loadScript('chroma-cdn', 'https://unpkg.com/chroma-js@2.4.2/dist/chroma.min.js', tryReady, () => {
114
- loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', tryReady);
115
- });
116
- };
117
-
118
  const cards = [
119
- { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.', generator: (baseHex, count) => {
120
- const base = chroma(baseHex);
121
- const lc = base.lch();
122
- const baseH = base.get('hsl.h') || 0;
123
- const L0 = Math.max(40, Math.min(85, lc[0] || 70));
124
- const C0 = Math.max(45, Math.min(75, lc[1] || 70));
125
- const MIN_DELTA = 18; // minimal distance in Lab
126
-
127
- const seen = new Set();
128
- const results = [];
129
-
130
- const makeSafe = (h, L, C) => {
131
- let c = C;
132
- let col = chroma.lch(L, c, h);
133
- let guard = 0;
134
- while (col.clipped && typeof col.clipped === 'function' && col.clipped() && c > 30 && guard < 8) {
135
- c -= 5;
136
- col = chroma.lch(L, c, h);
137
- guard++;
138
- }
139
- return col;
140
- };
141
-
142
- const isFarEnough = (hex) => results.every(prev => chroma.distance(hex, prev, 'lab') >= MIN_DELTA);
143
- const pushHex = (col) => {
144
- const hex = col.hex();
145
- if (!seen.has(hex.toLowerCase())) { results.push(hex); seen.add(hex.toLowerCase()); }
146
- };
147
-
148
- // Base first
149
- pushHex(base);
150
-
151
- const total = Math.max(6, Math.min(10, count || 6));
152
- const hueStep = 360 / total;
153
- const hueOffsets = [0, 18, -18, 36, -36, 54, -54, 72, -72];
154
- const lVariants = [L0, Math.max(40, L0 - 6), Math.min(85, L0 + 6)];
155
-
156
- for (let idx = 1; results.length < total && idx < total + 12; idx++) {
157
- let stepHue = (baseH + idx * hueStep) % 360;
158
- let accepted = false;
159
- for (let li = 0; li < lVariants.length && !accepted; li++) {
160
- for (let oi = 0; oi < hueOffsets.length && !accepted; oi++) {
161
- const h = (stepHue + hueOffsets[oi] + 360) % 360;
162
- const col = makeSafe(h, lVariants[li], C0);
163
- const hex = col.hex();
164
- if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
165
- pushHex(col);
166
- accepted = true;
167
- }
168
- }
169
- }
170
- if (!accepted) {
171
- // Reduce chroma if necessary
172
- let cTry = C0 - 10;
173
- let trials = 0;
174
- while (!accepted && cTry >= 30 && trials < 6) {
175
- const col = makeSafe(stepHue, L0, cTry);
176
- const hex = col.hex();
177
- if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
178
- pushHex(col);
179
- accepted = true;
180
- break;
181
- }
182
- cTry -= 5;
183
- trials++;
184
- }
185
- if (!accepted) {
186
- let bestHex = null; let bestMin = -1;
187
- hueOffsets.forEach(off => {
188
- const hh = (stepHue + off + 360) % 360;
189
- const cand = makeSafe(hh, L0, C0).hex();
190
- const minD = results.reduce((m, prev) => Math.min(m, chroma.distance(cand, prev, 'lab')), Infinity);
191
- if (minD > bestMin && !seen.has(cand.toLowerCase())) { bestMin = minD; bestHex = cand; }
192
- });
193
- if (bestHex) { seen.add(bestHex.toLowerCase()); results.push(bestHex); }
194
- }
195
- }
196
- }
197
-
198
- return results.slice(0, total);
199
- }},
200
- { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.', generator: (baseHex, count) => {
201
- const total = Math.max(6, Math.min(10, count || 6));
202
- const c = chroma(baseHex).saturate(0.3);
203
- return chroma.scale([c.darken(2), c, c.brighten(2)]).mode('lab').correctLightness(true).colors(total);
204
- }},
205
- { key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base → white → complement</strong>; smooth contrast around a neutral midpoint.', generator: (baseHex, count) => {
206
- const total = Math.max(6, Math.min(10, count || 6));
207
- const comp = chroma(baseHex).set('hsl.h', '+180');
208
- const bez = chroma.bezier([baseHex, '#ffffff', comp]);
209
- return bez.scale().padding(-0.15).colors(total);
210
- }}
211
  ];
212
 
213
- const getCssPrimary = () => {
214
- try { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); } catch { return ''; }
215
  };
216
 
217
- const getDesiredCount = () => {
218
- const input = document.getElementById('color-count');
219
- let v = input ? parseInt(input.value, 10) : 6;
220
- if (Number.isNaN(v)) v = 6;
221
- return Math.max(6, Math.min(10, v));
 
 
 
222
  };
223
 
224
  const render = () => {
@@ -227,13 +123,8 @@
227
  if (!root) return;
228
  const grid = root.querySelector('.palettes__grid');
229
  if (!grid) return;
230
- grid.innerHTML = '';
231
- const css = getCssPrimary();
232
- const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
233
- const count = getDesiredCount();
234
-
235
  const html = cards.map((c) => {
236
- const colors = c.generator(baseHex, count).slice(0, count);
237
  const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
238
  return `
239
  <div class="palette-card" data-colors="${colors.join(',')}">
@@ -252,7 +143,7 @@
252
  `;
253
  }).join('');
254
  grid.innerHTML = html;
255
- };
256
 
257
  const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
258
  const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
@@ -281,9 +172,19 @@
281
  const input = document.getElementById('color-count');
282
  const out = document.getElementById('color-count-out');
283
  if (!input) return;
284
- const sync = () => { if (out) out.textContent = String(getDesiredCount()); render(); };
285
- input.addEventListener('input', sync);
286
- try { if (out) out.textContent = String(getDesiredCount()); } catch {}
 
 
 
 
 
 
 
 
 
 
287
  };
288
 
289
  let copyDelegationSetup = false;
@@ -317,12 +218,21 @@
317
  setupCountControl();
318
  render();
319
  setupCopyDelegation();
 
 
320
  const mo = new MutationObserver(() => render());
321
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
 
 
 
 
 
 
 
322
  };
323
 
324
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureChroma(bootstrap), { once: true });
325
- else ensureChroma(bootstrap);
326
  })();
327
  </script>
328
 
 
8
  .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
9
  .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
10
  .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
11
+ .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 0; }
12
  .palettes .palette-card__content__info { display: flex; flex-direction: column; }
13
  .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
14
  .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
 
97
  </div>
98
  <script>
99
  (() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  const cards = [
101
+ { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.' },
102
+ { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
103
+ { key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base → white → complement</strong>; smooth contrast around a neutral midpoint.' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  ];
105
 
106
+ const getCssVar = (name) => {
107
+ try { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } catch { return ''; }
108
  };
109
 
110
+ const getPaletteColors = (key) => {
111
+ const count = Number(getCssVar(`--palette-${key}-count-current`)) || Number(getCssVar('--palette-count')) || 6;
112
+ const colors = [];
113
+ for (let i = 1; i <= count; i++) {
114
+ const v = getCssVar(`--palette-${key}-${i}`);
115
+ if (v) colors.push(v);
116
+ }
117
+ return colors;
118
  };
119
 
120
  const render = () => {
 
123
  if (!root) return;
124
  const grid = root.querySelector('.palettes__grid');
125
  if (!grid) return;
 
 
 
 
 
126
  const html = cards.map((c) => {
127
+ const colors = getPaletteColors(c.key);
128
  const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
129
  return `
130
  <div class="palette-card" data-colors="${colors.join(',')}">
 
143
  `;
144
  }).join('');
145
  grid.innerHTML = html;
146
+ };
147
 
148
  const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
149
  const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
 
172
  const input = document.getElementById('color-count');
173
  const out = document.getElementById('color-count-out');
174
  if (!input) return;
175
+ const read = () => {
176
+ const v = Number(getCssVar('--palette-count')) || 6;
177
+ return Math.max(6, Math.min(10, v));
178
+ };
179
+ const write = (v) => {
180
+ document.documentElement.style.setProperty('--palette-count', String(v));
181
+ if (window.ColorPalettes && typeof window.ColorPalettes.refresh === 'function') window.ColorPalettes.refresh();
182
+ };
183
+ const syncOut = () => { if (out) out.textContent = String(read()); };
184
+ const syncInput = () => { try { input.value = String(read()); } catch {} };
185
+ syncInput(); syncOut();
186
+ input.addEventListener('input', () => { write(input.value); syncOut(); });
187
+ document.addEventListener('palettes:updated', () => { syncInput(); syncOut(); render(); });
188
  };
189
 
190
  let copyDelegationSetup = false;
 
218
  setupCountControl();
219
  render();
220
  setupCopyDelegation();
221
+ // Re-render when global palettes update and when :root style changes
222
+ document.addEventListener('palettes:updated', render);
223
  const mo = new MutationObserver(() => render());
224
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
225
+
226
+ // Fallback: if global script loads after this fragment and we missed the event
227
+ const waitForPalettes = () => {
228
+ if (getCssVar('--palette-categorical-1')) { render(); return; }
229
+ setTimeout(waitForPalettes, 50);
230
+ };
231
+ waitForPalettes();
232
  };
233
 
234
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
235
+ else bootstrap();
236
  })();
237
  </script>
238
 
app/src/content/embeds/vibe-code-d3-embeds-directives.md ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Embed Chart Authoring Guidelines
2
+
3
+ Authoring rules for creating a new interactive chart as a single self-contained `.html` file under `src/content/embeds/`. These conventions are derived from `d3-bar.html`, `d3-comparison.html`, `d3-neural.html`, `d3-line.html`, and `d3-pie.html`.
4
+
5
+ ### 1) File, naming, and structure
6
+ - Name files with a clear prefix and purpose: `d3-<type>.html` (e.g., `d3-scatter.html`).
7
+ - Wrap everything in a single `<div class="<root-class>">`, a `<style>` block scoped to that root class, and a `<script>` IIFE.
8
+ - Do not leak globals; do not attach anything to `window`.
9
+ - Use a unique, descriptive root class (e.g., `.d3-scatter`).
10
+
11
+ Minimal skeleton:
12
+ ```html
13
+ <div class="d3-yourchart" style="width:100%;margin:10px 0;"></div>
14
+ <style>
15
+ .d3-yourchart {/* all styles scoped to the root */}
16
+ </style>
17
+ <script>
18
+ (() => {
19
+ // Optional dependency loader (e.g., D3)
20
+ const ensureD3 = (cb) => {
21
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
22
+ let s = document.getElementById('d3-cdn-script');
23
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
24
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
25
+ s.addEventListener('load', onReady, { once: true });
26
+ if (window.d3) onReady();
27
+ };
28
+
29
+ const bootstrap = () => {
30
+ const scriptEl = document.currentScript;
31
+ // Prefer the closest previous sibling with the root class
32
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
33
+ if (!(container && container.classList && container.classList.contains('d3-yourchart'))) {
34
+ // Fallback: pick the last unmounted instance in the page
35
+ const candidates = Array.from(document.querySelectorAll('.d3-yourchart'))
36
+ .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
37
+ container = candidates[candidates.length - 1] || null;
38
+ }
39
+ if (!container) return;
40
+ if (container.dataset) {
41
+ if (container.dataset.mounted === 'true') return;
42
+ container.dataset.mounted = 'true';
43
+ }
44
+
45
+ // Tooltip (optional)
46
+ container.style.position = container.style.position || 'relative';
47
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
48
+ if (!tip) {
49
+ tip = document.createElement('div'); tip.className = 'd3-tooltip';
50
+ Object.assign(tip.style, { position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none', padding:'8px 10px', borderRadius:'8px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)', background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 4px 24px rgba(0,0,0,.18)', opacity:'0', transition:'opacity .12s ease' });
51
+ tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip);
52
+ } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
53
+
54
+ // SVG scaffolding (if using D3)
55
+ const svg = d3.select(container).append('svg').attr('width','100%').style('display','block');
56
+ const gRoot = svg.append('g');
57
+
58
+ // State & layout
59
+ let width = 800, height = 360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
60
+ function updateSize(){
61
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
62
+ width = container.clientWidth || 800;
63
+ height = Math.max(260, Math.round(width / 3));
64
+ svg.attr('width', width).attr('height', height);
65
+ gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
66
+ return { innerWidth: width - margin.left - margin.right, innerHeight: height - margin.top - margin.bottom, isDark };
67
+ }
68
+
69
+ function render(){
70
+ const { innerWidth, innerHeight } = updateSize();
71
+ // ... draw/update your chart here using data joins
72
+ }
73
+
74
+ // Initial render + resize handling
75
+ render();
76
+ const rerender = () => render();
77
+ if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); }
78
+ else { window.addEventListener('resize', rerender); }
79
+ };
80
+
81
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); }
82
+ else { ensureD3(bootstrap); }
83
+ })();
84
+ </script>
85
+ ```
86
+
87
+ ### 2) Mounting and re-entrancy
88
+ - Select the closest previous sibling with the root class; fallback to the last unmounted matching element in the document.
89
+ - Gate with `data-mounted` to avoid double-initialization when the fragment re-runs.
90
+ - Assume the chart can appear multiple times on the same page.
91
+
92
+ ### 3) Styling and theming
93
+ - Scope all rules under the root class; do not style `body`, `svg` globally.
94
+ - Use CSS variables for theme alignment: `--primary-color`, `--text-color`, `--muted-color`, `--surface-bg`, `--border-color`.
95
+ - For dark mode–aware strokes/ticks, either:
96
+ - Read `document.documentElement.getAttribute('data-theme') === 'dark'`, or
97
+ - Prefer CSS-only where possible.
98
+ - Keep backgrounds light and borders subtle; the outer card frame is handled by `HtmlEmbed.astro`.
99
+
100
+ ### 4) Controls (labels, selects, sliders)
101
+ - Compose controls as plain HTML elements appended inside the root container.
102
+ - Style selects like in `d3-line.html`/`d3-bar.html` for consistency (rounded 8px, custom caret via data-URI, focus ring).
103
+ - Use `<label>` wrapping the input for accessibility; set concise text (e.g., "Metric", "Model Size").
104
+
105
+ ### 5) Tooltip pattern
106
+ - Create a single `.d3-tooltip` absolutely positioned inside the container.
107
+ - Show on hover, hide on leave; position using `d3.pointer(event, container)` plus a small offset.
108
+ - Keep content in a `.d3-tooltip__inner` node; avoid large inner HTML.
109
+
110
+ ### 6) Data loading
111
+ - Prefer public assets first, then fall back to content assets:
112
+ - Example CSV paths: `/data/<file>.csv`, then `./assets/data/<file>.csv`, `../assets/data/<file>.csv`, etc.
113
+ - Implement `fetchFirstAvailable(paths)`; try in order with `cache:'no-cache'`; handle errors gracefully with a red `<pre>` message.
114
+ - For images or JSON models, mirror the same approach (see `d3-comparison.html`, `d3-neural.html`).
115
+
116
+ ### 7) Responsiveness and layout
117
+ - Compute `width = container.clientWidth`, and a height derived from width (e.g., `width / 3`), with a sensible minimum height.
118
+ - Maintain a `margin` object and derive `innerWidth/innerHeight` for plots.
119
+ - Use a `ResizeObserver` on the container; fallback to `window.resize`.
120
+ - Recompute scales/axes/grid on every render.
121
+
122
+ ### 8) Legends and labels
123
+ - Use `foreignObject` + inline HTML to render compact legends that wrap nicely (see `d3-line.html`, `d3-pie.html`).
124
+ - For axes, remove and re-append groups each render (simple and predictable), or update in place if needed.
125
+ - Always add axis labels when applicable (e.g., `Step`, `Value`).
126
+
127
+ ### 9) Accessibility
128
+ - Provide `alt` attributes on `<img>` (see `d3-comparison.html`).
129
+ - Provide `aria-label` on interactive buttons (e.g., the erase button in `d3-neural.html`).
130
+ - Ensure focus-visible styles for interactive controls; avoid relying on color alone to encode meaning.
131
+
132
+ ### 10) Performance and updates
133
+ - Use D3 data joins (`.data().join()` or explicit enter/merge/exit) and keep transitions short (≤200ms).
134
+ - Recompute only what is necessary on each render; avoid repeated DOM clears if not needed.
135
+ - Debounce or gate expensive computations, especially on `mousemove`.
136
+
137
+ ### 11) External dependencies
138
+ - Load D3 (and optional TFJS) via CDN only once using an element id (e.g., `d3-cdn-script`, `tfjs-cdn-script`).
139
+ - After `.load`, verify the expected API (e.g., `window.d3.select`).
140
+ - Prefer pure D3 and built-ins; do not introduce new runtime dependencies unless necessary.
141
+
142
+ ### 12) Error handling and fallbacks
143
+ - Fail gracefully: append a small `<pre>` with a readable message inside the container.
144
+ - For optional models (e.g., TFJS), attempt multiple URLs and fall back to a heuristic if load fails.
145
+
146
+ ### 13) Printing
147
+ - Favor vector (`svg`) or simple shapes; avoid large bitmap backgrounds.
148
+ - Let `HtmlEmbed.astro` handle most print constraints; ensure the chart scales with width 100% and auto height.
149
+
150
+ ### 14) Conventions checklist (before committing)
151
+ - Root class is unique and matches file name (`d3-<type>`).
152
+ - No globals added; script wrapped in an IIFE.
153
+ - `data-mounded` guard is present to avoid double-mount.
154
+ - Uses CSS variables for colors; dark-mode friendly.
155
+ - Responsive: recomputes layout on resize; uses `ResizeObserver`.
156
+ - Controls are accessible and consistently styled.
157
+ - Tooltip is present (if hover/inspect is required).
158
+ - Data loading includes public-path-first strategy and graceful error.
159
+ - Axes/labels/legends are legible at small widths.
160
+ - Code is easy to skim: clear naming, early returns, short functions.
161
+
162
+ ### 15) Example: small bar chart (structure only)
163
+ ```html
164
+ <div class="d3-mini-bar" style="width:100%;margin:10px 0;"></div>
165
+ <style>
166
+ .d3-mini-bar .bar { stroke: none; }
167
+ </style>
168
+ <script>
169
+ (() => {
170
+ const ensureD3 = (cb) => {
171
+ if (window.d3 && d3.select) return cb();
172
+ let s = document.getElementById('d3-cdn-script');
173
+ if (!s) { s = document.createElement('script'); s.id='d3-cdn-script'; s.src='https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
174
+ const onReady = () => { if (window.d3 && d3.select) cb(); };
175
+ s.addEventListener('load', onReady, { once:true }); if (window.d3) onReady();
176
+ };
177
+ const bootstrap = () => {
178
+ const scriptEl = document.currentScript;
179
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
180
+ if (!(container && container.classList && container.classList.contains('d3-mini-bar'))){
181
+ const cs = Array.from(document.querySelectorAll('.d3-mini-bar')).filter(el => !(el.dataset && el.dataset.mounted==='true'));
182
+ container = cs[cs.length-1] || null;
183
+ }
184
+ if (!container) return;
185
+ if (container.dataset){ if (container.dataset.mounted==='true') return; container.dataset.mounted='true'; }
186
+
187
+ const svg = d3.select(container).append('svg').attr('width','100%').style('display','block');
188
+ const g = svg.append('g');
189
+ let width=800,height=280; const margin={top:16,right:16,bottom:40,left:40};
190
+ const x=d3.scaleBand().padding(0.2), y=d3.scaleLinear();
191
+ const data=[{k:'A',v:3},{k:'B',v:7},{k:'C',v:5}];
192
+
193
+ function render(){
194
+ width = container.clientWidth || 800; height = Math.max(220, Math.round(width/3.2));
195
+ svg.attr('width', width).attr('height', height);
196
+ g.attr('transform',`translate(${margin.left},${margin.top})`);
197
+ const iw=width-margin.left-margin.right, ih=height-margin.top-margin.bottom;
198
+ x.domain(data.map(d=>d.k)).range([0,iw]); y.domain([0, d3.max(data,d=>d.v)||1]).range([ih,0]).nice();
199
+ const bars=g.selectAll('rect.bar').data(data);
200
+ bars.join('rect').attr('class','bar').attr('x',d=>x(d.k)).attr('y',d=>y(d.v)).attr('width',x.bandwidth()).attr('height',d=>Math.max(0.5, ih - y(d.v))).attr('fill','var(--primary-color)');
201
+ g.selectAll('.x').data([0]).join('g').attr('class','x').attr('transform',`translate(0,${ih})`).call(d3.axisBottom(x));
202
+ g.selectAll('.y').data([0]).join('g').attr('class','y').call(d3.axisLeft(y).ticks(5));
203
+ }
204
+ render();
205
+ const ro = window.ResizeObserver ? new ResizeObserver(() => render()) : null; if (ro) ro.observe(container); else window.addEventListener('resize', render);
206
+ };
207
+ if (document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once:true }); } else { ensureD3(bootstrap); }
208
+ })();
209
+ </script>
210
+ ```
211
+
212
+
app/src/env.d.ts CHANGED
@@ -1,6 +1,13 @@
1
  /// <reference path="../.astro/types.d.ts" />
2
  /// <reference types="astro/client" />
 
3
 
4
- declare module 'astro:assets' {
5
- export const Image: any;
 
 
 
 
 
 
6
  }
 
1
  /// <reference path="../.astro/types.d.ts" />
2
  /// <reference types="astro/client" />
3
+ /// <reference types="vite/client" />
4
 
5
+ interface ImportMetaEnv {
6
+ readonly PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE?: string | boolean;
7
+ // Back-compat
8
+ readonly PUBLIC_TOC_AUTO_COLLAPSE?: string | boolean;
9
+ }
10
+
11
+ interface ImportMeta {
12
+ readonly env: ImportMetaEnv;
13
  }
app/src/pages/index.astro CHANGED
@@ -4,6 +4,7 @@ import Hero from '../components/Hero.astro';
4
  import Footer from '../components/Footer.astro';
5
  import ThemeToggle from '../components/ThemeToggle.astro';
6
  import Seo from '../components/Seo.astro';
 
7
  // Default OG image served from public/
8
  const ogDefaultUrl = '/thumb.jpg';
9
  import 'katex/dist/katex.min.css';
@@ -50,8 +51,21 @@ const keyAuthor = (authors[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCas
50
  const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
51
  const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
52
  const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ---
54
- <html lang="en" data-theme="light">
55
  <head>
56
  <meta charset="utf-8" />
57
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -66,25 +80,20 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
66
  } catch {}
67
  })();
68
  </script>
 
69
 
70
  <!-- TO MANAGE PROPERLY -->
71
  <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
72
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
73
- <script type="module" src="https://gradio.s3-us-west-2.amazonaws.com/4.4.0/gradio.js"> </script>
 
74
  </head>
75
  <body>
76
  <ThemeToggle />
77
  <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
78
 
79
  <section class="content-grid">
80
- <aside class="toc">
81
- <div class="title">Table of Contents</div>
82
- <div id="article-toc-placeholder"></div>
83
- </aside>
84
- <details class="toc-mobile">
85
- <summary>Table of Contents</summary>
86
- <div id="article-toc-mobile-placeholder"></div>
87
- </details>
88
  <main>
89
  <Article />
90
  <style is:inline>
@@ -96,185 +105,6 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
96
 
97
  <Footer citationText={citationText} bibtex={bibtex} />
98
 
99
- <!-- Medium-like image zoom (lightbox) -->
100
- <script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
101
- <script>
102
- // Initialize zoom on img[data-zoomable]; wait for script & content; close on scroll like Medium
103
- (() => {
104
- /** @type {any} */
105
- let zoomInstance = null;
106
-
107
- /** @param {() => void} cb */
108
- const ensureMediumZoomReady = (cb) => {
109
- // @ts-ignore mediumZoom injected globally by external script
110
- if (window.mediumZoom) return cb();
111
- // @ts-ignore mediumZoom injected globally by external script
112
- const retry = () => (window.mediumZoom ? cb() : setTimeout(retry, 30));
113
- retry();
114
- };
115
-
116
- /** @returns {HTMLElement[]} */
117
- const collectTargets = () => Array.from(document.querySelectorAll('section.content-grid main img[data-zoomable]'));
118
-
119
- const initOrUpdateZoom = () => {
120
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
121
- const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
122
- const targets = collectTargets();
123
- if (!targets.length) return;
124
-
125
- if (!zoomInstance) {
126
- // @ts-ignore medium-zoom injected globally by external script
127
- zoomInstance = window.mediumZoom(targets, { background, margin: 24, scrollOffset: 0 });
128
-
129
- let onScrollLike;
130
- const attachCloseOnScroll = () => {
131
- if (onScrollLike) return;
132
- // @ts-ignore medium-zoom instance has close()
133
- onScrollLike = () => { zoomInstance && zoomInstance.close(); };
134
- window.addEventListener('wheel', onScrollLike, { passive: true });
135
- window.addEventListener('touchmove', onScrollLike, { passive: true });
136
- window.addEventListener('scroll', onScrollLike, { passive: true });
137
- };
138
- const detachCloseOnScroll = () => {
139
- if (!onScrollLike) return;
140
- window.removeEventListener('wheel', onScrollLike);
141
- window.removeEventListener('touchmove', onScrollLike);
142
- window.removeEventListener('scroll', onScrollLike);
143
- onScrollLike = null;
144
- };
145
- // @ts-ignore medium-zoom instance has on()
146
- zoomInstance.on('open', attachCloseOnScroll);
147
- // @ts-ignore medium-zoom instance has on()
148
- zoomInstance.on('close', detachCloseOnScroll);
149
-
150
- const themeObserver = new MutationObserver(() => {
151
- const dark = document.documentElement.getAttribute('data-theme') === 'dark';
152
- // @ts-ignore medium-zoom instance has update()
153
- zoomInstance && zoomInstance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' });
154
- });
155
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
156
- } else {
157
- // @ts-ignore medium-zoom instance has attach()/update()
158
- zoomInstance.attach(targets);
159
- // @ts-ignore medium-zoom instance has update()
160
- zoomInstance.update({ background });
161
- }
162
- };
163
-
164
- const bootstrap = () => ensureMediumZoomReady(() => {
165
- initOrUpdateZoom();
166
- setTimeout(initOrUpdateZoom, 0);
167
- const main = document.querySelector('section.content-grid main');
168
- if (main) {
169
- const mo = new MutationObserver(() => initOrUpdateZoom());
170
- mo.observe(main, { childList: true, subtree: true });
171
- }
172
- });
173
-
174
- if (document.readyState === 'complete') bootstrap();
175
- else window.addEventListener('load', bootstrap, { once: true });
176
- })();
177
- </script>
178
-
179
-
180
- <script>
181
- // Add a small download button to img[data-downloadable]
182
- (() => {
183
- const SELECTOR = 'section.content-grid main img[data-downloadable]';
184
-
185
- /**
186
- * @param {HTMLImageElement} img
187
- */
188
- const injectDownloadButton = (img) => {
189
- if (!img || img.dataset.__dlInjected) return;
190
- const parentFigure = img.closest('figure');
191
- const parent = img.parentElement;
192
- if (!parent) return;
193
- img.dataset.__dlInjected = '1';
194
-
195
- // Wrap the image in a positioned inline-block so the button is on the image
196
- const wrapper = document.createElement('span');
197
- wrapper.className = 'img-dl-wrap';
198
- parent.insertBefore(wrapper, img);
199
- wrapper.appendChild(img);
200
- if (parentFigure && !parentFigure.classList.contains('has-dl-btn')) {
201
- parentFigure.classList.add('has-dl-btn');
202
- }
203
-
204
- // Determine download href and filename
205
- const pickHrefAndName = () => {
206
- const current = img.currentSrc || img.src || '';
207
- let href = img.getAttribute('data-download-src') || current;
208
- // Derive filename from the original source when possible
209
- const deriveName = () => {
210
- try {
211
- const u = new URL(current, location.href);
212
- // Prefer original href param if provided by Astro image service
213
- const rawHref = u.searchParams.get('href');
214
- const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
215
- const last = String(candidate).split('/').pop() || '';
216
- // Strip query/hash and any appended transform suffixes after extension
217
- const base = last.split('?')[0].split('#')[0];
218
- const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
219
- if (m && m[1]) return m[1];
220
- // If extension missing, fallback to base as-is
221
- return base || 'image';
222
- } catch {
223
- return 'image';
224
- }
225
- };
226
- const name = img.getAttribute('data-download-name') || deriveName();
227
- return { href, name };
228
- };
229
-
230
- const { href, name } = pickHrefAndName();
231
- const a = document.createElement('a');
232
- a.className = 'button button--ghost img-dl-btn';
233
- a.href = href;
234
- if (name) a.download = name;
235
- a.setAttribute('aria-label', 'Download image');
236
- a.setAttribute('title', name ? `Download ${name}` : 'Download image');
237
- a.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>';
238
-
239
- // Ensure href/name match currentSrc right before navigation
240
- a.addEventListener('click', async (ev) => {
241
- try {
242
- ev.preventDefault();
243
- const picked = pickHrefAndName();
244
- const res = await fetch(picked.href, { credentials: 'same-origin' });
245
- const blob = await res.blob();
246
- const objectUrl = URL.createObjectURL(blob);
247
- const tmp = document.createElement('a');
248
- tmp.href = objectUrl;
249
- tmp.download = picked.name || 'image';
250
- document.body.appendChild(tmp);
251
- tmp.click();
252
- setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
253
- } catch {
254
- // Fallback to native behavior if fetch fails
255
- }
256
- });
257
-
258
- // Append inside wrapper so positioning is relative to the image box
259
- wrapper.appendChild(a);
260
- };
261
-
262
- const scan = () => {
263
- document.querySelectorAll(SELECTOR).forEach((el) => injectDownloadButton(el));
264
- };
265
-
266
- const bootstrap = () => {
267
- scan();
268
- const main = document.querySelector('section.content-grid main');
269
- if (!main) return;
270
- const mo = new MutationObserver(() => scan());
271
- mo.observe(main, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
272
- };
273
-
274
- if (document.readyState === 'complete') bootstrap();
275
- else window.addEventListener('load', bootstrap, { once: true });
276
- })();
277
- </script>
278
 
279
  <script>
280
  // Open external links in a new tab; keep internal anchors in-page
@@ -320,119 +150,7 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
320
  });
321
  </script>
322
 
323
- <script>
324
- // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
325
- const buildTOC = () => {
326
- const holder = document.getElementById('article-toc-placeholder');
327
- const holderMobile = document.getElementById('article-toc-mobile-placeholder');
328
- // Always rebuild TOC to avoid stale entries
329
- if (holder) holder.innerHTML = '';
330
- if (holderMobile) holderMobile.innerHTML = '';
331
- const articleRoot = document.querySelector('section.content-grid main');
332
- if (!articleRoot) return;
333
- const headings = articleRoot.querySelectorAll('h2, h3, h4');
334
- if (!headings.length) return;
335
-
336
- // Filter out headings that should not appear in TOC
337
- const normalize = (s) => String(s || '')
338
- .toLowerCase()
339
- .replace(/[^a-z0-9]+/g, ' ')
340
- .trim();
341
- const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
342
- const shouldSkip = (h) => {
343
- const t = h.textContent || '';
344
- const id = String(h.id || '');
345
- const slug = normalize(t).replace(/\s+/g, '_');
346
- if (isTocLabel(t)) return true;
347
- if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
348
- if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
349
- return false;
350
- };
351
- const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
352
- if (!headingsArr.length) return;
353
-
354
- // Ensure unique ids for headings (deduplicate duplicates)
355
- const usedIds = new Set<string>();
356
- const slugify = (s: string) => String(s || '')
357
- .toLowerCase()
358
- .trim()
359
- .replace(/\s+/g, '_')
360
- .replace(/[^a-z0-9_\-]/g, '');
361
- headingsArr.forEach((h) => {
362
- let id = (h.id || '').trim();
363
- if (!id) {
364
- const base = slugify(h.textContent || '');
365
- id = base || 'section';
366
- }
367
- let candidate = id;
368
- let n = 2;
369
- while (usedIds.has(candidate)) {
370
- candidate = `${id}-${n++}`;
371
- }
372
- if (h.id !== candidate) h.id = candidate;
373
- usedIds.add(candidate);
374
- });
375
-
376
- const nav = document.createElement('nav');
377
- let ulStack = [document.createElement('ul')];
378
- nav.appendChild(ulStack[0]);
379
-
380
- const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
381
- let prev = 2;
382
- headingsArr.forEach((h) => {
383
- const lvl = levelOf(h.tagName);
384
- // adjust depth
385
- while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
386
- while (lvl < prev) { ulStack.pop(); prev--; }
387
- const li = document.createElement('li');
388
- const a = document.createElement('a');
389
- a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';
390
- li.appendChild(a);
391
- ulStack[ulStack.length-1].appendChild(li);
392
- });
393
-
394
- if (holder) holder.appendChild(nav);
395
- if (holderMobile) holderMobile.appendChild(nav.cloneNode(true));
396
-
397
- // active link on scroll
398
- const links = [
399
- ...(holder ? holder.querySelectorAll('a') : []),
400
- ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
401
- ];
402
- const onScroll = () => {
403
- for (let i = headingsArr.length - 1; i >= 0; i--) {
404
- const top = headingsArr[i].getBoundingClientRect().top;
405
- if (top - 60 <= 0) {
406
- links.forEach(l => l.classList.remove('active'));
407
- const id = '#' + headingsArr[i].id;
408
- const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
409
- actives.forEach(a => a.classList.add('active'));
410
- break;
411
- }
412
- }
413
- };
414
- window.addEventListener('scroll', onScroll);
415
- onScroll();
416
-
417
- // Close mobile accordion when a link inside it is clicked
418
- if (holderMobile) {
419
- const details = holderMobile.closest('details');
420
- holderMobile.addEventListener('click', (ev) => {
421
- const target = ev.target as Element | null;
422
- const anchor = target && 'closest' in target ? (target as Element).closest('a') : null;
423
- if (anchor instanceof HTMLAnchorElement && details && (details as HTMLDetailsElement).open) {
424
- (details as HTMLDetailsElement).open = false;
425
- }
426
- });
427
- }
428
- };
429
-
430
- if (document.readyState === 'loading') {
431
- document.addEventListener('DOMContentLoaded', buildTOC, { once: true });
432
- } else { buildTOC(); }
433
- </script>
434
-
435
- <!-- Removed JS fallback for language chips; labels handled by CSS/Shiki -->
436
  </body>
437
  </html>
438
 
 
4
  import Footer from '../components/Footer.astro';
5
  import ThemeToggle from '../components/ThemeToggle.astro';
6
  import Seo from '../components/Seo.astro';
7
+ import TableOfContent from '../components/TableOfContent.astro';
8
  // Default OG image served from public/
9
  const ogDefaultUrl = '/thumb.jpg';
10
  import 'katex/dist/katex.min.css';
 
51
  const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
52
  const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
53
  const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
54
+ // Determine if TOC auto-collapse is enabled via (priority):
55
+ // 1) article frontmatter: tableOfContentAutoCollapse (fallback: tocAutoCollapse)
56
+ // 2) public env flag: PUBLIC_TOC_AUTO_COLLAPSE
57
+ // Falls back to 'false' when not defined (string or boolean)
58
+ const __env = (import.meta as any)?.env || {};
59
+ const envCollapse = (
60
+ String((__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE ?? __env.PUBLIC_TOC_AUTO_COLLAPSE ?? 'false')).toLowerCase() === 'true'
61
+ || (__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE === true)
62
+ || (__env.PUBLIC_TOC_AUTO_COLLAPSE === true)
63
+ );
64
+ const tableOfContentAutoCollapse = Boolean(
65
+ (articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tocAutoCollapse ?? envCollapse
66
+ );
67
  ---
68
+ <html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
69
  <head>
70
  <meta charset="utf-8" />
71
  <meta name="viewport" content="width=device-width, initial-scale=1" />
 
80
  } catch {}
81
  })();
82
  </script>
83
+ <script is:inline src="/scripts/color-palettes.js"></script>
84
 
85
  <!-- TO MANAGE PROPERLY -->
86
  <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
87
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
88
+ <script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
89
+
90
  </head>
91
  <body>
92
  <ThemeToggle />
93
  <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
94
 
95
  <section class="content-grid">
96
+ <TableOfContent tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
 
 
 
 
 
 
 
97
  <main>
98
  <Article />
99
  <style is:inline>
 
105
 
106
  <Footer citationText={citationText} bibtex={bibtex} />
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  <script>
110
  // Open external links in a new tab; keep internal anchors in-page
 
150
  });
151
  </script>
152
 
153
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  </body>
155
  </html>
156
 
app/src/styles/_variables.css CHANGED
@@ -34,6 +34,9 @@
34
  --spacing-9: 64px;
35
  --spacing-10: 72px;
36
 
 
 
 
37
  /* Z-index scale */
38
  --z-base: 0; /* background/base */
39
  --z-content: 1; /* regular content */
 
34
  --spacing-9: 64px;
35
  --spacing-10: 72px;
36
 
37
+ /* Palettes config */
38
+ --palette-count: 6; /* default number of colors per palette */
39
+
40
  /* Z-index scale */
41
  --z-base: 0; /* background/base */
42
  --z-content: 1; /* regular content */