thibaud frere commited on
Commit
6b70860
Β·
1 Parent(s): ad5db07

update doc, add some new components

Browse files
app/.astro/astro/content.d.ts CHANGED
@@ -1,227 +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
- "01_introduction.mdx": {
156
- id: "01_introduction.mdx";
157
- slug: "01_introduction";
158
- body: string;
159
- collection: "chapters";
160
- data: any
161
- } & { render(): Render[".mdx"] };
162
- "02_classic_robotics.mdx": {
163
- id: "02_classic_robotics.mdx";
164
- slug: "02_classic_robotics";
165
- body: string;
166
- collection: "chapters";
167
- data: any
168
- } & { render(): Render[".mdx"] };
169
- "03_reinforcement_learning.mdx": {
170
- id: "03_reinforcement_learning.mdx";
171
- slug: "03_reinforcement_learning";
172
- body: string;
173
- collection: "chapters";
174
- data: any
175
- } & { render(): Render[".mdx"] };
176
- "04_imitation_learning.mdx": {
177
- id: "04_imitation_learning.mdx";
178
- slug: "04_imitation_learning";
179
- body: string;
180
- collection: "chapters";
181
- data: any
182
- } & { render(): Render[".mdx"] };
183
- "05_foundation_models.mdx": {
184
- id: "05_foundation_models.mdx";
185
- slug: "05_foundation_models";
186
- body: string;
187
- collection: "chapters";
188
- data: any
189
- } & { render(): Render[".mdx"] };
190
- "06_next_directions.mdx": {
191
- id: "06_next_directions.mdx";
192
- slug: "06_next_directions";
193
- body: string;
194
- collection: "chapters";
195
- data: any
196
- } & { render(): Render[".mdx"] };
197
- "07_conclusions.mdx": {
198
- id: "07_conclusions.mdx";
199
- slug: "07_conclusions";
200
- body: string;
201
- collection: "chapters";
202
- data: any
203
- } & { render(): Render[".mdx"] };
204
- "A_foreword.mdx": {
205
- id: "A_foreword.mdx";
206
- slug: "a_foreword";
207
- body: string;
208
- collection: "chapters";
209
- data: any
210
- } & { render(): Render[".mdx"] };
211
- };
212
-
213
- };
214
-
215
- type DataEntryMap = {
216
- "assets": Record<string, {
217
- id: string;
218
- collection: "assets";
219
- data: any;
220
- }>;
221
-
222
- };
223
-
224
- type AnyEntryMap = ContentEntryMap & DataEntryMap;
225
-
226
- export type ContentConfig = never;
227
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/.astro/settings.json CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:ed6d28be38b13c36af0d93f09ca03071e80381d49463aa549a5ee625ef9a8b56
3
  size 58
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5cd907e18678e90f342986642cb5fcdca79f1c4a61fe1d75caa2df036e69070e
3
  size 58
app/astro.config.mjs CHANGED
@@ -56,11 +56,13 @@ export default defineConfig({
56
  rehypePlugins: [
57
  rehypeSlug,
58
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],
59
- rehypeKatex,
 
 
60
  [rehypeCitation, {
61
  bibliography: 'src/content/bibliography.bib',
62
  linkCitations: true,
63
- csl: "apa"
64
  }],
65
  rehypeReferencesAndFootnotes,
66
  rehypeRestoreAtInCode,
 
56
  rehypePlugins: [
57
  rehypeSlug,
58
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],
59
+ [rehypeKatex, {
60
+ trust: true,
61
+ }],
62
  [rehypeCitation, {
63
  bibliography: 'src/content/bibliography.bib',
64
  linkCitations: true,
65
+ csl: "apa",
66
  }],
67
  rehypeReferencesAndFootnotes,
68
  rehypeRestoreAtInCode,
app/src/components/MultiImage.astro ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ // @ts-ignore - types provided by Astro at runtime
3
+ import ResponsiveImage from "./ResponsiveImage.astro";
4
+
5
+ interface ImageItem {
6
+ /** Source image imported via astro:assets */
7
+ src: any;
8
+ /** Alt text for accessibility */
9
+ alt: string;
10
+ /** Individual caption for this image */
11
+ caption?: string;
12
+ /** Optional individual image ID for referencing */
13
+ id?: string;
14
+ /** Enable zoom on this specific image (defaults to parent zoomable setting) */
15
+ zoomable?: boolean;
16
+ /** Enable download on this specific image (defaults to parent downloadable setting) */
17
+ downloadable?: boolean;
18
+ }
19
+
20
+ interface Props {
21
+ /** Array of images to display */
22
+ images: ImageItem[];
23
+ /** Global caption for the entire figure */
24
+ caption?: string;
25
+ /** Layout mode: number of columns or 'auto' for responsive */
26
+ layout?: "2-column" | "3-column" | "4-column" | "auto";
27
+ /** Enable medium-zoom behavior on all images (can be overridden per image) */
28
+ zoomable?: boolean;
29
+ /** Show download buttons on all images (can be overridden per image) */
30
+ downloadable?: boolean;
31
+ /** Optional class to apply on the wrapper */
32
+ class?: string;
33
+ /** Optional global ID for the multi-image figure */
34
+ id?: string;
35
+ }
36
+
37
+ const {
38
+ images,
39
+ caption,
40
+ layout = "3-column",
41
+ zoomable = false,
42
+ downloadable = false,
43
+ class: className,
44
+ id,
45
+ } = Astro.props as Props;
46
+
47
+ const hasCaptionSlot = Astro.slots.has("caption");
48
+ const hasCaption =
49
+ hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
50
+ const uid = `mi_${Math.random().toString(36).slice(2)}`;
51
+
52
+ // Generate CSS grid columns based on layout
53
+ const getGridColumns = () => {
54
+ switch (layout) {
55
+ case "2-column":
56
+ return "repeat(2, 1fr)";
57
+ case "3-column":
58
+ return "repeat(3, 1fr)";
59
+ case "4-column":
60
+ return "repeat(4, 1fr)";
61
+ case "auto":
62
+ return "repeat(auto-fit, minmax(200px, 1fr))";
63
+ default:
64
+ return "repeat(3, 1fr)";
65
+ }
66
+ };
67
+
68
+ const gridColumns = getGridColumns();
69
+ ---
70
+
71
+ <div
72
+ class={`multi-image ${className || ""}`}
73
+ data-mi-root={uid}
74
+ data-layout={layout}
75
+ {id}
76
+ >
77
+ {
78
+ hasCaption ? (
79
+ <figure class="multi-image-figure">
80
+ <div
81
+ class="multi-image-grid"
82
+ style={`grid-template-columns: ${gridColumns}`}
83
+ >
84
+ {images.map((image, index) => (
85
+ <div class="multi-image-item">
86
+ <ResponsiveImage
87
+ src={image.src}
88
+ alt={image.alt}
89
+ zoomable={image.zoomable ?? zoomable}
90
+ downloadable={
91
+ image.downloadable ?? downloadable
92
+ }
93
+ class="multi-image-img"
94
+ />
95
+ {image.caption && (
96
+ <div class="multi-image-subcaption">
97
+ {image.caption}
98
+ </div>
99
+ )}
100
+ {image.id && (
101
+ <span
102
+ id={image.id}
103
+ style="position: absolute;"
104
+ />
105
+ )}
106
+ </div>
107
+ ))}
108
+ </div>
109
+ <figcaption class="multi-image-caption">
110
+ {hasCaptionSlot ? (
111
+ <slot name="caption" />
112
+ ) : (
113
+ caption && <span set:html={caption} />
114
+ )}
115
+ </figcaption>
116
+ </figure>
117
+ ) : (
118
+ <div
119
+ class="multi-image-grid"
120
+ style={`grid-template-columns: ${gridColumns}`}
121
+ >
122
+ {images.map((image, index) => (
123
+ <div class="multi-image-item">
124
+ <ResponsiveImage
125
+ src={image.src}
126
+ alt={image.alt}
127
+ zoomable={image.zoomable ?? zoomable}
128
+ downloadable={image.downloadable ?? downloadable}
129
+ class="multi-image-img"
130
+ />
131
+ {image.caption && (
132
+ <div class="multi-image-subcaption">
133
+ {image.caption}
134
+ </div>
135
+ )}
136
+ {image.id && (
137
+ <span id={image.id} style="position: absolute;" />
138
+ )}
139
+ </div>
140
+ ))}
141
+ </div>
142
+ )
143
+ }
144
+ </div>
145
+
146
+ <style>
147
+ .multi-image {
148
+ margin: var(--block-spacing-y) 0;
149
+ }
150
+
151
+ .multi-image-figure {
152
+ margin: 0;
153
+ }
154
+
155
+ .multi-image-grid {
156
+ display: grid;
157
+ gap: 1rem;
158
+ align-items: start;
159
+ }
160
+
161
+ .multi-image-item {
162
+ display: flex;
163
+ flex-direction: column;
164
+ text-align: center;
165
+ position: relative;
166
+ z-index: var(--z-content);
167
+ transition: z-index 0.3s ease;
168
+ }
169
+
170
+ /* Quand medium-zoom est actif, masquer temporairement les autres images du multi-image */
171
+ :global(.medium-zoom--opened) .multi-image-item {
172
+ opacity: 0;
173
+ z-index: calc(var(--z-base) - 1);
174
+ transition:
175
+ opacity 0.3s ease,
176
+ z-index 0.3s ease;
177
+ }
178
+
179
+ /* Masquer spΓ©cifiquement les captions pendant le zoom - approche radicale */
180
+ :global(.medium-zoom--opened) .multi-image-subcaption {
181
+ display: none !important;
182
+ }
183
+
184
+ /* Masquer complètement tous les éléments multi-image pendant le zoom */
185
+ :global(.medium-zoom--opened) .multi-image {
186
+ z-index: -1 !important;
187
+ }
188
+
189
+ /* Masquer tous les textes de l'ensemble des multi-images */
190
+ :global(.medium-zoom--opened) .multi-image-caption {
191
+ display: none !important;
192
+ }
193
+
194
+ /* L'image actuellement zoomΓ©e reste visible */
195
+ :global(.medium-zoom--opened)
196
+ .multi-image-item:has(:global(.medium-zoom--opened)) {
197
+ opacity: 1;
198
+ z-index: var(--z-overlay);
199
+ }
200
+
201
+ /* Fallback pour navigateurs sans support :has() */
202
+ :global(.medium-zoom--opened) .multi-image-item.zoom-active {
203
+ opacity: 1 !important;
204
+ z-index: var(--z-overlay) !important;
205
+ }
206
+
207
+ /* Garder la caption de l'image active visible */
208
+ :global(.medium-zoom--opened)
209
+ .multi-image-item.zoom-active
210
+ .multi-image-subcaption {
211
+ opacity: 1 !important;
212
+ z-index: var(--z-overlay) !important;
213
+ }
214
+
215
+ .multi-image-item :global(.ri-root) {
216
+ margin: 0;
217
+ }
218
+
219
+ .multi-image-item :global(figure) {
220
+ margin: 0;
221
+ }
222
+
223
+ .multi-image-img {
224
+ width: 100%;
225
+ height: auto;
226
+ object-fit: contain;
227
+ }
228
+
229
+ .multi-image-subcaption {
230
+ font-size: 0.85rem;
231
+ color: var(--muted-color);
232
+ margin-top: 0.5rem;
233
+ line-height: 1.4;
234
+ }
235
+
236
+ .multi-image-caption {
237
+ text-align: left;
238
+ font-size: 0.9rem;
239
+ color: var(--muted-color);
240
+ margin-top: 1rem;
241
+ line-height: 1.4;
242
+ }
243
+
244
+ /* Responsive behavior */
245
+ @media (max-width: 768px) {
246
+ .multi-image-grid[style*="repeat(3, 1fr)"],
247
+ .multi-image-grid[style*="repeat(4, 1fr)"] {
248
+ grid-template-columns: 1fr !important;
249
+ gap: 1.5rem;
250
+ }
251
+
252
+ .multi-image-grid[style*="repeat(2, 1fr)"] {
253
+ grid-template-columns: 1fr !important;
254
+ gap: 1.5rem;
255
+ }
256
+ }
257
+
258
+ @media (min-width: 769px) and (max-width: 1024px) {
259
+ .multi-image-grid[style*="repeat(4, 1fr)"] {
260
+ grid-template-columns: repeat(2, 1fr) !important;
261
+ }
262
+ }
263
+
264
+ /* Equal height images when desired */
265
+ .multi-image[data-layout*="column"] .multi-image-item :global(img) {
266
+ height: 200px;
267
+ object-fit: contain;
268
+ }
269
+
270
+ /* Auto layout gets flexible heights */
271
+ .multi-image[data-layout="auto"] .multi-image-item :global(img) {
272
+ height: auto;
273
+ }
274
+
275
+ /* Ensure images maintain aspect ratio */
276
+ .multi-image-item :global(img) {
277
+ max-width: 100%;
278
+ display: block;
279
+ margin: 0 auto;
280
+ }
281
+ </style>
282
+
283
+ <script>
284
+ // Enhanced medium-zoom integration for MultiImage
285
+ document.addEventListener("DOMContentLoaded", () => {
286
+ // AmΓ©lioration du comportement des MultiImage avec medium-zoom
287
+ const multiImages = document.querySelectorAll(".multi-image");
288
+
289
+ multiImages.forEach((multiImage) => {
290
+ const items = multiImage.querySelectorAll(".multi-image-item");
291
+ const zoomableImages = multiImage.querySelectorAll(
292
+ 'img[data-zoomable="1"]',
293
+ );
294
+
295
+ zoomableImages.forEach((img) => {
296
+ img.addEventListener("click", () => {
297
+ // Trouver l'item parent de l'image cliquΓ©e et le ri-root
298
+ const activeItem = img.closest(".multi-image-item");
299
+ const riRoot = img.closest(".ri-root");
300
+
301
+ // Nettoyer TOUS les zoom-active (MultiImage items et ResponsiveImage)
302
+ document
303
+ .querySelectorAll(
304
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
305
+ )
306
+ .forEach((el) => el.classList.remove("zoom-active"));
307
+
308
+ // Ajouter zoom-active aux Γ©lΓ©ments actifs
309
+ if (activeItem) {
310
+ activeItem.classList.add("zoom-active");
311
+ }
312
+ if (riRoot) {
313
+ riRoot.classList.add("zoom-active");
314
+ }
315
+ });
316
+ });
317
+ });
318
+
319
+ // Nettoyer TOUTES les classes lors de la fermeture du zoom
320
+ document.addEventListener("click", (e) => {
321
+ if (e.target.classList.contains("medium-zoom-overlay")) {
322
+ // Zoom fermΓ©, nettoyer toutes les classes zoom-active
323
+ document
324
+ .querySelectorAll(
325
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
326
+ )
327
+ .forEach((item) => item.classList.remove("zoom-active"));
328
+ }
329
+ });
330
+
331
+ // Γ‰couter les Γ©vΓ©nements clavier pour fermer le zoom
332
+ document.addEventListener("keydown", (e) => {
333
+ if (e.key === "Escape") {
334
+ document
335
+ .querySelectorAll(
336
+ ".multi-image-item.zoom-active, .ri-root.zoom-active",
337
+ )
338
+ .forEach((item) => item.classList.remove("zoom-active"));
339
+ }
340
+ });
341
+ });
342
+ </script>
app/src/components/Quote.astro ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ author?: string;
4
+ source?: string;
5
+ }
6
+
7
+ const { author, source } = Astro.props;
8
+ ---
9
+
10
+ <blockquote class="quote">
11
+ <div class="quote__text">
12
+ <slot />
13
+ </div>
14
+ {
15
+ (author || source) && (
16
+ <footer class="quote__footer">
17
+ {author && <cite class="quote__author">{author}</cite>}
18
+ {source && <span class="quote__source">{source}</span>}
19
+ </footer>
20
+ )
21
+ }
22
+ </blockquote>
23
+
24
+ <style>
25
+ .quote {
26
+ font-family: var(--default-font-family);
27
+ margin: 32px 0;
28
+ max-width: 600px;
29
+ text-align: left;
30
+ position: relative;
31
+ padding: 0;
32
+ border: none;
33
+ background: none;
34
+ box-shadow: none;
35
+ white-space: normal;
36
+ }
37
+
38
+ .quote::before {
39
+ content: '"';
40
+ position: absolute;
41
+ top: -24px;
42
+ left: -30px;
43
+ font-size: 8rem;
44
+ font-family: var(--default-font-family);
45
+ font-weight: 400;
46
+ color: var(--text-color);
47
+ opacity: 0.05;
48
+ z-index: -1;
49
+ line-height: 1;
50
+ pointer-events: none;
51
+ }
52
+
53
+ .quote__text {
54
+ font-size: 1.5rem;
55
+ line-height: 1.4;
56
+ font-style: normal;
57
+ font-weight: 400;
58
+ font-family:
59
+ "SF Pro Text",
60
+ -apple-system,
61
+ BlinkMacSystemFont,
62
+ "Segoe UI",
63
+ sans-serif;
64
+ color: var(--text-color);
65
+ margin-bottom: 12px;
66
+ padding: 0;
67
+ letter-spacing: -0.01em;
68
+ }
69
+
70
+ .quote__text p {
71
+ margin: 0;
72
+ }
73
+
74
+ .quote__footer {
75
+ font-size: 0.875rem;
76
+ color: var(--muted-color);
77
+ display: flex;
78
+ justify-content: flex-start;
79
+ align-items: center;
80
+ gap: 6px;
81
+ margin-top: 0;
82
+ font-family:
83
+ "SF Pro Text",
84
+ -apple-system,
85
+ BlinkMacSystemFont,
86
+ "Segoe UI",
87
+ sans-serif;
88
+ }
89
+
90
+ .quote__author {
91
+ font-weight: 500;
92
+ font-style: normal;
93
+ color: var(--text-color);
94
+ opacity: 0.85;
95
+ }
96
+
97
+ .quote__author::before {
98
+ content: "β€” ";
99
+ opacity: 0.6;
100
+ }
101
+
102
+ .quote__source {
103
+ font-weight: 400;
104
+ opacity: 0.6;
105
+ font-style: italic;
106
+ }
107
+
108
+ .quote__source::before {
109
+ content: "β€” ";
110
+ font-style: normal;
111
+ }
112
+
113
+ @media (max-width: 640px) {
114
+ .quote {
115
+ margin: 24px 0;
116
+ max-width: 100%;
117
+ }
118
+
119
+ .quote::before {
120
+ font-size: 6rem;
121
+ top: -18px;
122
+ left: -16px;
123
+ }
124
+
125
+ .quote__text {
126
+ font-size: 1.25rem;
127
+ line-height: 1.45;
128
+ padding: 0;
129
+ }
130
+
131
+ .quote__footer {
132
+ flex-direction: row;
133
+ gap: 6px;
134
+ font-size: 0.8rem;
135
+ }
136
+ }
137
+ </style>
app/src/components/ResponsiveImage.astro CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
- import { Image } from 'astro:assets';
4
 
5
  interface Props {
6
  /** Source image imported via astro:assets */
@@ -29,82 +29,172 @@ interface Props {
29
  [key: string]: any;
30
  }
31
 
32
- const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, linkHref, linkTarget, linkRel, ...imgProps } = Astro.props as Props;
33
- const hasCaptionSlot = Astro.slots.has('caption');
34
- const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
 
 
 
 
 
 
 
 
 
 
 
 
35
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
36
- const dataZoomable = (zoomable === true || (imgProps as any)['data-zoomable']) ? '1' : undefined;
37
- const dataDownloadable = (downloadable === true || (imgProps as any)['data-downloadable']) ? '1' : undefined;
38
- const hasLink = typeof linkHref === 'string' && linkHref.length > 0;
39
- const resolvedTarget = hasLink ? (linkTarget || '_blank') : undefined;
40
- const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
 
 
 
 
41
  ---
 
42
  <div class="ri-root" data-ri-root={uid}>
43
- {hasCaption ? (
44
- <figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}>
45
- {dataDownloadable ? (
46
- <span class="img-dl-wrap">
47
- {hasLink ? (
48
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
49
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
50
- </a>
51
- ) : (
52
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
53
- )}
54
- <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
55
- <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>
56
- </button>
57
- </span>
58
- ) : (
59
- hasLink ? (
60
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <Image {...imgProps} data-zoomable={dataZoomable} />
62
  </a>
63
  ) : (
64
  <Image {...imgProps} data-zoomable={dataZoomable} />
65
- )
66
- )}
67
- <figcaption>
68
- {hasCaptionSlot ? (
69
- <slot name="caption" />
70
- ) : (
71
- caption && <span set:html={caption} />
72
  )}
73
- </figcaption>
74
- </figure>
75
- ) : (
76
- dataDownloadable ? (
 
 
 
 
 
77
  <span class="img-dl-wrap">
78
  {hasLink ? (
79
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
80
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
 
 
 
 
 
 
 
 
 
 
 
81
  </a>
82
  ) : (
83
- <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
 
 
 
 
 
 
84
  )}
85
- <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
86
- <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>
 
 
 
 
 
 
 
87
  </button>
88
  </span>
89
- ) : (
90
- hasLink ? (
91
- <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}>
92
- <Image {...imgProps} data-zoomable={dataZoomable} />
93
- </a>
94
- ) : (
 
95
  <Image {...imgProps} data-zoomable={dataZoomable} />
96
- )
 
 
97
  )
98
- )}
99
  </div>
100
 
101
  <script is:inline>
102
  (() => {
 
103
  const scriptEl = document.currentScript;
 
104
  const root = scriptEl ? scriptEl.previousElementSibling : null;
105
- if (!root) return;
106
- const img = (root.tagName === 'IMG' ? root : (root.querySelector ? root.querySelector('img') : null));
107
- if (!img) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  // medium-zoom integration scoped to this image only
110
  const ensureMediumZoomReady = (cb) => {
@@ -112,99 +202,174 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
112
  if (window.mediumZoom) return cb();
113
  const retry = () => {
114
  // @ts-ignore
115
- if (window.mediumZoom) cb(); else setTimeout(retry, 30);
 
116
  };
117
  retry();
118
  };
119
 
120
  const initZoomIfNeeded = () => {
121
- if (img.getAttribute('data-zoomable') !== '1') return;
122
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
123
- const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
 
 
 
 
 
 
 
124
  ensureMediumZoomReady(() => {
125
  // @ts-ignore
126
- const instance = window.mediumZoom ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 }) : null;
 
 
 
127
  if (!instance) return;
128
  let onScrollLike;
129
  const attachCloseOnScroll = () => {
130
  if (onScrollLike) return;
131
- onScrollLike = () => { try { instance.close && instance.close(); } catch {} };
132
- window.addEventListener('wheel', onScrollLike, { passive: true });
133
- window.addEventListener('touchmove', onScrollLike, { passive: true });
134
- window.addEventListener('scroll', onScrollLike, { passive: true });
 
 
 
 
135
  };
136
  const detachCloseOnScroll = () => {
137
  if (!onScrollLike) return;
138
- window.removeEventListener('wheel', onScrollLike);
139
- window.removeEventListener('touchmove', onScrollLike);
140
- window.removeEventListener('scroll', onScrollLike);
141
  onScrollLike = null;
142
  };
143
- try { instance.on && instance.on('open', attachCloseOnScroll); } catch {}
144
- try { instance.on && instance.on('close', detachCloseOnScroll); } catch {}
 
 
 
 
145
  const themeObserver = new MutationObserver(() => {
146
- const dark = document.documentElement.getAttribute('data-theme') === 'dark';
147
- try { instance.update && instance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' }); } catch {}
 
 
 
 
 
 
148
  });
149
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  });
151
  };
152
 
153
  // Download button handler
154
- const dlBtn = (root.querySelector ? root.querySelector('.img-dl-btn') : null);
155
  if (dlBtn) {
156
- dlBtn.addEventListener('click', async (ev) => {
157
  try {
158
  ev.preventDefault();
159
  ev.stopPropagation();
160
  const pickHrefAndName = () => {
161
- const current = img.currentSrc || img.src || '';
162
- let href = img.getAttribute('data-download-src') || current;
163
  const deriveName = () => {
164
  try {
165
  const u = new URL(current, location.href);
166
- const rawHref = u.searchParams.get('href');
167
- const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
168
- const last = String(candidate).split('/').pop() || '';
169
- const base = last.split('?')[0].split('#')[0];
170
- const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
 
 
 
 
171
  if (m && m[1]) return m[1];
172
- return base || 'image';
173
- } catch { return 'image'; }
 
 
174
  };
175
- const name = img.getAttribute('data-download-name') || deriveName();
176
  return { href, name };
177
  };
178
  const picked = pickHrefAndName();
179
- const res = await fetch(picked.href, { credentials: 'same-origin' });
180
  const blob = await res.blob();
181
  const objectUrl = URL.createObjectURL(blob);
182
- const tmp = document.createElement('a');
183
  tmp.href = objectUrl;
184
- tmp.download = picked.name || 'image';
185
- tmp.target = '_self';
186
- tmp.rel = 'noopener';
187
- tmp.style.display = 'none';
188
  document.body.appendChild(tmp);
189
  tmp.click();
190
- setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
 
 
 
191
  } catch {}
192
  });
193
  }
194
 
195
- if (document.readyState === 'complete') initZoomIfNeeded();
196
- else window.addEventListener('load', initZoomIfNeeded, { once: true });
 
 
 
197
  })();
198
  </script>
199
 
200
-
201
  <style>
202
-
203
- figure { margin: var(--block-spacing-y) 0; }
204
- figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
205
- figcaption { background: var(--page-bg); position: relative; z-index: var(--z-elevated); display: block; width: 100%; }
206
- .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
207
- .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
  /* Zoomable overlay container (if used by any lightbox implementation) */
210
  [data-zoom-overlay],
@@ -215,16 +380,30 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
215
  }
216
 
217
  /* Download link inside figures */
218
- figure .download-link { position: relative; z-index: var(--z-elevated); }
 
 
 
219
 
220
- /* Opt-in zoomable images */
221
- img[data-zoomable] { cursor: zoom-in; }
222
- .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
 
 
 
 
223
 
224
  /* Download button for img[data-downloadable] */
225
- figure.has-dl-btn { position: relative; }
226
- .dl-host { position: relative; }
227
- .img-dl-wrap { position: relative; display: inline-block; }
 
 
 
 
 
 
 
228
  .img-dl-btn {
229
  position: absolute;
230
  right: 8px;
@@ -236,12 +415,72 @@ const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined;
236
  border-radius: 6px;
237
  color: white;
238
  text-decoration: none;
239
- border: 1px solid rgba(255,255,255,0.25);
240
  z-index: var(--z-elevated);
241
  display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
- .img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
244
- .img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
245
- [data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
246
- [data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
247
- </style>
 
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
+ import { Image } from "astro:assets";
4
 
5
  interface Props {
6
  /** Source image imported via astro:assets */
 
29
  [key: string]: any;
30
  }
31
 
32
+ const {
33
+ caption,
34
+ figureClass,
35
+ zoomable,
36
+ downloadable,
37
+ downloadName,
38
+ downloadSrc,
39
+ linkHref,
40
+ linkTarget,
41
+ linkRel,
42
+ ...imgProps
43
+ } = Astro.props as Props;
44
+ const hasCaptionSlot = Astro.slots.has("caption");
45
+ const hasCaption =
46
+ hasCaptionSlot || (typeof caption === "string" && caption.length > 0);
47
  const uid = `ri_${Math.random().toString(36).slice(2)}`;
48
+ const dataZoomable =
49
+ zoomable === true || (imgProps as any)["data-zoomable"] ? "1" : undefined;
50
+ const dataDownloadable =
51
+ downloadable === true || (imgProps as any)["data-downloadable"]
52
+ ? "1"
53
+ : undefined;
54
+ const hasLink = typeof linkHref === "string" && linkHref.length > 0;
55
+ const resolvedTarget = hasLink ? linkTarget || "_blank" : undefined;
56
+ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
57
  ---
58
+
59
  <div class="ri-root" data-ri-root={uid}>
60
+ {
61
+ hasCaption ? (
62
+ <figure
63
+ class={(figureClass || "") + (dataDownloadable ? " has-dl-btn" : "")}
64
+ >
65
+ {dataDownloadable ? (
66
+ <span class="img-dl-wrap">
67
+ {hasLink ? (
68
+ <a
69
+ class="ri-link"
70
+ href={linkHref}
71
+ target={resolvedTarget}
72
+ rel={resolvedRel}
73
+ >
74
+ <Image
75
+ {...imgProps}
76
+ data-zoomable={dataZoomable}
77
+ data-downloadable={dataDownloadable}
78
+ data-download-name={downloadName}
79
+ data-download-src={downloadSrc}
80
+ />
81
+ </a>
82
+ ) : (
83
+ <Image
84
+ {...imgProps}
85
+ data-zoomable={dataZoomable}
86
+ data-downloadable={dataDownloadable}
87
+ data-download-name={downloadName}
88
+ data-download-src={downloadSrc}
89
+ />
90
+ )}
91
+ <button
92
+ type="button"
93
+ class="button img-dl-btn"
94
+ aria-label="Download image"
95
+ title={
96
+ downloadName ? `Download ${downloadName}` : "Download image"
97
+ }
98
+ >
99
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
100
+ <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" />
101
+ </svg>
102
+ </button>
103
+ </span>
104
+ ) : hasLink ? (
105
+ <a
106
+ class="ri-link"
107
+ href={linkHref}
108
+ target={resolvedTarget}
109
+ rel={resolvedRel}
110
+ >
111
  <Image {...imgProps} data-zoomable={dataZoomable} />
112
  </a>
113
  ) : (
114
  <Image {...imgProps} data-zoomable={dataZoomable} />
 
 
 
 
 
 
 
115
  )}
116
+ <figcaption>
117
+ {hasCaptionSlot ? (
118
+ <slot name="caption" />
119
+ ) : (
120
+ caption && <span set:html={caption} />
121
+ )}
122
+ </figcaption>
123
+ </figure>
124
+ ) : dataDownloadable ? (
125
  <span class="img-dl-wrap">
126
  {hasLink ? (
127
+ <a
128
+ class="ri-link"
129
+ href={linkHref}
130
+ target={resolvedTarget}
131
+ rel={resolvedRel}
132
+ >
133
+ <Image
134
+ {...imgProps}
135
+ data-zoomable={dataZoomable}
136
+ data-downloadable={dataDownloadable}
137
+ data-download-name={downloadName}
138
+ data-download-src={downloadSrc}
139
+ />
140
  </a>
141
  ) : (
142
+ <Image
143
+ {...imgProps}
144
+ data-zoomable={dataZoomable}
145
+ data-downloadable={dataDownloadable}
146
+ data-download-name={downloadName}
147
+ data-download-src={downloadSrc}
148
+ />
149
  )}
150
+ <button
151
+ type="button"
152
+ class="button img-dl-btn"
153
+ aria-label="Download image"
154
+ title={downloadName ? `Download ${downloadName}` : "Download image"}
155
+ >
156
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
157
+ <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" />
158
+ </svg>
159
  </button>
160
  </span>
161
+ ) : hasLink ? (
162
+ <a
163
+ class="ri-link"
164
+ href={linkHref}
165
+ target={resolvedTarget}
166
+ rel={resolvedRel}
167
+ >
168
  <Image {...imgProps} data-zoomable={dataZoomable} />
169
+ </a>
170
+ ) : (
171
+ <Image {...imgProps} data-zoomable={dataZoomable} />
172
  )
173
+ }
174
  </div>
175
 
176
  <script is:inline>
177
  (() => {
178
+ console.log("ResponsiveImage script: Starting execution");
179
  const scriptEl = document.currentScript;
180
+ console.log("ResponsiveImage script: scriptEl =", scriptEl);
181
  const root = scriptEl ? scriptEl.previousElementSibling : null;
182
+ console.log("ResponsiveImage script: root =", root);
183
+ if (!root) {
184
+ console.log("ResponsiveImage script: No root element found, exiting");
185
+ return;
186
+ }
187
+ const img =
188
+ root.tagName === "IMG"
189
+ ? root
190
+ : root.querySelector
191
+ ? root.querySelector("img")
192
+ : null;
193
+ console.log("ResponsiveImage script: img =", img);
194
+ if (!img) {
195
+ console.log("ResponsiveImage script: No img element found, exiting");
196
+ return;
197
+ }
198
 
199
  // medium-zoom integration scoped to this image only
200
  const ensureMediumZoomReady = (cb) => {
 
202
  if (window.mediumZoom) return cb();
203
  const retry = () => {
204
  // @ts-ignore
205
+ if (window.mediumZoom) cb();
206
+ else setTimeout(retry, 30);
207
  };
208
  retry();
209
  };
210
 
211
  const initZoomIfNeeded = () => {
212
+ console.log("ResponsiveImage: checking zoom for", img);
213
+ console.log(
214
+ "ResponsiveImage: data-zoomable =",
215
+ img.getAttribute("data-zoomable"),
216
+ );
217
+ if (img.getAttribute("data-zoomable") !== "1") return;
218
+ console.log("ResponsiveImage: initializing zoom for", img);
219
+ const isDark =
220
+ document.documentElement.getAttribute("data-theme") === "dark";
221
+ const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
222
  ensureMediumZoomReady(() => {
223
  // @ts-ignore
224
+ const instance = window.mediumZoom
225
+ ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
226
+ : null;
227
+ console.log("ResponsiveImage: zoom instance created:", instance);
228
  if (!instance) return;
229
  let onScrollLike;
230
  const attachCloseOnScroll = () => {
231
  if (onScrollLike) return;
232
+ onScrollLike = () => {
233
+ try {
234
+ instance.close && instance.close();
235
+ } catch {}
236
+ };
237
+ window.addEventListener("wheel", onScrollLike, { passive: true });
238
+ window.addEventListener("touchmove", onScrollLike, { passive: true });
239
+ window.addEventListener("scroll", onScrollLike, { passive: true });
240
  };
241
  const detachCloseOnScroll = () => {
242
  if (!onScrollLike) return;
243
+ window.removeEventListener("wheel", onScrollLike);
244
+ window.removeEventListener("touchmove", onScrollLike);
245
+ window.removeEventListener("scroll", onScrollLike);
246
  onScrollLike = null;
247
  };
248
+ try {
249
+ instance.on && instance.on("open", attachCloseOnScroll);
250
+ } catch {}
251
+ try {
252
+ instance.on && instance.on("close", detachCloseOnScroll);
253
+ } catch {}
254
  const themeObserver = new MutationObserver(() => {
255
+ const dark =
256
+ document.documentElement.getAttribute("data-theme") === "dark";
257
+ try {
258
+ instance.update &&
259
+ instance.update({
260
+ background: dark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)",
261
+ });
262
+ } catch {}
263
  });
264
+ themeObserver.observe(document.documentElement, {
265
+ attributes: true,
266
+ attributeFilter: ["data-theme"],
267
+ });
268
+ });
269
+ };
270
+
271
+ // Gestion zoom global pour masquer autres ResponsiveImage
272
+ const setupGlobalZoomBehavior = () => {
273
+ img.addEventListener("click", () => {
274
+ if (img.getAttribute("data-zoomable") === "1") {
275
+ // Enlever zoom-active de tous les autres ri-root
276
+ document
277
+ .querySelectorAll(".ri-root.zoom-active")
278
+ .forEach((el) => el.classList.remove("zoom-active"));
279
+
280
+ // Ajouter zoom-active Γ  cet ri-root
281
+ root.classList.add("zoom-active");
282
+ }
283
  });
284
  };
285
 
286
  // Download button handler
287
+ const dlBtn = root.querySelector ? root.querySelector(".img-dl-btn") : null;
288
  if (dlBtn) {
289
+ dlBtn.addEventListener("click", async (ev) => {
290
  try {
291
  ev.preventDefault();
292
  ev.stopPropagation();
293
  const pickHrefAndName = () => {
294
+ const current = img.currentSrc || img.src || "";
295
+ let href = img.getAttribute("data-download-src") || current;
296
  const deriveName = () => {
297
  try {
298
  const u = new URL(current, location.href);
299
+ const rawHref = u.searchParams.get("href");
300
+ const candidate = rawHref
301
+ ? decodeURIComponent(rawHref)
302
+ : u.pathname;
303
+ const last = String(candidate).split("/").pop() || "";
304
+ const base = last.split("?")[0].split("#")[0];
305
+ const m = base.match(
306
+ /^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i,
307
+ );
308
  if (m && m[1]) return m[1];
309
+ return base || "image";
310
+ } catch {
311
+ return "image";
312
+ }
313
  };
314
+ const name = img.getAttribute("data-download-name") || deriveName();
315
  return { href, name };
316
  };
317
  const picked = pickHrefAndName();
318
+ const res = await fetch(picked.href, { credentials: "same-origin" });
319
  const blob = await res.blob();
320
  const objectUrl = URL.createObjectURL(blob);
321
+ const tmp = document.createElement("a");
322
  tmp.href = objectUrl;
323
+ tmp.download = picked.name || "image";
324
+ tmp.target = "_self";
325
+ tmp.rel = "noopener";
326
+ tmp.style.display = "none";
327
  document.body.appendChild(tmp);
328
  tmp.click();
329
+ setTimeout(() => {
330
+ URL.revokeObjectURL(objectUrl);
331
+ tmp.remove();
332
+ }, 1000);
333
  } catch {}
334
  });
335
  }
336
 
337
+ // Setup comportement zoom
338
+ setupGlobalZoomBehavior();
339
+
340
+ if (document.readyState === "complete") initZoomIfNeeded();
341
+ else window.addEventListener("load", initZoomIfNeeded, { once: true });
342
  })();
343
  </script>
344
 
 
345
  <style>
346
+ figure {
347
+ margin: var(--block-spacing-y) 0;
348
+ }
349
+ figcaption {
350
+ text-align: left;
351
+ font-size: 0.9rem;
352
+ color: var(--muted-color);
353
+ margin-top: 6px;
354
+ }
355
+ figcaption {
356
+ background: var(--page-bg);
357
+ position: relative;
358
+ z-index: var(--z-elevated);
359
+ display: block;
360
+ width: 100%;
361
+ }
362
+ .image-credit {
363
+ display: block;
364
+ margin-top: 4px;
365
+ font-size: 12px;
366
+ color: var(--muted-color);
367
+ }
368
+ .image-credit a {
369
+ color: inherit;
370
+ text-decoration: underline;
371
+ text-underline-offset: 2px;
372
+ }
373
 
374
  /* Zoomable overlay container (if used by any lightbox implementation) */
375
  [data-zoom-overlay],
 
380
  }
381
 
382
  /* Download link inside figures */
383
+ figure .download-link {
384
+ position: relative;
385
+ z-index: var(--z-elevated);
386
+ }
387
 
388
+ /* Opt-in zoomable images */
389
+ img[data-zoomable] {
390
+ cursor: zoom-in;
391
+ }
392
+ .medium-zoom--opened img[data-zoomable] {
393
+ cursor: zoom-out;
394
+ }
395
 
396
  /* Download button for img[data-downloadable] */
397
+ figure.has-dl-btn {
398
+ position: relative;
399
+ }
400
+ .dl-host {
401
+ position: relative;
402
+ }
403
+ .img-dl-wrap {
404
+ position: relative;
405
+ display: inline-block;
406
+ }
407
  .img-dl-btn {
408
  position: absolute;
409
  right: 8px;
 
415
  border-radius: 6px;
416
  color: white;
417
  text-decoration: none;
418
+ border: 1px solid rgba(255, 255, 255, 0.25);
419
  z-index: var(--z-elevated);
420
  display: none;
421
+ background: var(--primary-color);
422
+ }
423
+
424
+ /* Quand une image est zoomΓ©e, cacher TOUS les ResponsiveImage de la page */
425
+ :global(.medium-zoom--opened) .ri-root {
426
+ opacity: 0;
427
+ z-index: calc(var(--z-base) - 1);
428
+ transition: opacity 0.3s ease;
429
+ }
430
+
431
+ /* L'image actuellement zoomΓ©e reste visible */
432
+ :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
433
+ opacity: 1;
434
+ z-index: var(--z-overlay);
435
+ }
436
+
437
+ /* Fallback pour navigateurs sans support :has() */
438
+ :global(.medium-zoom--opened) .ri-root.zoom-active {
439
+ opacity: 1 !important;
440
+ z-index: var(--z-overlay) !important;
441
+ }
442
+
443
+ /* SpΓ©cifiquement masquer bouton download et figcaption lors du zoom */
444
+ :global(.medium-zoom--opened) .img-dl-btn {
445
+ opacity: 0;
446
+ z-index: calc(var(--z-base) - 1);
447
+ transition: opacity 0.3s ease;
448
+ }
449
+
450
+ :global(.medium-zoom--opened) figcaption {
451
+ opacity: 0;
452
+ z-index: calc(var(--z-base) - 1);
453
+ transition: opacity 0.3s ease;
454
+ }
455
+
456
+ /* MΓͺme pour l'image zoomΓ©e active, masquer bouton et caption pour une expΓ©rience propre */
457
+ :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
458
+ opacity: 0;
459
+ z-index: calc(var(--z-base) - 1);
460
+ }
461
+
462
+ :global(.medium-zoom--opened) .ri-root.zoom-active figcaption {
463
+ opacity: 0;
464
+ z-index: calc(var(--z-base) - 1);
465
+ }
466
+ .img-dl-btn svg {
467
+ width: 18px;
468
+ height: 18px;
469
+ fill: currentColor;
470
+ }
471
+ .img-dl-wrap:hover .img-dl-btn {
472
+ display: inline-flex;
473
+ }
474
+ .img-dl-btn:hover {
475
+ background: var(--primary-color-hover);
476
+ }
477
+
478
+ [data-theme="dark"] .img-dl-btn {
479
+ background: var(--primary-color);
480
+ color: var(--on-primary);
481
+ border-color: var(--primary-color);
482
+ }
483
+ [data-theme="dark"] .img-dl-btn:hover {
484
+ background: var(--primary-color-hover);
485
  }
486
+ </style>
 
 
 
 
app/src/components/Sidenote.astro CHANGED
@@ -1,37 +1,78 @@
1
  ---
 
2
  ---
3
- <div class="aside">
4
- <div class="aside__main">
 
5
  <slot />
6
- </div>
7
- <aside class="aside__aside">
8
- <slot name="aside" />
9
  </aside>
10
  </div>
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  <style is:global>
14
- .aside {
15
- position: relative;
 
16
  margin: 12px 0;
 
 
17
  }
18
 
19
- .aside__aside {
20
- position: absolute;
21
- top: 0;
22
- right: -260px; /* push into the right grid column (width 260 + gap 32) */
23
- width: 260px;
24
  border-radius: 8px;
25
  padding: 0 30px;
26
  font-size: 0.9rem;
27
  color: var(--muted-color);
 
28
  }
29
 
30
  @media (--bp-content-collapse) {
31
- .aside__aside {
32
- position: static;
33
- width: auto;
 
 
34
  margin-top: 8px;
 
 
 
 
 
 
 
35
  }
36
  }
37
  </style>
 
1
  ---
2
+
3
  ---
4
+
5
+ <div class="sidenote-container">
6
+ <aside class="sidenote">
7
  <slot />
 
 
 
8
  </aside>
9
  </div>
10
 
11
+ <script>
12
+ document.addEventListener("DOMContentLoaded", () => {
13
+ const containers = document.querySelectorAll(".sidenote-container");
14
+
15
+ containers.forEach((container) => {
16
+ // Trouve l'élément précédent (frère juste avant)
17
+ const previousElement = container.previousElementSibling;
18
+
19
+ if (previousElement) {
20
+ // Rend le conteneur de la sidenote relatif Γ  l'Γ©lΓ©ment prΓ©cΓ©dent
21
+ previousElement.style.position = "relative";
22
+
23
+ // DΓ©place le conteneur de la sidenote comme enfant de l'Γ©lΓ©ment prΓ©cΓ©dent
24
+ previousElement.appendChild(container);
25
+
26
+ // Style le conteneur pour qu'il se positionne correctement
27
+ container.style.position = "absolute";
28
+ container.style.top = "0";
29
+ container.style.right = "-292px"; // 260px width + 32px gap
30
+ container.style.width = "260px";
31
+
32
+ // Affiche le container avec un fade-in
33
+ container.style.display = "block";
34
+ container.style.opacity = "0";
35
+
36
+ // Fade-in avec transition
37
+ setTimeout(() => {
38
+ container.style.opacity = "1";
39
+ }, 10);
40
+ }
41
+ });
42
+ });
43
+ </script>
44
 
45
  <style is:global>
46
+ .sidenote-container {
47
+ /* CachΓ© par dΓ©faut, sera affichΓ© par JS */
48
+ display: none;
49
  margin: 12px 0;
50
+ /* Transition pour le fade-in */
51
+ transition: opacity 0.3s ease-in-out;
52
  }
53
 
54
+ .sidenote {
 
 
 
 
55
  border-radius: 8px;
56
  padding: 0 30px;
57
  font-size: 0.9rem;
58
  color: var(--muted-color);
59
+ margin: 0;
60
  }
61
 
62
  @media (--bp-content-collapse) {
63
+ .sidenote-container {
64
+ position: static !important;
65
+ width: auto !important;
66
+ right: auto !important;
67
+ top: auto !important;
68
  margin-top: 8px;
69
+ /* Affichage normal sur mobile */
70
+ display: block !important;
71
+ opacity: 1 !important;
72
+ }
73
+
74
+ .sidenote {
75
+ padding: 0;
76
  }
77
  }
78
  </style>
app/src/content/article.mdx CHANGED
@@ -20,15 +20,15 @@ tags:
20
  tableOfContentsAutoCollapse: true
21
  ---
22
 
23
- import Introduction from "./chapters/introduction.mdx";
24
- import BestPractices from "./chapters/best-pratices.mdx";
25
- import WritingYourContent from "./chapters/writing-your-content.mdx";
26
- import AvailableBlocks from "./chapters/markdown.mdx";
27
- import GettingStarted from "./chapters/getting-started.mdx";
28
- import Markdown from "./chapters/markdown.mdx";
29
- import Components from "./chapters/components.mdx";
30
- import Greetings from "./chapters/greetings.mdx";
31
- import VibeCodingCharts from "./chapters/vibe-coding-charts.mdx";
32
 
33
  <Introduction />
34
 
 
20
  tableOfContentsAutoCollapse: true
21
  ---
22
 
23
+ import Introduction from "./chapters/demo/introduction.mdx";
24
+ import BestPractices from "./chapters/demo/best-pratices.mdx";
25
+ import WritingYourContent from "./chapters/demo/writing-your-content.mdx";
26
+ import AvailableBlocks from "./chapters/demo/markdown.mdx";
27
+ import GettingStarted from "./chapters/demo/getting-started.mdx";
28
+ import Markdown from "./chapters/demo/markdown.mdx";
29
+ import Components from "./chapters/demo/components.mdx";
30
+ import Greetings from "./chapters/demo/greetings.mdx";
31
+ import VibeCodingCharts from "./chapters/demo/vibe-coding-charts.mdx";
32
 
33
  <Introduction />
34
 
app/src/content/assets/data/llm_benchmarks.json CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:03eeac176ae9abab0c36d798678d80aaa14228ae71d0f9134127cc3cc0d00196
3
- size 1088
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:67f10bd2fdcce7231c40db14b013a587e0dcecf02e427b10bb159669576e9ad8
3
+ size 834
app/src/content/chapters/{best-pratices.mdx β†’ demo/best-pratices.mdx} RENAMED
@@ -1,7 +1,9 @@
1
 
2
- import visualPoster from '../assets/image/visual-vocabulary-poster.png';
3
- import Note from '../../components/Note.astro';
4
- import ResponsiveImage from '../../components/ResponsiveImage.astro';
 
 
5
 
6
 
7
  ## Best Practices
@@ -36,26 +38,22 @@ Favor **concise captions** and callouts that clarify what to look at and why it
36
 
37
  </div>
38
 
39
- {/* ### Use the right color
 
40
  A palette encodes **meaning** (categories, magnitudes, oppositions), preserves **readability** and **accessibility** (sufficient contrast, color‑vision safety), and ensures **perceptually smooth transitions**. The three families below illustrate when to use **categorical**, **sequential**, or **diverging** colors and how they evolve from the same **reference hue**.
41
 
42
- <Sidenote>
43
  <div className="">
44
  <HtmlEmbed src="palettes.html" />
45
  </div>
46
- <Fragment slot="aside">
47
- You can choose a color from the palette to update palettes and copy them to your clipboard.
48
- </Fragment>
49
- <Fragment slot="aside">
50
- It will be applied to the <a href="/" target="_blank">whole page</a>.
51
- </Fragment>
52
- </Sidenote> */}
53
 
54
  ### Use the right chart
55
 
56
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
57
 
58
-
59
  <ResponsiveImage
60
  src={visualPoster}
61
  alt="Visual Vocabulary: choosing the right chart by task"
 
1
 
2
+ import visualPoster from '../../assets/image/visual-vocabulary-poster.png';
3
+ import Note from '../../../components/Note.astro';
4
+ import ResponsiveImage from '../../../components/ResponsiveImage.astro';
5
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro';
6
+ import Sidenote from '../../../components/Sidenote.astro';
7
 
8
 
9
  ## Best Practices
 
38
 
39
  </div>
40
 
41
+ ### Use the right color
42
+
43
  A palette encodes **meaning** (categories, magnitudes, oppositions), preserves **readability** and **accessibility** (sufficient contrast, color‑vision safety), and ensures **perceptually smooth transitions**. The three families below illustrate when to use **categorical**, **sequential**, or **diverging** colors and how they evolve from the same **reference hue**.
44
 
 
45
  <div className="">
46
  <HtmlEmbed src="palettes.html" />
47
  </div>
48
+
49
+ <Sidenote>
50
+ You can choose a color from the palette to update palettes and copy them to your clipboard. It will be applied to the <a href="/" target="_blank">whole page</a>.
51
+ </Sidenote>
 
 
 
52
 
53
  ### Use the right chart
54
 
55
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
56
 
 
57
  <ResponsiveImage
58
  src={visualPoster}
59
  alt="Visual Vocabulary: choosing the right chart by task"
app/src/content/chapters/{components.mdx β†’ demo/components.mdx} RENAMED
@@ -1,35 +1,79 @@
1
  import { Image } from 'astro:assets';
2
- import placeholder from '../assets/image/placeholder.png';
3
- import audioDemo from '../assets/audio/audio-example.wav';
4
- import HtmlEmbed from '../../components/HtmlEmbed.astro';
5
- import Sidenote from '../../components/Sidenote.astro';
6
- import Wide from '../../components/Wide.astro';
7
- import Note from '../../components/Note.astro';
8
- import FullWidth from '../../components/FullWidth.astro';
9
- import Accordion from '../../components/Accordion.astro';
10
- import ResponsiveImage from '../../components/ResponsiveImage.astro';
 
 
11
 
12
  ## Components
13
 
14
  **All** the following **components** are available in the **article.mdx** file. You can also create your own **components** by creating a new file in the `/components` folder.
15
- You have to import them in the **.mdx** file you want to use them in.
16
-
17
- <br/>
18
- <div className="button-group">
19
- <button className="button" href="#responsiveimage">ResponsiveImage</button>
20
- <button className="button" href="#placement">Placement</button>
21
- <button className="button" href="#accordion">Accordion</button>
22
- <button className="button" href="#note">Note</button>
23
- <button className="button" href="#htmlembed">HtmlEmbed</button>
24
- <button className="button" href="#iframe">Iframe</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  </div>
26
 
27
- ### ResponsiveImage
28
 
29
- **Responsive images** automatically generate an optimized `srcset` and `sizes` so the browser downloads the most appropriate file for the current viewport and DPR. You can also request multiple output formats (e.g., **AVIF**, **WebP**, fallback **PNG/JPEG**) and control **lazy loading/decoding** for better **performance**.
30
 
31
 
32
- <ResponsiveImage
33
  src={placeholder}
34
  zoomable
35
  downloadable
@@ -50,12 +94,12 @@ You have to import them in the **.mdx** file you want to use them in.
50
 
51
  <Accordion title="Code example">
52
  ```mdx
53
- import ResponsiveImage from '../components/ResponsiveImage.astro'
54
  import myImage from './assets/image/placeholder.jpg'
55
 
56
- <ResponsiveImage src={myImage} alt="Responsive, optimized example image" />
57
 
58
- <ResponsiveImage
59
  src={myImage}
60
  layout="fixed"
61
  zoomable
@@ -67,6 +111,40 @@ import myImage from './assets/image/placeholder.jpg'
67
  ```
68
  </Accordion>
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  ### Placement
72
 
@@ -74,21 +152,18 @@ Use these helpers when you need to step outside the main content flow: **Sidenot
74
 
75
  #### Sidenotes
76
 
 
 
77
  <Sidenote>
78
- This paragraph presents a **key idea** concisely.
79
- <Fragment slot="aside">
80
- **Side note** for brief context or a definition.
81
- </Fragment>
82
- </Sidenote>
83
 
84
  <Accordion title="Code example">
85
  ```mdx
86
- import Sidenote from '../components/Sidenote.astro'
87
 
88
- <Sidenote>
89
- Main paragraph with the core idea.
90
- <Fragment slot="aside">Short side note.</Fragment>
91
- </Sidenote>
92
  ```
93
  </Accordion>
94
 
@@ -101,7 +176,7 @@ import Sidenote from '../components/Sidenote.astro'
101
 
102
  <Accordion title="Code example">
103
  ```mdx
104
- import Wide from '../components/Wide.astro'
105
 
106
  <Wide>
107
  Your content here...
@@ -117,7 +192,7 @@ import Wide from '../components/Wide.astro'
117
 
118
  <Accordion title="Code example">
119
  ```mdx
120
- import FullWidth from '../components/FullWidth.astro'
121
 
122
  <FullWidth>
123
  Your content here...
@@ -151,7 +226,7 @@ Can be used like this `<Accordion>some content</Accordion>`. You can pass any ch
151
 
152
  <Accordion title="Code example">
153
  ````mdx
154
- import Accordion from '../components/Accordion.astro'
155
 
156
  <Accordion title="Accordion title" open>
157
  <p>Free content with <strong>markdown</strong> and MDX components.</p>
@@ -209,7 +284,7 @@ Small contextual callout for tips, caveats, or emphasis.
209
 
210
  <Accordion title="Code example">
211
  ```mdx
212
- import Note from '../../components/Note.astro'
213
 
214
  <Note title="Heads‑up" emoji="πŸ’‘" variant="info">
215
  Use notes to surface context without breaking reading flow.
@@ -229,6 +304,28 @@ import Note from '../../components/Note.astro'
229
  ```
230
  </Accordion>
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
  ### Iframes
234
 
@@ -274,7 +371,7 @@ For researchers who want to stay in **Python** while targeting **D3**, the [d3bl
274
 
275
  <Accordion title="Code example">
276
  ```mdx
277
- import HtmlEmbed from '../components/HtmlEmbed.astro'
278
 
279
  <HtmlEmbed src="d3-line.html" title="This is a chart title" desc="Some chart description <br/>Credit: <a href='https://example.com' target='_blank'>Example</a>" />
280
 
 
1
  import { Image } from 'astro:assets';
2
+ import placeholder from '../../assets/image/placeholder.png';
3
+ import audioDemo from '../../assets/audio/audio-example.wav';
4
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro';
5
+ import Sidenote from '../../../components/Sidenote.astro';
6
+ import Wide from '../../../components/Wide.astro';
7
+ import Note from '../../../components/Note.astro';
8
+ import FullWidth from '../../../components/FullWidth.astro';
9
+ import Accordion from '../../../components/Accordion.astro';
10
+ import Figure from '../../../components/ResponsiveImage.astro';
11
+ import SubFigures from '../../../components/MultiImage.astro';
12
+ import Quote from '../../../components/Quote.astro';
13
 
14
  ## Components
15
 
16
  **All** the following **components** are available in the **article.mdx** file. You can also create your own **components** by creating a new file in the `/components` folder.
17
+
18
+ ### How to import components
19
+
20
+ To use any component in your MDX file, add the import statement at the top:
21
+
22
+ ```mdx
23
+ import Figure from '../components/ResponsiveImage.astro';
24
+ import SubFigures from '../components/MultiImage.astro';
25
+ import Note from '../components/Note.astro';
26
+
27
+ # Your content
28
+
29
+ <Figure src={myImage} alt="Description" />
30
+ <Note>This is a note</Note>
31
+ ```
32
+
33
+ Here are the components that are available:
34
+
35
+
36
+ <div className="feature-grid">
37
+ <a href="#figure" className="feature-card">
38
+ <strong>Figure</strong>
39
+ <span>Optimized images</span>
40
+ </a>
41
+ <a href="#subfigures" className="feature-card">
42
+ <strong>SubFigures</strong>
43
+ <span>Multi-panel figures</span>
44
+ </a>
45
+ <a href="#placement" className="feature-card">
46
+ <strong>Placement</strong>
47
+ <span>Layout helpers</span>
48
+ </a>
49
+ <a href="#accordion" className="feature-card">
50
+ <strong>Accordion</strong>
51
+ <span>Collapsible content</span>
52
+ </a>
53
+ <a href="#note" className="feature-card">
54
+ <strong>Note</strong>
55
+ <span>Contextual callouts</span>
56
+ </a>
57
+ <a href="#quote" className="feature-card">
58
+ <strong>Quote</strong>
59
+ <span>Elegant citations</span>
60
+ </a>
61
+ <a href="#htmlembed" className="feature-card">
62
+ <strong>HtmlEmbed</strong>
63
+ <span>External content</span>
64
+ </a>
65
+ <a href="#iframe" className="feature-card">
66
+ <strong>Iframe</strong>
67
+ <span>Web embeds</span>
68
+ </a>
69
  </div>
70
 
71
+ ### Figure
72
 
73
+ **Figures** automatically generate optimized `srcset` and `sizes` so the browser downloads the most appropriate file for the current viewport and DPR. You can also request multiple output formats (e.g., **AVIF**, **WebP**, fallback **PNG/JPEG**) and control **lazy loading/decoding** for better **performance**.
74
 
75
 
76
+ <Figure
77
  src={placeholder}
78
  zoomable
79
  downloadable
 
94
 
95
  <Accordion title="Code example">
96
  ```mdx
97
+ import Figure from '../../../components/ResponsiveImage.astro'
98
  import myImage from './assets/image/placeholder.jpg'
99
 
100
+ <Figure src={myImage} alt="Optimized figure with caption" />
101
 
102
+ <Figure
103
  src={myImage}
104
  layout="fixed"
105
  zoomable
 
111
  ```
112
  </Accordion>
113
 
114
+ ### SubFigures
115
+
116
+ **Display multiple figures** in a grid layout with customizable columns and captions. Perfect for **multi-panel figures**, **comparisons**, and **step-by-step visualizations** β€” just like LaTeX's `subfigure` environment.
117
+
118
+ <SubFigures
119
+ images={[
120
+ { src: placeholder, alt: "Image 1", caption: "First image with caption" },
121
+ { src: placeholder, alt: "Image 2", caption: "Second image" },
122
+ { src: placeholder, alt: "Image 3", caption: "Third image description" }
123
+ ]}
124
+ layout="3-column"
125
+ caption="Gallery of three images with individual captions"
126
+ zoomable={true}
127
+ />
128
+
129
+ <Accordion title="Code example">
130
+ ```mdx
131
+ import placeholder from '../assets/image/placeholder.png';
132
+
133
+ <SubFigures
134
+ images={[
135
+ { src: placeholder, alt: "Panel A", caption: "(a) First panel" },
136
+ { src: placeholder, alt: "Panel B", caption: "(b) Second panel" },
137
+ { src: placeholder, alt: "Panel C", caption: "(c) Third panel" }
138
+ ]}
139
+ layout="3-column"
140
+ caption="Figure 1: Multi-panel comparison showing different conditions"
141
+ zoomable={true}
142
+ />
143
+ ```
144
+ </Accordion>
145
+
146
+ **Layout options:** `2-column`, `3-column`, `4-column`, `auto`
147
+ **Features:** Individual subcaptions (a), (b), (c), zoom functionality, responsive design
148
 
149
  ### Placement
150
 
 
152
 
153
  #### Sidenotes
154
 
155
+ This paragraph presents a **key idea** concisely.
156
+
157
  <Sidenote>
158
+ **Side note** for brief context or a definition.
159
+ </Sidenote>
 
 
 
160
 
161
  <Accordion title="Code example">
162
  ```mdx
163
+ import Sidenote from '../../../components/Sidenote.astro'
164
 
165
+ Main paragraph with the core idea.
166
+ <Sidenote>Short side note.</Sidenote>
 
 
167
  ```
168
  </Accordion>
169
 
 
176
 
177
  <Accordion title="Code example">
178
  ```mdx
179
+ import Wide from '../../../components/Wide.astro'
180
 
181
  <Wide>
182
  Your content here...
 
192
 
193
  <Accordion title="Code example">
194
  ```mdx
195
+ import FullWidth from '../../../components/FullWidth.astro'
196
 
197
  <FullWidth>
198
  Your content here...
 
226
 
227
  <Accordion title="Code example">
228
  ````mdx
229
+ import Accordion from '../../../components/Accordion.astro'
230
 
231
  <Accordion title="Accordion title" open>
232
  <p>Free content with <strong>markdown</strong> and MDX components.</p>
 
284
 
285
  <Accordion title="Code example">
286
  ```mdx
287
+ import Note from '../../../components/Note.astro'
288
 
289
  <Note title="Heads‑up" emoji="πŸ’‘" variant="info">
290
  Use notes to surface context without breaking reading flow.
 
304
  ```
305
  </Accordion>
306
 
307
+ ### Quote
308
+
309
+ Elegant quotes with optional author attribution.
310
+
311
+ <Quote author="Geoffrey Hinton">
312
+ The brain has about 10^14 synapses and we only live for about 10^9 seconds. So we have a lot more parameters than data. This motivates the idea that we must do a lot of unsupervised learning since labeled examples are so limited.
313
+ </Quote>
314
+
315
+ | Prop | Required | Type | Description |
316
+ |----------|----------|--------|--------------------------------|
317
+ | `author` | No | string | Author of the quote |
318
+ | `source` | No | string | Source publication or context |
319
+
320
+ <Accordion title="Code example">
321
+ ```mdx
322
+ import Quote from '../../../components/Quote.astro'
323
+
324
+ <Quote author="Geoffrey Hinton">
325
+ The brain has about 10^14 synapses and we only live for about 10^9 seconds. So we have a lot more parameters than data. This motivates the idea that we must do a lot of unsupervised learning since labeled examples are so limited.
326
+ </Quote>
327
+ ```
328
+ </Accordion>
329
 
330
  ### Iframes
331
 
 
371
 
372
  <Accordion title="Code example">
373
  ```mdx
374
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro'
375
 
376
  <HtmlEmbed src="d3-line.html" title="This is a chart title" desc="Some chart description <br/>Credit: <a href='https://example.com' target='_blank'>Example</a>" />
377
 
app/src/content/chapters/{debug-components.mdx β†’ demo/debug-components.mdx} RENAMED
@@ -1,9 +1,9 @@
1
- import Accordion from '../../components/Accordion.astro';
2
- import HtmlEmbed from '../../components/HtmlEmbed.astro';
3
- import ResponsiveImage from '../../components/ResponsiveImage.astro';
4
- import Wide from '../../components/Wide.astro';
5
- import FullWidth from '../../components/FullWidth.astro';
6
- import Note from '../../components/Note.astro';
7
 
8
  | Prop | Required |
9
  |------------------------|----------|
@@ -29,7 +29,7 @@ import Note from '../../components/Note.astro';
29
 
30
  <Accordion title="code">
31
  ```mdx
32
- import HtmlEmbed from '../components/HtmlEmbed.astro'
33
 
34
  <HtmlEmbed src="plotly-line.html" title="Plotly Line" desc="Some chart description" />
35
  <HtmlEmbed src="d3-line-example.html" title="D3 Line" desc="Some chart description" />
 
1
+ import Accordion from '../../../components/Accordion.astro';
2
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro';
3
+ import ResponsiveImage from '../../../components/ResponsiveImage.astro';
4
+ import Wide from '../../../components/Wide.astro';
5
+ import FullWidth from '../../../components/FullWidth.astro';
6
+ import Note from '../../../components/Note.astro';
7
 
8
  | Prop | Required |
9
  |------------------------|----------|
 
29
 
30
  <Accordion title="code">
31
  ```mdx
32
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro'
33
 
34
  <HtmlEmbed src="plotly-line.html" title="Plotly Line" desc="Some chart description" />
35
  <HtmlEmbed src="d3-line-example.html" title="D3 Line" desc="Some chart description" />
app/src/content/chapters/{getting-started.mdx β†’ demo/getting-started.mdx} RENAMED
@@ -1,5 +1,5 @@
1
- import Sidenote from '../../components/Sidenote.astro';
2
- import Note from '../../components/Note.astro';
3
 
4
  ## Getting Started
5
 
@@ -34,14 +34,14 @@ git lfs pull
34
 
35
 
36
  6. Install dependencies.
37
- <Sidenote>
38
  ```bash
39
  cd app
40
  npm install
41
  ```
42
- <Fragment slot="aside">
43
- Alternatively, you can use **Yarn** as your package manager.
44
- </Fragment>
45
  </Sidenote>
46
 
47
 
 
1
+ import Sidenote from '../../../components/Sidenote.astro';
2
+ import Note from '../../../components/Note.astro';
3
 
4
  ## Getting Started
5
 
 
34
 
35
 
36
  6. Install dependencies.
37
+
38
  ```bash
39
  cd app
40
  npm install
41
  ```
42
+
43
+ <Sidenote>
44
+ Alternatively, you can use **Yarn** as your package manager.
45
  </Sidenote>
46
 
47
 
app/src/content/chapters/{greetings.mdx β†’ demo/greetings.mdx} RENAMED
@@ -2,7 +2,7 @@
2
 
3
  Huge thanks to the following people for their **precious feedbacks**!
4
 
5
- import HfUser from '../../components/HfUser.astro';
6
 
7
  <div className="hf-user-list">
8
  <HfUser username="lvwerra" name="Leandro von Werra" />
 
2
 
3
  Huge thanks to the following people for their **precious feedbacks**!
4
 
5
+ import HfUser from '../../../components/HfUser.astro';
6
 
7
  <div className="hf-user-list">
8
  <HfUser username="lvwerra" name="Leandro von Werra" />
app/src/content/chapters/{introduction.mdx β†’ demo/introduction.mdx} RENAMED
@@ -1,18 +1,15 @@
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**.
5
-
6
- Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
7
 
8
- <Fragment slot="aside">
9
- Reading time: 20–25 minutes.
10
- </Fragment>
11
  </Sidenote>
12
 
13
  #### Features
14
 
15
- <Sidenote>
16
  <div className="tag-list">
17
  <span className="tag">Markdown-based</span>
18
  <span className="tag">KaTeX math</span>
@@ -34,9 +31,9 @@ import Sidenote from "../../components/Sidenote.astro";
34
  <span className="tag">Dark theme</span>
35
  <span className="tag">Mobile friendly</span>
36
  </div>
37
- <Fragment slot="aside">
38
- If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-article-template/discussions?status=open&type=discussion">Community tab</a>!
39
- </Fragment>
40
  </Sidenote>
41
 
42
  ## Introduction
@@ -55,7 +52,6 @@ This is not a CMS or a multi‑page blogβ€”it's a **focused**, **single‑page**
55
 
56
  ### Inspired by Distill
57
 
58
- <Sidenote>
59
  This project stands in the direct continuity of [Distill](https://distill.pub/) (2016–2021). Our goal is to carry that spirit forward and push it even further: **accessible scientific writing**, **high‑quality interactive explanations**, and **reproducible**, production‑ready demos.
60
 
61
  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:
@@ -65,7 +61,6 @@ To give you a sense of what inspired this template, here is a short, curated lis
65
  - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
66
  - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
67
 
68
- <Fragment slot="aside">
69
- 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>!
70
- </Fragment>
71
  </Sidenote>
 
1
+ import Sidenote from "../../../components/Sidenote.astro";
2
 
3
+ Welcome to this single‑page **research article template**. It helps you publish **clear**, **modern**, and **interactive technical writing** with **minimal setup**.
4
+
5
+ Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
 
6
 
7
+ <Sidenote>
8
+ Reading time: 20–25 minutes.
 
9
  </Sidenote>
10
 
11
  #### Features
12
 
 
13
  <div className="tag-list">
14
  <span className="tag">Markdown-based</span>
15
  <span className="tag">KaTeX math</span>
 
31
  <span className="tag">Dark theme</span>
32
  <span className="tag">Mobile friendly</span>
33
  </div>
34
+
35
+ <Sidenote>
36
+ If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-article-template/discussions?status=open&type=discussion">Community tab</a>!
37
  </Sidenote>
38
 
39
  ## Introduction
 
52
 
53
  ### Inspired by Distill
54
 
 
55
  This project stands in the direct continuity of [Distill](https://distill.pub/) (2016–2021). Our goal is to carry that spirit forward and push it even further: **accessible scientific writing**, **high‑quality interactive explanations**, and **reproducible**, production‑ready demos.
56
 
57
  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:
 
61
  - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
62
  - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
63
 
64
+ <Sidenote>
65
+ 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>!
 
66
  </Sidenote>
app/src/content/chapters/{markdown.mdx β†’ demo/markdown.mdx} RENAMED
@@ -1,27 +1,64 @@
1
- import placeholder from '../assets/image/placeholder.png';
2
- import audioDemo from '../assets/audio/audio-example.wav';
3
- import HtmlEmbed from '../../components/HtmlEmbed.astro';
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 Accordion from '../../components/Accordion.astro';
9
- import ResponsiveImage from '../../components/ResponsiveImage.astro';
10
 
11
  ## Markdown
12
 
13
- All the following **markdown features** are available **natively** in the `article.mdx` file. See also the complete [**Markdown documentation**](https://www.markdownguide.org/basic-syntax/).
14
-
15
- <br/>
16
- <div className="button-group">
17
- <button className="button" href="#math">Math</button>
18
- <button className="button" href="#code-blocks">Code</button>
19
- <button className="button" href="#citation-and-notes">Citation</button>
20
- <button className="button" href="#footnote">Footnote</button>
21
- <button className="button" href="#mermaid-diagrams">Mermaid</button>
22
- <button className="button" href="#separator">Separator</button>
23
- <button className="button" href="#table">Table</button>
24
- <button className="button" href="#audio">Audio</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  </div>
26
 
27
  ### Math
@@ -32,6 +69,18 @@ $$
32
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
33
  $$
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  <Accordion title="Code example">
36
  ```mdx
37
  $x^2 + y^2 = z^2$
@@ -39,6 +88,16 @@ $x^2 + y^2 = z^2$
39
  $$
40
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
41
  $$
 
 
 
 
 
 
 
 
 
 
42
  ```
43
  </Accordion>
44
 
 
1
+ import placeholder from '../../assets/image/placeholder.png';
2
+ import audioDemo from '../../assets/audio/audio-example.wav';
3
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro';
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 Accordion from '../../../components/Accordion.astro';
9
+ import ResponsiveImage from '../../../components/ResponsiveImage.astro';
10
 
11
  ## Markdown
12
 
13
+ All the following **markdown features** are available **natively** in the `article.mdx` file. No imports needed, just write markdown directly:
14
+
15
+ **Text formatting** β€” `**Bold**` β†’ **Bold**, `*italic*` β†’ *italic*, `~~strikethrough~~` β†’ ~~strikethrough~~
16
+
17
+ **Code** β€” `` `inline code` `` β†’ `inline code`, triple backticks for code blocks
18
+
19
+ **Lists** β€” `- Item` for bullets, `1. Item` for numbered lists with nesting support
20
+
21
+ **Links** β€” `[text](url)` β†’ [External links](https://example.com) and internal navigation
22
+
23
+ **Highlight** β€” `<mark>text</mark>` β†’ <mark>Highlighted text</mark> for emphasis
24
+
25
+ See also the complete [**Markdown documentation**](https://www.markdownguide.org/basic-syntax/).
26
+
27
+ **Advanced features** β€” Explore specialized content types:
28
+
29
+ <div className="feature-grid">
30
+ <a href="#math" className="feature-card">
31
+ <strong>Math</strong>
32
+ <span>LaTeX equations</span>
33
+ </a>
34
+ <a href="#code-blocks" className="feature-card">
35
+ <strong>Code</strong>
36
+ <span>Syntax highlighting</span>
37
+ </a>
38
+ <a href="#citation-and-notes" className="feature-card">
39
+ <strong>Citation</strong>
40
+ <span>Academic references</span>
41
+ </a>
42
+ <a href="#footnote" className="feature-card">
43
+ <strong>Footnote</strong>
44
+ <span>Additional context</span>
45
+ </a>
46
+ <a href="#mermaid-diagrams" className="feature-card">
47
+ <strong>Mermaid</strong>
48
+ <span>Diagrams & flowcharts</span>
49
+ </a>
50
+ <a href="#separator" className="feature-card">
51
+ <strong>Separator</strong>
52
+ <span>Visual breaks</span>
53
+ </a>
54
+ <a href="#table" className="feature-card">
55
+ <strong>Table</strong>
56
+ <span>Data presentation</span>
57
+ </a>
58
+ <a href="#audio" className="feature-card">
59
+ <strong>Audio</strong>
60
+ <span>Sound embeds</span>
61
+ </a>
62
  </div>
63
 
64
  ### Math
 
69
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
70
  $$
71
 
72
+ Aligned math equations are also supported. You can add IDs to equations using `\htmlId{my_id}` and then reference them with links like [this equation](#trajectory_definition).
73
+
74
+ ```math
75
+ \htmlId{trajectory_definition}{\begin{align}
76
+ \log p_\theta(\mathcal D) &= \log \sum_{i=0}^N p_\theta ((o,a)_i) \\
77
+ &= \log \sum_{i=0}^N \int_{\text{supp}({Z})} p_\theta((o,a)_i \vert z) p(z) \\
78
+ &= \log \sum_{i=0}^N \int_{\text{supp}({Z})} \frac{q_\theta(z \vert (o,a)_i)}{q_\theta(z \vert (o,a)_i)} \cdot p_\theta((o,a)_i \vert z) p(z) \\
79
+ &= \log \sum_{i=0}^N \mathbb E_{z \sim p_\theta(\bullet \vert (o,a)_i)} [\frac{p(z)}{q_\theta(z \vert (o,a)_i)} \cdot p_\theta((o,a)_i \vert z)],
80
+ \end{align}}
81
+ ```
82
+
83
+
84
  <Accordion title="Code example">
85
  ```mdx
86
  $x^2 + y^2 = z^2$
 
88
  $$
89
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
90
  $$
91
+
92
+ $$
93
+ \htmlId{trajectory_definition}{\begin{align}
94
+ \log p_\theta(\mathcal D) &= \log \sum_{i=0}^N p_\theta ((o,a)_i) \\
95
+ &= \log \sum_{i=0}^N \int_{\text{supp}({Z})} p_\theta((o,a)_i \vert z) p(z) \\
96
+ &= \log \sum_{i=0}^N \int_{\text{supp}({Z})} \frac{q_\theta(z \vert (o,a)_i)}{q_\theta(z \vert (o,a)_i)} \cdot p_\theta((o,a)_i \vert z) p(z) \\
97
+ &= \log \sum_{i=0}^N \mathbb E_{z \sim p_\theta(\bullet \vert (o,a)_i)} [\frac{p(z)}{q_\theta(z \vert (o,a)_i)} \cdot p_\theta((o,a)_i \vert z)],
98
+ \end{align}}
99
+ $$
100
+
101
  ```
102
  </Accordion>
103
 
app/src/content/chapters/{vibe-coding-charts.mdx β†’ demo/vibe-coding-charts.mdx} RENAMED
@@ -1,6 +1,6 @@
1
- import HtmlEmbed from '../../components/HtmlEmbed.astro';
2
- import Note from '../../components/Note.astro';
3
- import TrackioWrapper from '../../components/TrackioWrapper.astro';
4
 
5
  ## Vibe coding charts
6
 
@@ -72,6 +72,6 @@ They can be found in the `app/src/content/embeds` folder and you can also use th
72
  ---
73
  <HtmlEmbed src="d3-scatter.html" title="d3-scatter: 2D projection by category" desc={`Figure 8: Dataset visualization via UMAP <br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>`} frameless align="center" />
74
 
75
- ### Trackio redesign experiment
76
- <TrackioWrapper />
77
 
 
1
+ import HtmlEmbed from '../../../components/HtmlEmbed.astro';
2
+ import Note from '../../../components/Note.astro';
3
+ import TrackioWrapper from '../../../components/TrackioWrapper.astro';
4
 
5
  ## Vibe coding charts
6
 
 
72
  ---
73
  <HtmlEmbed src="d3-scatter.html" title="d3-scatter: 2D projection by category" desc={`Figure 8: Dataset visualization via UMAP <br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>`} frameless align="center" />
74
 
75
+ {/* ### Trackio redesign experiment
76
+ <TrackioWrapper /> */}
77
 
app/src/content/chapters/{writing-your-content.mdx β†’ demo/writing-your-content.mdx} RENAMED
@@ -1,15 +1,15 @@
1
  {/* IMPORTS */}
2
  import { Image } from 'astro:assets';
3
- import placeholder from '../assets/image/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 ColorPicker from '../../components/ColorPicker.astro';
10
- import Palettes from '../../components/Palettes.astro';
11
- import audioDemo from '../assets/audio/audio-example.wav';
12
- import Accordion from '../../components/Accordion.astro';
13
 
14
  ## Writing your content
15
 
@@ -86,14 +86,15 @@ Your story. Write your content here.
86
 
87
  <small className="muted">**Content** in app/src/content/article.mdx</small>
88
  ```mdx
89
- import placeholder from '../assets/image/placeholder.png'
90
- import ResponsiveImage from '../components/ResponsiveImage.astro'
91
- import Sidenote from '../components/Sidenote.astro'
 
 
92
 
93
  <Sidenote>
94
- This paragraph is written in Markdown.
95
- <Fragment slot="aside">A short callout inserted via a component.</Fragment>
96
- </Sidenote>
97
  <ResponsiveImage src={placeholder} alt="Sample image with optimization" />
98
 
99
  This paragraph is also written in Markdown.
@@ -131,13 +132,12 @@ Use the **color picker** below to see how the primary color affects the theme.
131
 
132
  #### Brand color
133
 
 
 
134
  <Sidenote>
135
- <ColorPicker />
136
- <Fragment slot="aside">
137
- You can use the color picker to select the right color.
138
-
139
- Here is an example of an <a href="#">interactive element</a>.
140
- </Fragment>
141
  </Sidenote>
142
 
143
 
 
1
  {/* IMPORTS */}
2
  import { Image } from 'astro:assets';
3
+ import placeholder from '../../assets/image/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 ColorPicker from '../../../components/ColorPicker.astro';
10
+ import Palettes from '../../../components/Palettes.astro';
11
+ import audioDemo from '../../assets/audio/audio-example.wav';
12
+ import Accordion from '../../../components/Accordion.astro';
13
 
14
  ## Writing your content
15
 
 
86
 
87
  <small className="muted">**Content** in app/src/content/article.mdx</small>
88
  ```mdx
89
+ import placeholder from '../../assets/image/placeholder.png'
90
+ import ResponsiveImage from '../../../components/ResponsiveImage.astro'
91
+ import Sidenote from '../../../components/Sidenote.astro'
92
+
93
+ This paragraph is written in Markdown.
94
 
95
  <Sidenote>
96
+ A short callout inserted via a component.
97
+ </Sidenote>
 
98
  <ResponsiveImage src={placeholder} alt="Sample image with optimization" />
99
 
100
  This paragraph is also written in Markdown.
 
132
 
133
  #### Brand color
134
 
135
+ <ColorPicker />
136
+
137
  <Sidenote>
138
+ You can use the color picker to select the right color.
139
+
140
+ Here is an example of an <a href="#">interactive element</a>.
 
 
 
141
  </Sidenote>
142
 
143
 
app/src/pages/index.astro CHANGED
@@ -1,48 +1,57 @@
1
  ---
2
- import * as ArticleMod from '../content/article.mdx';
3
 
4
- import Hero from '../components/Hero.astro';
5
- import Footer from '../components/Footer.astro';
6
- import ThemeToggle from '../components/ThemeToggle.astro';
7
- import Seo from '../components/Seo.astro';
8
- import TableOfContents from '../components/TableOfContents.astro';
9
  // Default OG image served from public/
10
- const ogDefaultUrl = '/thumb.auto.jpg';
11
- import 'katex/dist/katex.min.css';
12
- import '../styles/global.css';
13
  const articleFM = (ArticleMod as any).frontmatter ?? {};
14
  const Article = (ArticleMod as any).default;
15
- const docTitle = articleFM?.title ?? 'Untitled article';
16
  // Allow explicit line breaks in the title via "\n" or YAML newlines
17
- const docTitleHtml = (articleFM?.title ?? 'Untitled article')
18
- .replace(/\\n/g, '<br/>')
19
- .replace(/\n/g, '<br/>');
20
- const subtitle = articleFM?.subtitle ?? '';
21
- const description = articleFM?.description ?? '';
22
  // Accept authors as string[] or array of objects { name, url, affiliations? }
23
  const rawAuthors = (articleFM as any)?.authors ?? [];
24
  type Affiliation = { id: number; name: string; url?: string };
25
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
26
 
27
  // Normalize affiliations from frontmatter: supports strings or objects { id?, name, url? }
28
- const rawAffils = (articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? [];
 
29
  const normalizedAffiliations: Affiliation[] = (() => {
30
  const seen: Map<string, number> = new Map();
31
  const list: Affiliation[] = [];
32
  const pushUnique = (name: string, url?: string) => {
33
- const key = `${String(name).trim()}|${url ? String(url).trim() : ''}`;
34
  if (seen.has(key)) return seen.get(key)!;
35
  const id = list.length + 1;
36
- list.push({ id, name: String(name).trim(), url: url ? String(url) : undefined });
 
 
 
 
37
  seen.set(key, id);
38
  return id;
39
  };
40
- const input = Array.isArray(rawAffils) ? rawAffils : (rawAffils ? [rawAffils] : []);
 
 
 
 
41
  for (const a of input) {
42
- if (typeof a === 'string') {
43
  pushUnique(a);
44
- } else if (a && typeof a === 'object') {
45
- const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? '';
46
  if (!String(name).trim()) continue;
47
  const url = a.url || a.link;
48
  // Respect provided numeric id for display stability if present and sequential; otherwise reassign
@@ -55,53 +64,69 @@ const normalizedAffiliations: Affiliation[] = (() => {
55
  // Helper: ensure an affiliation exists and return its id
56
  const ensureAffiliation = (val: any): number | undefined => {
57
  if (val == null) return undefined;
58
- if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
59
  return Math.floor(val);
60
  }
61
- const name = typeof val === 'string' ? val : (val?.name ?? val?.label ?? val?.text ?? val?.affiliation);
 
 
 
62
  if (!name || !String(name).trim()) return undefined;
63
- const existing = normalizedAffiliations.find(a => a.name === String(name).trim());
 
 
64
  if (existing) return existing.id;
65
  const id = normalizedAffiliations.length + 1;
66
- normalizedAffiliations.push({ id, name: String(name).trim(), url: val?.url || val?.link });
 
 
 
 
67
  return id;
68
  };
69
 
70
  // Normalize authors and map affiliations -> indices (Distill-like)
71
- const normalizedAuthors: Author[] = (Array.isArray(rawAuthors) ? rawAuthors : [])
 
 
72
  .map((a: any) => {
73
- if (typeof a === 'string') {
74
  return { name: a } as Author;
75
  }
76
- const name = String(a?.name || '').trim();
77
  const url = a?.url || a?.link;
78
  let indices: number[] | undefined = undefined;
79
  const raw = a?.affiliations ?? a?.affiliation ?? a?.affils;
80
  if (raw != null) {
81
  const entries = Array.isArray(raw) ? raw : [raw];
82
- const ids = entries.map(ensureAffiliation).filter((x): x is number => typeof x === 'number');
 
 
83
  const unique = Array.from(new Set(ids)).sort((x, y) => x - y);
84
  if (unique.length) indices = unique;
85
  }
86
  return { name, url, affiliationIndices: indices } as Author;
87
  })
88
  .filter((a: Author) => a.name && a.name.trim().length > 0);
89
- const authorNames: string[] = normalizedAuthors.map(a => a.name);
90
  const published = articleFM?.published ?? undefined;
91
  const tags = articleFM?.tags ?? [];
92
  // Prefer seoThumbImage from frontmatter if provided
93
  const fmOg = articleFM?.seoThumbImage as string | undefined;
94
- const imageAbs: string = fmOg && fmOg.startsWith('http')
95
- ? fmOg
96
- : (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl));
 
 
 
97
 
98
  // ---- Build citation text & BibTeX from frontmatter ----
99
- const stripHtml = (text: string) => String(text || '').replace(/<[^>]*>/g, '');
100
- const rawTitle = articleFM?.title ?? 'Untitled article';
101
  const titleFlat = stripHtml(String(rawTitle))
102
- .replace(/\\n/g, ' ')
103
- .replace(/\n/g, ' ')
104
- .replace(/\s+/g, ' ')
105
  .trim();
106
  const extractYear = (val: string | undefined): number | undefined => {
107
  if (!val) return undefined;
@@ -112,105 +137,212 @@ const extractYear = (val: string | undefined): number | undefined => {
112
  };
113
 
114
  const year = extractYear(published);
115
- const citationAuthorsText = authorNames.join(', ');
116
- const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
117
-
118
- const authorsBib = authorNames.join(' and ');
119
- const keyAuthor = (authorNames[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCase();
120
- const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
121
- const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
122
- const doi = (ArticleMod as any)?.frontmatter?.doi ? String((ArticleMod as any).frontmatter.doi) : undefined;
123
- const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ''}${doi ? `doi={${doi}}` : ''}\n}`;
 
 
 
 
 
 
 
 
 
124
  const envCollapse = false;
125
  const tableOfContentAutoCollapse = Boolean(
126
- (articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tableOfContentsAutoCollapse ?? envCollapse
 
 
127
  );
128
  // Licence note (HTML allowed)
129
- const licence = (articleFM as any)?.licence ?? (articleFM as any)?.license ?? (articleFM as any)?.licenseNote;
 
 
 
130
  ---
131
- <html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
 
 
 
 
 
132
  <head>
133
  <meta charset="utf-8" />
134
  <meta name="viewport" content="width=device-width, initial-scale=1" />
135
- <Seo title={docTitle} description={description} authors={authorNames} published={published} tags={tags} image={imageAbs} />
 
 
 
 
 
 
 
136
  <script is:inline>
137
  (() => {
138
  try {
139
- const saved = localStorage.getItem('theme');
140
- const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
141
- const theme = saved || (prefersDark ? 'dark' : 'light');
142
- document.documentElement.setAttribute('data-theme', theme);
 
 
143
  } catch {}
144
  })();
145
  </script>
146
  <script type="module" src="/scripts/color-palettes.js"></script>
147
-
148
  <!-- TO MANAGE PROPERLY -->
149
- <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
 
150
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
151
- <script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
 
 
 
 
 
153
  </head>
154
  <body>
155
  <ThemeToggle />
156
- <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={normalizedAuthors as any} affiliations={normalizedAffiliations as any} affiliation={articleFM?.affiliation} published={articleFM?.published} doi={doi} />
 
 
 
 
 
 
 
 
 
157
 
158
  <section class="content-grid">
159
- <TableOfContents tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
 
 
160
  <main>
161
  <Article />
162
  </main>
163
  </section>
164
 
165
- <Footer citationText={citationText} bibtex={bibtex} licence={licence} doi={doi} />
166
-
 
 
 
 
167
 
168
  <script>
169
  // Open external links in a new tab; keep internal anchors in-page
170
  const setExternalTargets = () => {
171
  const isExternal = (href) => {
172
- try { const u = new URL(href, location.href); return u.origin !== location.origin; } catch { return false; }
 
 
 
 
 
173
  };
174
- document.querySelectorAll('a[href]').forEach(a => {
175
- const href = a.getAttribute('href');
176
  if (!href) return;
177
  if (isExternal(href)) {
178
- a.setAttribute('target', '_blank');
179
- a.setAttribute('rel', 'noopener noreferrer');
180
  } else {
181
- a.removeAttribute('target');
182
  }
183
  });
184
  };
185
- if (document.readyState === 'loading') {
186
- document.addEventListener('DOMContentLoaded', setExternalTargets, { once: true });
187
- } else { setExternalTargets(); }
 
 
 
 
188
  </script>
189
 
190
  <script>
191
  // Delegate copy clicks for code blocks injected by rehypeCodeCopy
192
- document.addEventListener('click', async (e) => {
193
  const target = e.target instanceof Element ? e.target : null;
194
- const btn = target ? target.closest('.code-copy') : null;
195
  if (!btn) return;
196
- const card = btn.closest('.code-card');
197
- const pre = card && card.querySelector('pre');
198
  if (!pre) return;
199
- const text = pre.textContent || '';
200
  try {
201
  await navigator.clipboard.writeText(text.trim());
202
  const old = btn.innerHTML;
203
- btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
 
204
  setTimeout(() => (btn.innerHTML = old), 1200);
205
  } catch {
206
- btn.textContent = 'Error';
207
- setTimeout(() => (btn.textContent = 'Copy'), 1200);
208
  }
209
  });
210
  </script>
211
-
212
-
213
  </body>
214
  </html>
215
-
216
-
 
1
  ---
2
+ import * as ArticleMod from "../content/article.mdx";
3
 
4
+ import Hero from "../components/Hero.astro";
5
+ import Footer from "../components/Footer.astro";
6
+ import ThemeToggle from "../components/ThemeToggle.astro";
7
+ import Seo from "../components/Seo.astro";
8
+ import TableOfContents from "../components/TableOfContents.astro";
9
  // Default OG image served from public/
10
+ const ogDefaultUrl = "/thumb.auto.jpg";
11
+ import "katex/dist/katex.min.css";
12
+ import "../styles/global.css";
13
  const articleFM = (ArticleMod as any).frontmatter ?? {};
14
  const Article = (ArticleMod as any).default;
15
+ const docTitle = articleFM?.title ?? "Untitled article";
16
  // Allow explicit line breaks in the title via "\n" or YAML newlines
17
+ const docTitleHtml = (articleFM?.title ?? "Untitled article")
18
+ .replace(/\\n/g, "<br/>")
19
+ .replace(/\n/g, "<br/>");
20
+ const subtitle = articleFM?.subtitle ?? "";
21
+ const description = articleFM?.description ?? "";
22
  // Accept authors as string[] or array of objects { name, url, affiliations? }
23
  const rawAuthors = (articleFM as any)?.authors ?? [];
24
  type Affiliation = { id: number; name: string; url?: string };
25
  type Author = { name: string; url?: string; affiliationIndices?: number[] };
26
 
27
  // Normalize affiliations from frontmatter: supports strings or objects { id?, name, url? }
28
+ const rawAffils =
29
+ (articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? [];
30
  const normalizedAffiliations: Affiliation[] = (() => {
31
  const seen: Map<string, number> = new Map();
32
  const list: Affiliation[] = [];
33
  const pushUnique = (name: string, url?: string) => {
34
+ const key = `${String(name).trim()}|${url ? String(url).trim() : ""}`;
35
  if (seen.has(key)) return seen.get(key)!;
36
  const id = list.length + 1;
37
+ list.push({
38
+ id,
39
+ name: String(name).trim(),
40
+ url: url ? String(url) : undefined,
41
+ });
42
  seen.set(key, id);
43
  return id;
44
  };
45
+ const input = Array.isArray(rawAffils)
46
+ ? rawAffils
47
+ : rawAffils
48
+ ? [rawAffils]
49
+ : [];
50
  for (const a of input) {
51
+ if (typeof a === "string") {
52
  pushUnique(a);
53
+ } else if (a && typeof a === "object") {
54
+ const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? "";
55
  if (!String(name).trim()) continue;
56
  const url = a.url || a.link;
57
  // Respect provided numeric id for display stability if present and sequential; otherwise reassign
 
64
  // Helper: ensure an affiliation exists and return its id
65
  const ensureAffiliation = (val: any): number | undefined => {
66
  if (val == null) return undefined;
67
+ if (typeof val === "number" && Number.isFinite(val) && val > 0) {
68
  return Math.floor(val);
69
  }
70
+ const name =
71
+ typeof val === "string"
72
+ ? val
73
+ : (val?.name ?? val?.label ?? val?.text ?? val?.affiliation);
74
  if (!name || !String(name).trim()) return undefined;
75
+ const existing = normalizedAffiliations.find(
76
+ (a) => a.name === String(name).trim(),
77
+ );
78
  if (existing) return existing.id;
79
  const id = normalizedAffiliations.length + 1;
80
+ normalizedAffiliations.push({
81
+ id,
82
+ name: String(name).trim(),
83
+ url: val?.url || val?.link,
84
+ });
85
  return id;
86
  };
87
 
88
  // Normalize authors and map affiliations -> indices (Distill-like)
89
+ const normalizedAuthors: Author[] = (
90
+ Array.isArray(rawAuthors) ? rawAuthors : []
91
+ )
92
  .map((a: any) => {
93
+ if (typeof a === "string") {
94
  return { name: a } as Author;
95
  }
96
+ const name = String(a?.name || "").trim();
97
  const url = a?.url || a?.link;
98
  let indices: number[] | undefined = undefined;
99
  const raw = a?.affiliations ?? a?.affiliation ?? a?.affils;
100
  if (raw != null) {
101
  const entries = Array.isArray(raw) ? raw : [raw];
102
+ const ids = entries
103
+ .map(ensureAffiliation)
104
+ .filter((x): x is number => typeof x === "number");
105
  const unique = Array.from(new Set(ids)).sort((x, y) => x - y);
106
  if (unique.length) indices = unique;
107
  }
108
  return { name, url, affiliationIndices: indices } as Author;
109
  })
110
  .filter((a: Author) => a.name && a.name.trim().length > 0);
111
+ const authorNames: string[] = normalizedAuthors.map((a) => a.name);
112
  const published = articleFM?.published ?? undefined;
113
  const tags = articleFM?.tags ?? [];
114
  // Prefer seoThumbImage from frontmatter if provided
115
  const fmOg = articleFM?.seoThumbImage as string | undefined;
116
+ const imageAbs: string =
117
+ fmOg && fmOg.startsWith("http")
118
+ ? fmOg
119
+ : Astro.site
120
+ ? new URL(fmOg ?? ogDefaultUrl, Astro.site).toString()
121
+ : (fmOg ?? ogDefaultUrl);
122
 
123
  // ---- Build citation text & BibTeX from frontmatter ----
124
+ const stripHtml = (text: string) => String(text || "").replace(/<[^>]*>/g, "");
125
+ const rawTitle = articleFM?.title ?? "Untitled article";
126
  const titleFlat = stripHtml(String(rawTitle))
127
+ .replace(/\\n/g, " ")
128
+ .replace(/\n/g, " ")
129
+ .replace(/\s+/g, " ")
130
  .trim();
131
  const extractYear = (val: string | undefined): number | undefined => {
132
  if (!val) return undefined;
 
137
  };
138
 
139
  const year = extractYear(published);
140
+ const citationAuthorsText = authorNames.join(", ");
141
+ const citationText = `${citationAuthorsText}${year ? ` (${year})` : ""}. "${titleFlat}".`;
142
+
143
+ const authorsBib = authorNames.join(" and ");
144
+ const keyAuthor = (authorNames[0] || "article")
145
+ .split(/\s+/)
146
+ .slice(-1)[0]
147
+ .toLowerCase();
148
+ const keyTitle = titleFlat
149
+ .toLowerCase()
150
+ .replace(/[^a-z0-9]+/g, "_")
151
+ .replace(/^_|_$/g, "")
152
+ .slice(0, 24);
153
+ const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
154
+ const doi = (ArticleMod as any)?.frontmatter?.doi
155
+ ? String((ArticleMod as any).frontmatter.doi)
156
+ : undefined;
157
+ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ""}${doi ? `doi={${doi}}` : ""}\n}`;
158
  const envCollapse = false;
159
  const tableOfContentAutoCollapse = Boolean(
160
+ (articleFM as any)?.tableOfContentAutoCollapse ??
161
+ (articleFM as any)?.tableOfContentsAutoCollapse ??
162
+ envCollapse,
163
  );
164
  // Licence note (HTML allowed)
165
+ const licence =
166
+ (articleFM as any)?.licence ??
167
+ (articleFM as any)?.license ??
168
+ (articleFM as any)?.licenseNote;
169
  ---
170
+
171
+ <html
172
+ lang="en"
173
+ data-theme="light"
174
+ data-toc-auto-collapse={tableOfContentAutoCollapse ? "1" : "0"}
175
+ >
176
  <head>
177
  <meta charset="utf-8" />
178
  <meta name="viewport" content="width=device-width, initial-scale=1" />
179
+ <Seo
180
+ title={docTitle}
181
+ description={description}
182
+ authors={authorNames}
183
+ published={published}
184
+ tags={tags}
185
+ image={imageAbs}
186
+ />
187
  <script is:inline>
188
  (() => {
189
  try {
190
+ const saved = localStorage.getItem("theme");
191
+ const prefersDark =
192
+ window.matchMedia &&
193
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
194
+ const theme = saved || (prefersDark ? "dark" : "light");
195
+ document.documentElement.setAttribute("data-theme", theme);
196
  } catch {}
197
  })();
198
  </script>
199
  <script type="module" src="/scripts/color-palettes.js"></script>
200
+
201
  <!-- TO MANAGE PROPERLY -->
202
+ <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"
203
+ ></script>
204
  <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
205
+ <script
206
+ src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"
207
+ ></script>
208
+ <script>
209
+ // Debug et initialisation globale du zoom
210
+ console.log("Global script: Starting zoom initialization");
211
+
212
+ function initializeZoom() {
213
+ console.log("Global script: Looking for zoomable images");
214
+ const zoomableImages = document.querySelectorAll(
215
+ 'img[data-zoomable="1"]',
216
+ );
217
+ console.log("Global script: Found images:", zoomableImages.length);
218
+
219
+ if (window.mediumZoom && zoomableImages.length > 0) {
220
+ zoomableImages.forEach((img, index) => {
221
+ console.log(`Global script: Processing image ${index}:`, img);
222
+
223
+ // VΓ©rifier si dΓ©jΓ  initialisΓ©
224
+ if (!img.classList.contains("medium-zoom-image")) {
225
+ try {
226
+ const instance = window.mediumZoom(img, {
227
+ background: "rgba(0,0,0,.85)",
228
+ margin: 24,
229
+ scrollOffset: 0,
230
+ });
231
+ console.log(
232
+ `Global script: Zoom initialized for image ${index}:`,
233
+ instance,
234
+ );
235
+ } catch (error) {
236
+ console.error(
237
+ `Global script: Error initializing zoom for image ${index}:`,
238
+ error,
239
+ );
240
+ }
241
+ } else {
242
+ console.log(`Global script: Image ${index} already has zoom`);
243
+ }
244
+ });
245
+ } else {
246
+ console.log(
247
+ "Global script: mediumZoom not available or no images found",
248
+ );
249
+ }
250
+ }
251
+
252
+ // Essayer d'initialiser immΓ©diatement
253
+ if (document.readyState === "loading") {
254
+ document.addEventListener("DOMContentLoaded", initializeZoom);
255
+ } else {
256
+ initializeZoom();
257
+ }
258
 
259
+ // Aussi essayer après le chargement complet
260
+ window.addEventListener("load", () => {
261
+ setTimeout(initializeZoom, 100);
262
+ });
263
+ </script>
264
  </head>
265
  <body>
266
  <ThemeToggle />
267
+ <Hero
268
+ title={docTitleHtml}
269
+ titleRaw={docTitle}
270
+ description={subtitle}
271
+ authors={normalizedAuthors as any}
272
+ affiliations={normalizedAffiliations as any}
273
+ affiliation={articleFM?.affiliation}
274
+ published={articleFM?.published}
275
+ doi={doi}
276
+ />
277
 
278
  <section class="content-grid">
279
+ <TableOfContents
280
+ tableOfContentAutoCollapse={tableOfContentAutoCollapse}
281
+ />
282
  <main>
283
  <Article />
284
  </main>
285
  </section>
286
 
287
+ <Footer
288
+ citationText={citationText}
289
+ bibtex={bibtex}
290
+ licence={licence}
291
+ doi={doi}
292
+ />
293
 
294
  <script>
295
  // Open external links in a new tab; keep internal anchors in-page
296
  const setExternalTargets = () => {
297
  const isExternal = (href) => {
298
+ try {
299
+ const u = new URL(href, location.href);
300
+ return u.origin !== location.origin;
301
+ } catch {
302
+ return false;
303
+ }
304
  };
305
+ document.querySelectorAll("a[href]").forEach((a) => {
306
+ const href = a.getAttribute("href");
307
  if (!href) return;
308
  if (isExternal(href)) {
309
+ a.setAttribute("target", "_blank");
310
+ a.setAttribute("rel", "noopener noreferrer");
311
  } else {
312
+ a.removeAttribute("target");
313
  }
314
  });
315
  };
316
+ if (document.readyState === "loading") {
317
+ document.addEventListener("DOMContentLoaded", setExternalTargets, {
318
+ once: true,
319
+ });
320
+ } else {
321
+ setExternalTargets();
322
+ }
323
  </script>
324
 
325
  <script>
326
  // Delegate copy clicks for code blocks injected by rehypeCodeCopy
327
+ document.addEventListener("click", async (e) => {
328
  const target = e.target instanceof Element ? e.target : null;
329
+ const btn = target ? target.closest(".code-copy") : null;
330
  if (!btn) return;
331
+ const card = btn.closest(".code-card");
332
+ const pre = card && card.querySelector("pre");
333
  if (!pre) return;
334
+ const text = pre.textContent || "";
335
  try {
336
  await navigator.clipboard.writeText(text.trim());
337
  const old = btn.innerHTML;
338
+ btn.innerHTML =
339
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
340
  setTimeout(() => (btn.innerHTML = old), 1200);
341
  } catch {
342
+ btn.textContent = "Error";
343
+ setTimeout(() => (btn.textContent = "Copy"), 1200);
344
  }
345
  });
346
  </script>
 
 
347
  </body>
348
  </html>
 
 
app/src/styles/_base.css CHANGED
@@ -1,9 +1,17 @@
1
  @import "https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200..900;1,200..900&display=swap";
2
 
3
- html { font-size: 16px; line-height: 1.6; }
 
 
 
4
 
5
- .content-grid main { color: var(--text-color); }
6
- .content-grid main p { margin: 0 0 var(--spacing-3); }
 
 
 
 
 
7
 
8
  .content-grid main h2 {
9
  font-weight: 600;
@@ -29,25 +37,54 @@ html { font-size: 16px; line-height: 1.6; }
29
  margin: var(--spacing-8) 0 var(--spacing-4);
30
  }
31
 
32
- .content-grid main a { color: var(--primary-color); text-decoration: none; border-bottom: 1px solid var(--link-underline); }
33
- .content-grid main a:hover { color: var(--primary-color-hover); border-bottom: 1px solid var(--link-underline-hover); }
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  /* Do not underline heading links inside the article (not the TOC) */
36
  .content-grid main h2 a,
37
  .content-grid main h3 a,
38
  .content-grid main h4 a,
39
  .content-grid main h5 a,
40
- .content-grid main h6 a { color: inherit; border-bottom: none; text-decoration: none; }
 
 
 
 
 
41
  .content-grid main h2 a:hover,
42
  .content-grid main h3 a:hover,
43
  .content-grid main h4 a:hover,
44
  .content-grid main h5 a:hover,
45
- .content-grid main h6 a:hover { color: inherit; border-bottom: none; text-decoration: none; }
 
 
 
 
46
 
47
  .content-grid main ul,
48
- .content-grid main ol { padding-left: 24px; margin: 0 0 var(--spacing-3); }
49
- .content-grid main li { margin-bottom: var(--spacing-2); }
50
- .content-grid main li:last-child { margin-bottom: 0; }
 
 
 
 
 
 
 
 
 
51
 
52
  .content-grid main blockquote {
53
  border-left: 2px solid var(--border-color);
@@ -57,7 +94,11 @@ html { font-size: 16px; line-height: 1.6; }
57
  margin: var(--spacing-4) 0;
58
  }
59
 
60
- .content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
 
 
 
 
61
 
62
  .muted {
63
  color: var(--muted-color);
@@ -65,4 +106,62 @@ html { font-size: 16px; line-height: 1.6; }
65
 
66
  [data-footnote-ref] {
67
  margin-left: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
 
1
  @import "https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200..900;1,200..900&display=swap";
2
 
3
+ html {
4
+ font-size: 16px;
5
+ line-height: 1.6;
6
+ }
7
 
8
+ .content-grid main {
9
+ color: var(--text-color);
10
+ }
11
+
12
+ .content-grid main p {
13
+ margin: 0 0 var(--spacing-3);
14
+ }
15
 
16
  .content-grid main h2 {
17
  font-weight: 600;
 
37
  margin: var(--spacing-8) 0 var(--spacing-4);
38
  }
39
 
40
+ .content-grid main a {
41
+ color: var(--primary-color);
42
+ text-decoration: none;
43
+ background: var(--sufrace-bg);
44
+ border-bottom: 1px solid color-mix(in srgb, var(--primary-color, #007AFF) 40%, transparent);
45
+ ;
46
+ }
47
+
48
+ .content-grid main a:hover {
49
+ color: var(--primary-color-hover);
50
+ border-bottom: 1px solid color-mix(in srgb, var(--primary-color, #007AFF) 40%, transparent);
51
+ ;
52
+ }
53
 
54
  /* Do not underline heading links inside the article (not the TOC) */
55
  .content-grid main h2 a,
56
  .content-grid main h3 a,
57
  .content-grid main h4 a,
58
  .content-grid main h5 a,
59
+ .content-grid main h6 a {
60
+ color: inherit;
61
+ border-bottom: none;
62
+ text-decoration: none;
63
+ }
64
+
65
  .content-grid main h2 a:hover,
66
  .content-grid main h3 a:hover,
67
  .content-grid main h4 a:hover,
68
  .content-grid main h5 a:hover,
69
+ .content-grid main h6 a:hover {
70
+ color: inherit;
71
+ border-bottom: none;
72
+ text-decoration: none;
73
+ }
74
 
75
  .content-grid main ul,
76
+ .content-grid main ol {
77
+ padding-left: 24px;
78
+ margin: 0 0 var(--spacing-3);
79
+ }
80
+
81
+ .content-grid main li {
82
+ margin-bottom: var(--spacing-2);
83
+ }
84
+
85
+ .content-grid main li:last-child {
86
+ margin-bottom: 0;
87
+ }
88
 
89
  .content-grid main blockquote {
90
  border-left: 2px solid var(--border-color);
 
94
  margin: var(--spacing-4) 0;
95
  }
96
 
97
+ .content-grid main hr {
98
+ border: none;
99
+ border-bottom: 1px solid var(--border-color);
100
+ margin: var(--spacing-5) 0;
101
+ }
102
 
103
  .muted {
104
  color: var(--muted-color);
 
106
 
107
  [data-footnote-ref] {
108
  margin-left: 4px;
109
+ }
110
+
111
+ .content-grid main mark {
112
+ background-color: color-mix(in srgb, var(--primary-color, #007AFF) 10%, transparent);
113
+ border: 1px solid color-mix(in srgb, var(--primary-color) 18%, transparent);
114
+ color: inherit;
115
+ padding: 4px 6px;
116
+ border-radius: 4px;
117
+ font-weight: 500;
118
+ box-decoration-break: clone;
119
+ -webkit-box-decoration-break: clone;
120
+ }
121
+
122
+ .feature-grid {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
125
+ gap: 12px;
126
+ margin: 46px 0;
127
+ }
128
+
129
+ .feature-card {
130
+ display: flex;
131
+ flex-direction: column;
132
+ padding: 16px;
133
+ border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
134
+ ;
135
+ background: color-mix(in srgb, var(--primary-color, #007AFF) 05%, transparent) !important;
136
+ border-radius: 8px;
137
+ text-decoration: none;
138
+ color: inherit;
139
+ transition: all 0.2s ease;
140
+ }
141
+
142
+ .feature-card:hover {
143
+ transform: translateY(-2px);
144
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
145
+ }
146
+
147
+ .feature-card strong {
148
+ font-size: 14px;
149
+ font-weight: 600;
150
+ color: var(--text-color);
151
+ color: var(--primary-color) !important;
152
+ margin-bottom: 0px !important;
153
+ }
154
+
155
+ .feature-card span {
156
+ font-size: 12px;
157
+ color: var(--muted-color);
158
+ color: var(--primary-color) !important;
159
+ margin-bottom: 0px !important;
160
+ opacity: 1;
161
+ }
162
+
163
+ .katex .tag {
164
+ background: none;
165
+ border: none;
166
+ opacity: 0.4;
167
  }
app/src/styles/_variables.css CHANGED
@@ -8,7 +8,7 @@
8
  --neutral-300: rgb(228, 228, 228);
9
  --neutral-200: rgb(245, 245, 245);
10
 
11
- --default-font-family: Source Sans Pro,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
12
 
13
  /* Brand (OKLCH base + derived states) */
14
  --primary-base: oklch(0.75 0.12 340);
@@ -19,10 +19,10 @@
19
 
20
  /* Text & Surfaces */
21
  --page-bg: #ffffff;
22
- --text-color: rgba(0,0,0,.85);
23
- --transparent-page-contrast: rgba(255,255,255,.85);
24
- --muted-color: rgba(0,0,0,.6);
25
- --border-color: rgba(0,0,0,.1);
26
  --surface-bg: #fafafa;
27
  --code-bg: #f6f8fa;
28
 
@@ -52,8 +52,10 @@
52
  @custom-media --bp-content-collapse (max-width: 1100px);
53
 
54
  /* Layout */
55
- --content-padding-x: 16px; /* default page gutter */
56
- --block-spacing-y: var(--spacing-4); /* default vertical spacing between block components */
 
 
57
 
58
  /* Config */
59
  --palette-count: 8;
@@ -86,7 +88,7 @@
86
  /* Charts (global) */
87
  --axis-color: var(--muted-color);
88
  --tick-color: var(--text-color);
89
- --grid-color: rgba(0,0,0,.08);
90
  }
91
 
92
  /* ============================================================================ */
@@ -94,17 +96,17 @@
94
  /* ============================================================================ */
95
  [data-theme="dark"] {
96
  --page-bg: #0f1115;
97
- --text-color: rgba(255,255,255,.9);
98
- --muted-color: rgba(255,255,255,.7);
99
- --border-color: rgba(255,255,255,.15);
100
  --surface-bg: #12151b;
101
  --code-bg: #12151b;
102
- --transparent-page-contrast: rgba(0,0,0,.85);
103
-
104
  /* Charts (global) */
105
  --axis-color: var(--muted-color);
106
  --tick-color: var(--muted-color);
107
- --grid-color: rgba(255,255,255,.10);
108
 
109
  /* Primary (lower L in dark) */
110
  --primary-color: oklch(from var(--primary-base) calc(l - 0.08) c h);
 
8
  --neutral-300: rgb(228, 228, 228);
9
  --neutral-200: rgb(245, 245, 245);
10
 
11
+ --default-font-family: Source Sans Pro, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
12
 
13
  /* Brand (OKLCH base + derived states) */
14
  --primary-base: oklch(0.75 0.12 340);
 
19
 
20
  /* Text & Surfaces */
21
  --page-bg: #ffffff;
22
+ --text-color: rgba(0, 0, 0, .85);
23
+ --transparent-page-contrast: rgba(255, 255, 255, .85);
24
+ --muted-color: rgba(0, 0, 0, .6);
25
+ --border-color: rgba(0, 0, 0, .1);
26
  --surface-bg: #fafafa;
27
  --code-bg: #f6f8fa;
28
 
 
52
  @custom-media --bp-content-collapse (max-width: 1100px);
53
 
54
  /* Layout */
55
+ --content-padding-x: 16px;
56
+ /* default page gutter */
57
+ --block-spacing-y: var(--spacing-4);
58
+ /* default vertical spacing between block components */
59
 
60
  /* Config */
61
  --palette-count: 8;
 
88
  /* Charts (global) */
89
  --axis-color: var(--muted-color);
90
  --tick-color: var(--text-color);
91
+ --grid-color: rgba(0, 0, 0, .08);
92
  }
93
 
94
  /* ============================================================================ */
 
96
  /* ============================================================================ */
97
  [data-theme="dark"] {
98
  --page-bg: #0f1115;
99
+ --text-color: rgba(255, 255, 255, .9);
100
+ --muted-color: rgba(255, 255, 255, .7);
101
+ --border-color: rgba(255, 255, 255, .15);
102
  --surface-bg: #12151b;
103
  --code-bg: #12151b;
104
+ --transparent-page-contrast: rgba(0, 0, 0, .85);
105
+
106
  /* Charts (global) */
107
  --axis-color: var(--muted-color);
108
  --tick-color: var(--muted-color);
109
+ --grid-color: rgba(255, 255, 255, .10);
110
 
111
  /* Primary (lower L in dark) */
112
  --primary-color: oklch(from var(--primary-base) calc(l - 0.08) c h);