thibaud frere commited on
Commit
a4b9560
·
1 Parent(s): ebb053a

big refactor

Browse files
Files changed (37) hide show
  1. app/.astro/astro/content.d.ts +20 -6
  2. app/astro.config.mjs +11 -139
  3. app/plugins/rehype/code-copy-and-label.mjs +129 -0
  4. app/plugins/rehype/post-citation.mjs +441 -0
  5. app/plugins/rehype/restore-at-in-code.mjs +22 -0
  6. app/plugins/rehype/wrap-tables.mjs +43 -0
  7. app/plugins/remark/ignore-citations-in-code.mjs +21 -0
  8. app/src/components/Accordion.astro +25 -4
  9. app/src/components/Footer.astro +182 -16
  10. app/src/components/Hero.astro +146 -40
  11. app/src/components/HtmlEmbed.astro +34 -5
  12. app/src/components/Note.astro +17 -11
  13. app/src/components/ResponsiveImage.astro +46 -0
  14. app/src/components/Sidenote.astro +28 -3
  15. app/src/components/{TableOfContent.astro → TableOfContents.astro} +136 -10
  16. app/src/components/ThemeToggle.astro +7 -0
  17. app/src/content/article.mdx +25 -5
  18. app/src/content/bibliography.bib +0 -12
  19. app/src/content/chapters/{available-blocks.mdx → components.mdx} +41 -187
  20. app/src/content/chapters/debug-components.mdx +37 -0
  21. app/src/content/chapters/getting-started.mdx +2 -0
  22. app/src/content/chapters/introduction.mdx +13 -11
  23. app/src/content/chapters/markdown.mdx +285 -0
  24. app/src/content/chapters/writing-your-content.mdx +41 -96
  25. app/src/pages/index.astro +84 -23
  26. app/src/styles/_base.css +4 -229
  27. app/src/styles/_layout.css +21 -76
  28. app/src/styles/_print.css +66 -0
  29. app/src/styles/_reset.css +13 -0
  30. app/src/styles/_variables.css +62 -25
  31. app/src/styles/components/_button.css +58 -0
  32. app/src/styles/components/_code.css +113 -35
  33. app/src/styles/components/_footer.css +0 -56
  34. app/src/styles/components/_poltly.css +0 -44
  35. app/src/styles/components/_table.css +95 -0
  36. app/src/styles/components/_tag.css +14 -0
  37. app/src/styles/global.css +20 -49
app/.astro/astro/content.d.ts CHANGED
@@ -152,16 +152,23 @@ declare module 'astro:content' {
152
 
153
  type ContentEntryMap = {
154
  "chapters": {
155
- "available-blocks.mdx": {
156
- id: "available-blocks.mdx";
157
- slug: "available-blocks";
158
  body: string;
159
  collection: "chapters";
160
  data: any
161
  } & { render(): Render[".mdx"] };
162
- "best-pratices.mdx": {
163
- id: "best-pratices.mdx";
164
- slug: "best-pratices";
 
 
 
 
 
 
 
165
  body: string;
166
  collection: "chapters";
167
  data: any
@@ -180,6 +187,13 @@ declare module 'astro:content' {
180
  collection: "chapters";
181
  data: any
182
  } & { render(): Render[".mdx"] };
 
 
 
 
 
 
 
183
  "writing-your-content.mdx": {
184
  id: "writing-your-content.mdx";
185
  slug: "writing-your-content";
 
152
 
153
  type ContentEntryMap = {
154
  "chapters": {
155
+ "best-pratices.mdx": {
156
+ id: "best-pratices.mdx";
157
+ slug: "best-pratices";
158
  body: string;
159
  collection: "chapters";
160
  data: any
161
  } & { render(): Render[".mdx"] };
162
+ "components.mdx": {
163
+ id: "components.mdx";
164
+ slug: "components";
165
+ body: string;
166
+ collection: "chapters";
167
+ data: any
168
+ } & { render(): Render[".mdx"] };
169
+ "debug-components.mdx": {
170
+ id: "debug-components.mdx";
171
+ slug: "debug-components";
172
  body: string;
173
  collection: "chapters";
174
  data: any
 
187
  collection: "chapters";
188
  data: any
189
  } & { render(): Render[".mdx"] };
190
+ "markdown.mdx": {
191
+ id: "markdown.mdx";
192
+ slug: "markdown";
193
+ body: string;
194
+ collection: "chapters";
195
+ data: any
196
+ } & { render(): Render[".mdx"] };
197
  "writing-your-content.mdx": {
198
  id: "writing-your-content.mdx";
199
  slug: "writing-your-content";
app/astro.config.mjs CHANGED
@@ -8,137 +8,14 @@ import remarkFootnotes from 'remark-footnotes';
8
  import rehypeSlug from 'rehype-slug';
9
  import rehypeAutolinkHeadings from 'rehype-autolink-headings';
10
  import rehypeCitation from 'rehype-citation';
 
 
 
 
 
11
  // Built-in Shiki (dual themes) — no rehype-pretty-code
12
 
13
- // Minimal rehype plugin to wrap code blocks with a copy button and a language label
14
- function rehypeCodeCopyAndLabel() {
15
- return (tree) => {
16
- // Walk the tree; lightweight visitor to find <pre><code>
17
- const visit = (node, parent) => {
18
- if (!node || typeof node !== 'object') return;
19
- const children = Array.isArray(node.children) ? node.children : [];
20
- if (node.tagName === 'pre' && children.some(c => c.tagName === 'code')) {
21
- // Find code child and guess language
22
- const code = children.find(c => c.tagName === 'code');
23
- const collectClasses = (val) => Array.isArray(val) ? val.map(String) : (typeof val === 'string' ? String(val).split(/\s+/) : []);
24
- const fromClass = (names) => {
25
- const hit = names.find((n) => /^language-/.test(String(n)));
26
- return hit ? String(hit).replace(/^language-/, '') : '';
27
- };
28
- const codeClasses = collectClasses(code?.properties?.className);
29
- const preClasses = collectClasses(node?.properties?.className);
30
- const candidates = [
31
- code?.properties?.['data-language'],
32
- fromClass(codeClasses),
33
- node?.properties?.['data-language'],
34
- fromClass(preClasses),
35
- ];
36
- let lang = candidates.find(Boolean) || '';
37
- const lower = String(lang).toLowerCase();
38
- const toExt = (s) => {
39
- switch (String(s).toLowerCase()) {
40
- case 'typescript': case 'ts': return 'ts';
41
- case 'tsx': return 'tsx';
42
- case 'javascript': case 'js': case 'node': return 'js';
43
- case 'jsx': return 'jsx';
44
- case 'python': case 'py': return 'py';
45
- case 'bash': case 'shell': case 'sh': return 'sh';
46
- case 'markdown': case 'md': return 'md';
47
- case 'yaml': case 'yml': return 'yml';
48
- case 'html': return 'html';
49
- case 'css': return 'css';
50
- case 'json': return 'json';
51
- default: return lower || '';
52
- }
53
- };
54
- const ext = toExt(lower);
55
- const displayLang = ext ? String(ext).toUpperCase() : '';
56
- // Determine if single-line block: prefer Shiki lines, then text content
57
- const countLinesFromShiki = () => {
58
- const isLineEl = (el) => el && el.type === 'element' && el.tagName === 'span' && Array.isArray(el.properties?.className) && el.properties.className.includes('line');
59
- const hasNonWhitespaceText = (node) => {
60
- if (!node) return false;
61
- if (node.type === 'text') return /\S/.test(String(node.value || ''));
62
- const kids = Array.isArray(node.children) ? node.children : [];
63
- return kids.some(hasNonWhitespaceText);
64
- };
65
- const collectLines = (node, acc) => {
66
- if (!node || typeof node !== 'object') return;
67
- if (isLineEl(node)) acc.push(node);
68
- const kids = Array.isArray(node.children) ? node.children : [];
69
- kids.forEach((k) => collectLines(k, acc));
70
- };
71
- const lines = [];
72
- collectLines(code, lines);
73
- const nonEmpty = lines.filter((ln) => hasNonWhitespaceText(ln)).length;
74
- return nonEmpty || 0;
75
- };
76
- const countLinesFromText = () => {
77
- // Parse raw text content of the <code> node including nested spans
78
- const extractText = (node) => {
79
- if (!node) return '';
80
- if (node.type === 'text') return String(node.value || '');
81
- const kids = Array.isArray(node.children) ? node.children : [];
82
- return kids.map(extractText).join('');
83
- };
84
- const raw = extractText(code);
85
- if (!raw || !/\S/.test(raw)) return 0;
86
- return raw.split('\n').filter(line => /\S/.test(line)).length;
87
- };
88
- const lines = countLinesFromShiki() || countLinesFromText();
89
- const isSingleLine = lines <= 1;
90
- // Also treat code blocks shorter than a threshold as single-line (defensive)
91
- if (!isSingleLine) {
92
- const approxChars = (() => {
93
- const extract = (n) => Array.isArray(n?.children) ? n.children.map(extract).join('') : (n?.type === 'text' ? String(n.value||'') : '');
94
- return extract(code).length;
95
- })();
96
- if (approxChars < 6) {
97
- // e.g., "npm i" alone
98
- // downgrade to single-line behavior
99
- node.__forceSingle = true;
100
- }
101
- }
102
- // Ensure CSS-only label works: set data-language on <code> and <pre>, and wrapper
103
- code.properties = code.properties || {};
104
- if (ext) code.properties['data-language'] = ext;
105
- node.properties = node.properties || {};
106
- if (ext) node.properties['data-language'] = ext;
107
- // Replace <pre> with wrapper div.code-card containing button + pre
108
- const wrapper = {
109
- type: 'element',
110
- tagName: 'div',
111
- properties: { className: ['code-card'].concat((isSingleLine || node.__forceSingle) ? ['no-copy'] : []), 'data-language': ext },
112
- children: (isSingleLine || node.__forceSingle) ? [ node ] : [
113
- {
114
- type: 'element',
115
- tagName: 'button',
116
- properties: { className: ['code-copy', 'button--ghost'], type: 'button', 'aria-label': 'Copy code' },
117
- children: [
118
- {
119
- type: 'element',
120
- tagName: 'svg',
121
- properties: { viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false' },
122
- children: [
123
- { type: 'element', tagName: 'path', properties: { d: 'M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' }, children: [] }
124
- ]
125
- }
126
- ]
127
- },
128
- node
129
- ]
130
- };
131
- if (parent && Array.isArray(parent.children)) {
132
- const idx = parent.children.indexOf(node);
133
- if (idx !== -1) parent.children[idx] = wrapper;
134
- }
135
- return; // don't visit nested
136
- }
137
- children.forEach((c) => visit(c, node));
138
- };
139
- visit(tree, null);
140
- };
141
- }
142
 
143
  export default defineConfig({
144
  output: 'static',
@@ -151,15 +28,6 @@ export default defineConfig({
151
  devToolbar: {
152
  enabled: false
153
  },
154
- // Expose a public flag to control TOC auto-collapse behavior at runtime
155
- // This can be overridden via environment variable PUBLIC_TOC_AUTO_COLLAPSE
156
- // (Vite define provides a default fallback when env is not set.)
157
- vite: {
158
- define: {
159
- 'import.meta.env.PUBLIC_TOC_AUTO_COLLAPSE': JSON.stringify(false),
160
- 'import.meta.env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE': JSON.stringify(false)
161
- }
162
- },
163
  markdown: {
164
  shikiConfig: {
165
  themes: {
@@ -174,6 +42,7 @@ export default defineConfig({
174
  }
175
  },
176
  remarkPlugins: [
 
177
  remarkMath,
178
  [remarkFootnotes, { inlineNotes: true }]
179
  ],
@@ -185,7 +54,10 @@ export default defineConfig({
185
  bibliography: 'src/content/bibliography.bib',
186
  linkCitations: true
187
  }],
188
- rehypeCodeCopyAndLabel
 
 
 
189
  ]
190
  }
191
  });
 
8
  import rehypeSlug from 'rehype-slug';
9
  import rehypeAutolinkHeadings from 'rehype-autolink-headings';
10
  import rehypeCitation from 'rehype-citation';
11
+ import rehypeCodeCopyAndLabel from './plugins/rehype/code-copy-and-label.mjs';
12
+ import rehypeReferencesAndFootnotes from './plugins/rehype/post-citation.mjs';
13
+ import remarkIgnoreCitationsInCode from './plugins/remark/ignore-citations-in-code.mjs';
14
+ import rehypeRestoreAtInCode from './plugins/rehype/restore-at-in-code.mjs';
15
+ import rehypeWrapTables from './plugins/rehype/wrap-tables.mjs';
16
  // Built-in Shiki (dual themes) — no rehype-pretty-code
17
 
18
+ // Plugins moved to app/plugins/*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  export default defineConfig({
21
  output: 'static',
 
28
  devToolbar: {
29
  enabled: false
30
  },
 
 
 
 
 
 
 
 
 
31
  markdown: {
32
  shikiConfig: {
33
  themes: {
 
42
  }
43
  },
44
  remarkPlugins: [
45
+ remarkIgnoreCitationsInCode,
46
  remarkMath,
47
  [remarkFootnotes, { inlineNotes: true }]
48
  ],
 
54
  bibliography: 'src/content/bibliography.bib',
55
  linkCitations: true
56
  }],
57
+ rehypeReferencesAndFootnotes,
58
+ rehypeRestoreAtInCode,
59
+ rehypeCodeCopyAndLabel,
60
+ rehypeWrapTables
61
  ]
62
  }
63
  });
app/plugins/rehype/code-copy-and-label.mjs ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Minimal rehype plugin to wrap code blocks with a copy button and a language label
2
+ // Exported as a standalone module to keep astro.config.mjs lean
3
+ export default function rehypeCodeCopyAndLabel() {
4
+ return (tree) => {
5
+ // Walk the tree; lightweight visitor to find <pre><code>
6
+ const visit = (node, parent) => {
7
+ if (!node || typeof node !== 'object') return;
8
+ const children = Array.isArray(node.children) ? node.children : [];
9
+ if (node.tagName === 'pre' && children.some(c => c.tagName === 'code')) {
10
+ // Find code child and guess language
11
+ const code = children.find(c => c.tagName === 'code');
12
+ const collectClasses = (val) => Array.isArray(val) ? val.map(String) : (typeof val === 'string' ? String(val).split(/\s+/) : []);
13
+ const fromClass = (names) => {
14
+ const hit = names.find((n) => /^language-/.test(String(n)));
15
+ return hit ? String(hit).replace(/^language-/, '') : '';
16
+ };
17
+ const codeClasses = collectClasses(code?.properties?.className);
18
+ const preClasses = collectClasses(node?.properties?.className);
19
+ const candidates = [
20
+ code?.properties?.['data-language'],
21
+ fromClass(codeClasses),
22
+ node?.properties?.['data-language'],
23
+ fromClass(preClasses),
24
+ ];
25
+ let lang = candidates.find(Boolean) || '';
26
+ const lower = String(lang).toLowerCase();
27
+ const toExt = (s) => {
28
+ switch (String(s).toLowerCase()) {
29
+ case 'typescript': case 'ts': return 'ts';
30
+ case 'tsx': return 'tsx';
31
+ case 'javascript': case 'js': case 'node': return 'js';
32
+ case 'jsx': return 'jsx';
33
+ case 'python': case 'py': return 'py';
34
+ case 'bash': case 'shell': case 'sh': return 'sh';
35
+ case 'markdown': case 'md': return 'md';
36
+ case 'yaml': case 'yml': return 'yml';
37
+ case 'html': return 'html';
38
+ case 'css': return 'css';
39
+ case 'json': return 'json';
40
+ default: return lower || '';
41
+ }
42
+ };
43
+ const ext = toExt(lower);
44
+ // Determine if single-line block: prefer Shiki lines, then text content
45
+ const countLinesFromShiki = () => {
46
+ const isLineEl = (el) => el && el.type === 'element' && el.tagName === 'span' && Array.isArray(el.properties?.className) && el.properties.className.includes('line');
47
+ const hasNonWhitespaceText = (node) => {
48
+ if (!node) return false;
49
+ if (node.type === 'text') return /\S/.test(String(node.value || ''));
50
+ const kids = Array.isArray(node.children) ? node.children : [];
51
+ return kids.some(hasNonWhitespaceText);
52
+ };
53
+ const collectLines = (node, acc) => {
54
+ if (!node || typeof node !== 'object') return;
55
+ if (isLineEl(node)) acc.push(node);
56
+ const kids = Array.isArray(node.children) ? node.children : [];
57
+ kids.forEach((k) => collectLines(k, acc));
58
+ };
59
+ const lines = [];
60
+ collectLines(code, lines);
61
+ const nonEmpty = lines.filter((ln) => hasNonWhitespaceText(ln)).length;
62
+ return nonEmpty || 0;
63
+ };
64
+ const countLinesFromText = () => {
65
+ // Parse raw text content of the <code> node including nested spans
66
+ const extractText = (node) => {
67
+ if (!node) return '';
68
+ if (node.type === 'text') return String(node.value || '');
69
+ const kids = Array.isArray(node.children) ? node.children : [];
70
+ return kids.map(extractText).join('');
71
+ };
72
+ const raw = extractText(code);
73
+ if (!raw || !/\S/.test(raw)) return 0;
74
+ return raw.split('\n').filter(line => /\S/.test(line)).length;
75
+ };
76
+ const lines = countLinesFromShiki() || countLinesFromText();
77
+ const isSingleLine = lines <= 1;
78
+ // Also treat code blocks shorter than a threshold as single-line (defensive)
79
+ if (!isSingleLine) {
80
+ const approxChars = (() => {
81
+ const extract = (n) => Array.isArray(n?.children) ? n.children.map(extract).join('') : (n?.type === 'text' ? String(n.value||'') : '');
82
+ return extract(code).length;
83
+ })();
84
+ if (approxChars < 6) {
85
+ node.__forceSingle = true;
86
+ }
87
+ }
88
+ // Ensure CSS-only label works: set data-language on <code> and <pre>, and wrapper
89
+ code.properties = code.properties || {};
90
+ if (ext) code.properties['data-language'] = ext;
91
+ node.properties = node.properties || {};
92
+ if (ext) node.properties['data-language'] = ext;
93
+ // Replace <pre> with wrapper div.code-card containing button + pre
94
+ const wrapper = {
95
+ type: 'element',
96
+ tagName: 'div',
97
+ properties: { className: ['code-card'].concat((isSingleLine || node.__forceSingle) ? ['no-copy'] : []), 'data-language': ext },
98
+ children: (isSingleLine || node.__forceSingle) ? [ node ] : [
99
+ {
100
+ type: 'element',
101
+ tagName: 'button',
102
+ properties: { className: ['code-copy', 'button--ghost'], type: 'button', 'aria-label': 'Copy code' },
103
+ children: [
104
+ {
105
+ type: 'element',
106
+ tagName: 'svg',
107
+ properties: { viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false' },
108
+ children: [
109
+ { type: 'element', tagName: 'path', properties: { d: 'M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' }, children: [] }
110
+ ]
111
+ }
112
+ ]
113
+ },
114
+ node
115
+ ]
116
+ };
117
+ if (parent && Array.isArray(parent.children)) {
118
+ const idx = parent.children.indexOf(node);
119
+ if (idx !== -1) parent.children[idx] = wrapper;
120
+ }
121
+ return; // don't visit nested
122
+ }
123
+ children.forEach((c) => visit(c, node));
124
+ };
125
+ visit(tree, null);
126
+ };
127
+ }
128
+
129
+
app/plugins/rehype/post-citation.mjs ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // rehype plugin to post-process citations and footnotes at build-time
2
+ // - Normalizes the bibliography into <ol class="references"> with <li id="...">
3
+ // - Linkifies DOI/URL occurrences inside references
4
+ // - Appends back-reference links (↩ back: 1, 2, ...) from each reference to in-text citation anchors
5
+ // - Cleans up footnotes block (.footnotes)
6
+
7
+ export default function rehypeReferencesAndFootnotes() {
8
+ return (tree) => {
9
+ const isElement = (n) => n && typeof n === 'object' && n.type === 'element';
10
+ const getChildren = (n) => (Array.isArray(n?.children) ? n.children : []);
11
+
12
+ const walk = (node, parent, fn) => {
13
+ if (!node || typeof node !== 'object') return;
14
+ fn && fn(node, parent);
15
+ const kids = getChildren(node);
16
+ for (const child of kids) walk(child, node, fn);
17
+ };
18
+
19
+ const ensureArray = (v) => (Array.isArray(v) ? v : v != null ? [v] : []);
20
+
21
+ const hasClass = (el, name) => {
22
+ const cn = ensureArray(el?.properties?.className).map(String);
23
+ return cn.includes(name);
24
+ };
25
+
26
+ const setAttr = (el, key, val) => {
27
+ el.properties = el.properties || {};
28
+ if (val == null) delete el.properties[key];
29
+ else el.properties[key] = val;
30
+ };
31
+
32
+ const getAttr = (el, key) => (el?.properties ? el.properties[key] : undefined);
33
+
34
+ // Shared helpers for backlinks + backrefs block
35
+ const collectBacklinksForIdSet = (idSet, anchorPrefix) => {
36
+ const idToBacklinks = new Map();
37
+ const idToAnchorNodes = new Map();
38
+ if (!idSet || idSet.size === 0) return { idToBacklinks, idToAnchorNodes };
39
+ walk(tree, null, (node) => {
40
+ if (!isElement(node) || node.tagName !== 'a') return;
41
+ const href = String(getAttr(node, 'href') || '');
42
+ if (!href.startsWith('#')) return;
43
+ const id = href.slice(1);
44
+ if (!idSet.has(id)) return;
45
+ // Ensure a stable id
46
+ let anchorId = String(getAttr(node, 'id') || '');
47
+ if (!anchorId) {
48
+ const list = idToBacklinks.get(id) || [];
49
+ anchorId = `${anchorPrefix}-${id}-${list.length + 1}`;
50
+ setAttr(node, 'id', anchorId);
51
+ }
52
+ const list = idToBacklinks.get(id) || [];
53
+ list.push(anchorId);
54
+ idToBacklinks.set(id, list);
55
+ const nodes = idToAnchorNodes.get(id) || [];
56
+ nodes.push(node);
57
+ idToAnchorNodes.set(id, nodes);
58
+ });
59
+ return { idToBacklinks, idToAnchorNodes };
60
+ };
61
+
62
+ const createBackIcon = () => ({
63
+ type: 'element',
64
+ tagName: 'svg',
65
+ properties: {
66
+ className: ['back-icon'],
67
+ width: 12,
68
+ height: 12,
69
+ viewBox: '0 0 24 24',
70
+ fill: 'none',
71
+ stroke: 'currentColor',
72
+ 'stroke-width': 2,
73
+ 'stroke-linecap': 'round',
74
+ 'stroke-linejoin': 'round',
75
+ 'aria-hidden': 'true',
76
+ focusable: 'false'
77
+ },
78
+ children: [
79
+ { type: 'element', tagName: 'line', properties: { x1: 12, y1: 19, x2: 12, y2: 5 }, children: [] },
80
+ { type: 'element', tagName: 'polyline', properties: { points: '5 12 12 5 19 12' }, children: [] }
81
+ ]
82
+ });
83
+
84
+ const appendBackrefsBlock = (listElement, idToBacklinks, ariaLabel) => {
85
+ if (!listElement || !idToBacklinks || idToBacklinks.size === 0) return;
86
+ for (const li of getChildren(listElement)) {
87
+ if (!isElement(li) || li.tagName !== 'li') continue;
88
+ const id = String(getAttr(li, 'id') || '');
89
+ if (!id) continue;
90
+ const keys = idToBacklinks.get(id);
91
+ if (!keys || !keys.length) continue;
92
+ // Remove pre-existing .backrefs in this li to avoid duplicates
93
+ li.children = getChildren(li).filter((n) => !(isElement(n) && n.tagName === 'small' && hasClass(n, 'backrefs')));
94
+ const small = {
95
+ type: 'element',
96
+ tagName: 'small',
97
+ properties: { className: ['backrefs'] },
98
+ children: []
99
+ };
100
+ if (keys.length === 1) {
101
+ // Single backlink: just the icon wrapped in the anchor
102
+ const a = {
103
+ type: 'element',
104
+ tagName: 'a',
105
+ properties: { href: `#${keys[0]}`, 'aria-label': ariaLabel },
106
+ children: [ createBackIcon() ]
107
+ };
108
+ small.children.push(a);
109
+ } else {
110
+ // Multiple backlinks: icon + label + numbered links
111
+ small.children.push(createBackIcon());
112
+ small.children.push({ type: 'text', value: ' back: ' });
113
+ keys.forEach((backId, idx) => {
114
+ small.children.push({
115
+ type: 'element',
116
+ tagName: 'a',
117
+ properties: { href: `#${backId}`, 'aria-label': ariaLabel },
118
+ children: [ { type: 'text', value: String(idx + 1) } ]
119
+ });
120
+ if (idx < keys.length - 1) small.children.push({ type: 'text', value: ', ' });
121
+ });
122
+ }
123
+ li.children.push(small);
124
+ }
125
+ };
126
+ // Remove default back-reference anchors generated by remark-footnotes inside a footnote item
127
+ const getTextContent = (el) => {
128
+ if (!el) return '';
129
+ const stack = [el];
130
+ let out = '';
131
+ while (stack.length) {
132
+ const cur = stack.pop();
133
+ if (!cur) continue;
134
+ if (cur.type === 'text') out += String(cur.value || '');
135
+ const kids = getChildren(cur);
136
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
137
+ }
138
+ return out;
139
+ };
140
+
141
+ const removeFootnoteBackrefAnchors = (el) => {
142
+ if (!isElement(el)) return;
143
+ const kids = getChildren(el);
144
+ for (let i = kids.length - 1; i >= 0; i--) {
145
+ const child = kids[i];
146
+ if (isElement(child)) {
147
+ if (
148
+ child.tagName === 'a' && (
149
+ getAttr(child, 'data-footnote-backref') != null ||
150
+ hasClass(child, 'footnote-backref') ||
151
+ String(getAttr(child, 'role') || '').toLowerCase() === 'doc-backlink' ||
152
+ String(getAttr(child, 'aria-label') || '').toLowerCase().includes('back to content') ||
153
+ String(getAttr(child, 'href') || '').startsWith('#fnref') ||
154
+ // Fallback: text-based detection like "↩" or "↩2"
155
+ /^\s*↩\s*\d*\s*$/u.test(getTextContent(child))
156
+ )
157
+ ) {
158
+ // Remove the anchor
159
+ el.children.splice(i, 1);
160
+ continue;
161
+ }
162
+ // Recurse into element
163
+ removeFootnoteBackrefAnchors(child);
164
+ // If a wrapper like <sup> or <span> became empty, remove it
165
+ const becameKids = getChildren(child);
166
+ if ((child.tagName === 'sup' || child.tagName === 'span') && (!becameKids || becameKids.length === 0)) {
167
+ el.children.splice(i, 1);
168
+ }
169
+ }
170
+ }
171
+ };
172
+
173
+
174
+ const normDoiHref = (href) => {
175
+ if (!href) return href;
176
+ const DUP = /https?:\/\/(?:dx\.)?doi\.org\/(?:https?:\/\/(?:dx\.)?doi\.org\/)+/gi;
177
+ const ONE = /https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i;
178
+ href = String(href).replace(DUP, 'https://doi.org/');
179
+ const m = href.match(ONE);
180
+ return m ? `https://doi.org/${m[1]}` : href;
181
+ };
182
+
183
+ const DOI_BARE = /\b10\.[0-9]{4,9}\/[\-._;()\/:A-Z0-9]+\b/gi;
184
+ const URL_GEN = /\bhttps?:\/\/[^\s<>()"']+/gi;
185
+
186
+ const linkifyTextNode = (textNode) => {
187
+ const text = String(textNode.value || '');
188
+ let last = 0;
189
+ const parts = [];
190
+ const pushText = (s) => { if (s) parts.push({ type: 'text', value: s }); };
191
+
192
+ const matches = [];
193
+ // Collect URL matches
194
+ let m;
195
+ URL_GEN.lastIndex = 0;
196
+ while ((m = URL_GEN.exec(text)) !== null) {
197
+ matches.push({ type: 'url', start: m.index, end: URL_GEN.lastIndex, raw: m[0] });
198
+ }
199
+ // Collect DOI matches
200
+ DOI_BARE.lastIndex = 0;
201
+ while ((m = DOI_BARE.exec(text)) !== null) {
202
+ matches.push({ type: 'doi', start: m.index, end: DOI_BARE.lastIndex, raw: m[0] });
203
+ }
204
+ matches.sort((a, b) => a.start - b.start);
205
+
206
+ for (const match of matches) {
207
+ if (match.start < last) continue; // overlapping
208
+ pushText(text.slice(last, match.start));
209
+ if (match.type === 'url') {
210
+ const href = normDoiHref(match.raw);
211
+ const doiOne = href.match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i);
212
+ const a = {
213
+ type: 'element',
214
+ tagName: 'a',
215
+ properties: { href, target: '_blank', rel: 'noopener noreferrer' },
216
+ children: [{ type: 'text', value: doiOne ? doiOne[1] : href }]
217
+ };
218
+ parts.push(a);
219
+ } else {
220
+ const href = `https://doi.org/${match.raw}`;
221
+ const a = {
222
+ type: 'element',
223
+ tagName: 'a',
224
+ properties: { href, target: '_blank', rel: 'noopener noreferrer' },
225
+ children: [{ type: 'text', value: match.raw }]
226
+ };
227
+ parts.push(a);
228
+ }
229
+ last = match.end;
230
+ }
231
+
232
+ pushText(text.slice(last));
233
+ return parts;
234
+ };
235
+
236
+ const linkifyInElement = (el) => {
237
+ const kids = getChildren(el);
238
+ for (let i = 0; i < kids.length; i++) {
239
+ const child = kids[i];
240
+ if (!child) continue;
241
+ if (child.type === 'text') {
242
+ const replacement = linkifyTextNode(child);
243
+ if (replacement.length === 1 && replacement[0].type === 'text') continue;
244
+ // Replace the single text node with multiple nodes
245
+ el.children.splice(i, 1, ...replacement);
246
+ i += replacement.length - 1;
247
+ } else if (isElement(child)) {
248
+ if (child.tagName === 'a') {
249
+ const href = normDoiHref(getAttr(child, 'href'));
250
+ setAttr(child, 'href', href);
251
+ const m = String(href || '').match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i);
252
+ if (m && (!child.children || child.children.length === 0)) {
253
+ child.children = [{ type: 'text', value: m[1] }];
254
+ }
255
+ continue;
256
+ }
257
+ linkifyInElement(child);
258
+ }
259
+ }
260
+ // Deduplicate adjacent identical anchors
261
+ for (let i = 1; i < el.children.length; i++) {
262
+ const prev = el.children[i - 1];
263
+ const curr = el.children[i];
264
+ if (isElement(prev) && isElement(curr) && prev.tagName === 'a' && curr.tagName === 'a') {
265
+ const key = `${getAttr(prev, 'href') || ''}|${(prev.children?.[0]?.value) || ''}`;
266
+ const key2 = `${getAttr(curr, 'href') || ''}|${(curr.children?.[0]?.value) || ''}`;
267
+ if (key === key2) {
268
+ el.children.splice(i, 1);
269
+ i--;
270
+ }
271
+ }
272
+ }
273
+ };
274
+
275
+ // Find references container and normalize its list
276
+ const findReferencesRoot = () => {
277
+ let found = null;
278
+ walk(tree, null, (node) => {
279
+ if (found) return;
280
+ if (!isElement(node)) return;
281
+ const id = getAttr(node, 'id');
282
+ if (id === 'references' || hasClass(node, 'references') || hasClass(node, 'bibliography')) {
283
+ found = node;
284
+ }
285
+ });
286
+ return found;
287
+ };
288
+
289
+ const toOrderedList = (container) => {
290
+ // If there is already an <ol>, use it; otherwise convert common structures
291
+ let ol = getChildren(container).find((c) => isElement(c) && c.tagName === 'ol');
292
+ if (!ol) {
293
+ ol = { type: 'element', tagName: 'ol', properties: { className: ['references'] }, children: [] };
294
+ const candidates = getChildren(container).filter((n) => isElement(n));
295
+ if (candidates.length) {
296
+ for (const node of candidates) {
297
+ if (hasClass(node, 'csl-entry') || node.tagName === 'li' || node.tagName === 'p' || node.tagName === 'div') {
298
+ const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(node) };
299
+ if (getAttr(node, 'id')) setAttr(li, 'id', getAttr(node, 'id'));
300
+ ol.children.push(li);
301
+ }
302
+ }
303
+ }
304
+ // Replace container children by the new ol
305
+ container.children = [ol];
306
+ }
307
+ if (!hasClass(ol, 'references')) {
308
+ const cls = ensureArray(ol.properties?.className).map(String);
309
+ if (!cls.includes('references')) cls.push('references');
310
+ ol.properties = ol.properties || {};
311
+ ol.properties.className = cls;
312
+ }
313
+ return ol;
314
+ };
315
+
316
+ const refsRoot = findReferencesRoot();
317
+ let refsOl = null;
318
+ const refIdSet = new Set();
319
+ const refIdToExternalHref = new Map();
320
+
321
+ if (refsRoot) {
322
+ refsOl = toOrderedList(refsRoot);
323
+ // Collect item ids and linkify their content
324
+ for (const li of getChildren(refsOl)) {
325
+ if (!isElement(li) || li.tagName !== 'li') continue;
326
+ if (!getAttr(li, 'id')) {
327
+ // Try to find a nested element with id to promote
328
+ const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id'));
329
+ if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
330
+ }
331
+ const id = getAttr(li, 'id');
332
+ if (id) refIdSet.add(String(id));
333
+ linkifyInElement(li);
334
+ // Record first external link href (e.g., DOI/URL) if present
335
+ if (id) {
336
+ let externalHref = null;
337
+ const stack = [li];
338
+ while (stack.length) {
339
+ const cur = stack.pop();
340
+ const kids = getChildren(cur);
341
+ for (const k of kids) {
342
+ if (isElement(k) && k.tagName === 'a') {
343
+ const href = String(getAttr(k, 'href') || '');
344
+ if (/^https?:\/\//i.test(href)) {
345
+ externalHref = href;
346
+ break;
347
+ }
348
+ }
349
+ if (isElement(k)) stack.push(k);
350
+ }
351
+ if (externalHref) break;
352
+ }
353
+ if (externalHref) refIdToExternalHref.set(String(id), externalHref);
354
+ }
355
+ }
356
+ setAttr(refsRoot, 'data-built-refs', '1');
357
+ }
358
+
359
+ // Collect in-text anchors that point to references ids
360
+ const { idToBacklinks: refIdToBacklinks, idToAnchorNodes: refIdToCitationAnchors } = collectBacklinksForIdSet(refIdSet, 'refctx');
361
+
362
+ // Append backlinks into references list items
363
+ appendBackrefsBlock(refsOl, refIdToBacklinks, 'Back to citation');
364
+
365
+ // Rewrite in-text citation anchors to external link when available
366
+ if (refIdToCitationAnchors.size > 0) {
367
+ for (const [id, anchors] of refIdToCitationAnchors.entries()) {
368
+ const ext = refIdToExternalHref.get(id);
369
+ if (!ext) continue;
370
+ for (const a of anchors) {
371
+ setAttr(a, 'data-ref-id', id);
372
+ setAttr(a, 'href', ext);
373
+ const existingTarget = getAttr(a, 'target');
374
+ if (!existingTarget) setAttr(a, 'target', '_blank');
375
+ const rel = String(getAttr(a, 'rel') || '');
376
+ const relSet = new Set(rel ? rel.split(/\s+/) : []);
377
+ relSet.add('noopener');
378
+ relSet.add('noreferrer');
379
+ setAttr(a, 'rel', Array.from(relSet).join(' '));
380
+ }
381
+ }
382
+ }
383
+
384
+ // Footnotes cleanup + backrefs harmonized with references
385
+ const cleanupFootnotes = () => {
386
+ let root = null;
387
+ walk(tree, null, (node) => {
388
+ if (!isElement(node)) return;
389
+ if (hasClass(node, 'footnotes')) root = node;
390
+ });
391
+ if (!root) return { root: null, ol: null, idSet: new Set() };
392
+ // Remove <hr> direct children
393
+ root.children = getChildren(root).filter((n) => !(isElement(n) && n.tagName === 'hr'));
394
+ // Ensure an <ol>
395
+ let ol = getChildren(root).find((c) => isElement(c) && c.tagName === 'ol');
396
+ if (!ol) {
397
+ ol = { type: 'element', tagName: 'ol', properties: {}, children: [] };
398
+ const items = getChildren(root).filter((n) => isElement(n) && (n.tagName === 'li' || hasClass(n, 'footnote') || n.tagName === 'p' || n.tagName === 'div'));
399
+ if (items.length) {
400
+ for (const it of items) {
401
+ const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(it) };
402
+ // Promote nested id if present (e.g., <p id="fn-1">)
403
+ const nestedWithId = getChildren(it).find((n) => isElement(n) && getAttr(n, 'id'));
404
+ if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
405
+ ol.children.push(li);
406
+ }
407
+ }
408
+ root.children = [ol];
409
+ }
410
+ // For existing structures, try to promote ids from children when missing
411
+ for (const li of getChildren(ol)) {
412
+ if (!isElement(li) || li.tagName !== 'li') continue;
413
+ if (!getAttr(li, 'id')) {
414
+ const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id'));
415
+ if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
416
+ }
417
+ // Remove default footnote backrefs anywhere inside (to avoid duplication)
418
+ removeFootnoteBackrefAnchors(li);
419
+ }
420
+ setAttr(root, 'data-built-footnotes', '1');
421
+ // Collect id set
422
+ const idSet = new Set();
423
+ for (const li of getChildren(ol)) {
424
+ if (!isElement(li) || li.tagName !== 'li') continue;
425
+ const id = getAttr(li, 'id');
426
+ if (id) idSet.add(String(id));
427
+ }
428
+ return { root, ol, idSet };
429
+ };
430
+
431
+ const { root: footRoot, ol: footOl, idSet: footIdSet } = cleanupFootnotes();
432
+
433
+ // Collect in-text anchors pointing to footnotes
434
+ const { idToBacklinks: footIdToBacklinks } = collectBacklinksForIdSet(footIdSet, 'footctx');
435
+
436
+ // Append backlinks into footnote list items (identical pattern to references)
437
+ appendBackrefsBlock(footOl, footIdToBacklinks, 'Back to footnote call');
438
+ };
439
+ }
440
+
441
+
app/plugins/rehype/restore-at-in-code.mjs ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Rehype plugin to restore '@' inside code nodes after rehype-citation ran
2
+ export default function rehypeRestoreAtInCode() {
3
+ return (tree) => {
4
+ const restoreInNode = (node) => {
5
+ if (!node || typeof node !== 'object') return;
6
+ const isText = node.type === 'text';
7
+ if (isText && typeof node.value === 'string' && node.value.includes('__AT_SENTINEL__')) {
8
+ node.value = node.value.replace(/__AT_SENTINEL__/g, '@');
9
+ }
10
+ const isCodeEl = node.type === 'element' && node.tagName === 'code';
11
+ const children = Array.isArray(node.children) ? node.children : [];
12
+ if (isCodeEl && children.length) {
13
+ children.forEach(restoreInNode);
14
+ return;
15
+ }
16
+ children.forEach(restoreInNode);
17
+ };
18
+ restoreInNode(tree);
19
+ };
20
+ }
21
+
22
+
app/plugins/rehype/wrap-tables.mjs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // rehype plugin: wrap bare <table> elements in a <div class="table-scroll"> container
2
+ // so that tables stay width:100% while enabling horizontal scroll when content overflows
3
+
4
+ export default function rehypeWrapTables() {
5
+ return (tree) => {
6
+ const isElement = (n) => n && typeof n === 'object' && n.type === 'element';
7
+ const getChildren = (n) => (Array.isArray(n?.children) ? n.children : []);
8
+
9
+ const walk = (node, parent, fn) => {
10
+ if (!node || typeof node !== 'object') return;
11
+ fn && fn(node, parent);
12
+ const kids = getChildren(node);
13
+ for (const child of kids) walk(child, node, fn);
14
+ };
15
+
16
+ const ensureArray = (v) => (Array.isArray(v) ? v : v != null ? [v] : []);
17
+ const hasClass = (el, name) => ensureArray(el?.properties?.className).map(String).includes(name);
18
+
19
+ const wrapTable = (tableNode, parent) => {
20
+ if (!parent || !Array.isArray(parent.children)) return;
21
+ // Don't double-wrap if already inside .table-scroll
22
+ if (parent.tagName === 'div' && hasClass(parent, 'table-scroll')) return;
23
+
24
+ const wrapper = {
25
+ type: 'element',
26
+ tagName: 'div',
27
+ properties: { className: ['table-scroll'] },
28
+ children: [tableNode]
29
+ };
30
+
31
+ const idx = parent.children.indexOf(tableNode);
32
+ if (idx >= 0) parent.children.splice(idx, 1, wrapper);
33
+ };
34
+
35
+ walk(tree, null, (node, parent) => {
36
+ if (!isElement(node)) return;
37
+ if (node.tagName !== 'table') return;
38
+ wrapTable(node, parent);
39
+ });
40
+ };
41
+ }
42
+
43
+
app/plugins/remark/ignore-citations-in-code.mjs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Remark plugin to ignore citations inside code (block and inline)
2
+ export default function remarkIgnoreCitationsInCode() {
3
+ return (tree) => {
4
+ const visit = (node) => {
5
+ if (!node || typeof node !== 'object') return;
6
+ const type = node.type;
7
+ if (type === 'code' || type === 'inlineCode') {
8
+ if (typeof node.value === 'string' && node.value.includes('@')) {
9
+ // Use a sentinel to avoid rehype-citation, will be restored later in rehype
10
+ node.value = node.value.replace(/@/g, '__AT_SENTINEL__');
11
+ }
12
+ return; // do not traverse into code
13
+ }
14
+ const children = Array.isArray(node.children) ? node.children : [];
15
+ children.forEach(visit);
16
+ };
17
+ visit(tree);
18
+ };
19
+ }
20
+
21
+
app/src/components/Accordion.astro CHANGED
@@ -76,6 +76,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
76
 
77
  <style>
78
  .accordion {
 
 
79
  border: 1px solid var(--border-color);
80
  border-radius: 10px;
81
  background: var(--surface-bg);
@@ -87,12 +89,13 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
87
  }
88
 
89
  .accordion__summary {
 
90
  list-style: none;
91
  display: flex;
92
  align-items: center;
93
  justify-content: space-between;
94
  gap: 4px;
95
- padding: 4px;
96
  cursor: pointer;
97
  color: var(--text-color);
98
  user-select: none;
@@ -131,7 +134,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
131
  }
132
 
133
  .accordion__content {
134
- padding: 12px 4px 4px;
 
135
  }
136
 
137
  /* Ensure the very last slotted element has no bottom spacing */
@@ -140,15 +144,30 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
140
  margin-bottom: 0 !important;
141
  }
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  /* Separator between header and content when open (edge-to-edge) */
144
  .accordion[open] .accordion__content-wrapper::before {
145
  content: "";
146
  position: absolute;
147
  left: 0;
148
  right: 0;
149
- top: 6px; /* space below header */
150
  height: 1px;
151
- background: var(--neutral-300);
152
  pointer-events: none;
153
  }
154
 
@@ -158,6 +177,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
158
  outline-offset: 3px;
159
  border-radius: 8px;
160
  }
 
 
161
  </style>
162
 
163
 
 
76
 
77
  <style>
78
  .accordion {
79
+ margin: 0 0 var(--spacing-4);
80
+ padding: 0;
81
  border: 1px solid var(--border-color);
82
  border-radius: 10px;
83
  background: var(--surface-bg);
 
89
  }
90
 
91
  .accordion__summary {
92
+ margin: 0;
93
  list-style: none;
94
  display: flex;
95
  align-items: center;
96
  justify-content: space-between;
97
  gap: 4px;
98
+ padding: 10px 8px;
99
  cursor: pointer;
100
  color: var(--text-color);
101
  user-select: none;
 
134
  }
135
 
136
  .accordion__content {
137
+ margin: 0;
138
+ padding: 0;
139
  }
140
 
141
  /* Ensure the very last slotted element has no bottom spacing */
 
144
  margin-bottom: 0 !important;
145
  }
146
 
147
+ /* Ensure the very first slotted element has no top spacing */
148
+ .accordion .accordion__content > :global(*:first-child) {
149
+ margin-top: 0 !important;
150
+ }
151
+
152
+ /* Content padding: default for direct children, opt-out for code/tables */
153
+ .accordion .accordion__content > :global(*) {
154
+ padding: 8px;
155
+ }
156
+ .accordion .accordion__content > :global(.table-scroll),
157
+ .accordion .accordion__content > :global(pre),
158
+ .accordion .accordion__content > :global(.code-card) {
159
+ padding: 0;
160
+ }
161
+
162
  /* Separator between header and content when open (edge-to-edge) */
163
  .accordion[open] .accordion__content-wrapper::before {
164
  content: "";
165
  position: absolute;
166
  left: 0;
167
  right: 0;
168
+ top: 0px; /* space below header */
169
  height: 1px;
170
+ background: var(--border-color);
171
  pointer-events: none;
172
  }
173
 
 
177
  outline-offset: 3px;
178
  border-radius: 8px;
179
  }
180
+
181
+
182
  </style>
183
 
184
 
app/src/components/Footer.astro CHANGED
@@ -2,10 +2,12 @@
2
  interface Props {
3
  citationText: string;
4
  bibtex: string;
 
 
5
  }
6
- const { citationText, bibtex } = Astro.props as Props;
7
  ---
8
- <footer class="distill-footer">
9
  <div class="footer-inner">
10
  <section class="citation-block">
11
  <h3>Citation</h3>
@@ -15,6 +17,18 @@ const { citationText, bibtex } = Astro.props as Props;
15
  <p>BibTeX citation</p>
16
  <pre class="citation long">{bibtex}</pre>
17
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
18
  <section class="references-block">
19
  <slot />
20
  </section>
@@ -24,7 +38,7 @@ const { citationText, bibtex } = Astro.props as Props;
24
 
25
  <script is:inline>
26
  (() => {
27
- const getFooter = () => document.currentScript?.closest('footer') || document.querySelector('footer.distill-footer');
28
  const footer = getFooter();
29
  if (!footer) return;
30
  const target = footer.querySelector('.references-block');
@@ -32,14 +46,6 @@ const { citationText, bibtex } = Astro.props as Props;
32
 
33
  const contentRoot = document.querySelector('section.content-grid main') || document.querySelector('main') || document.body;
34
 
35
- const findFirstOutsideFooter = (selectors) => {
36
- for (const sel of selectors) {
37
- const el = contentRoot.querySelector(sel);
38
- if (el && !footer.contains(el)) return el;
39
- }
40
- return null;
41
- };
42
-
43
  const ensureHeading = (text) => {
44
  const exists = Array.from(target.children).some((c) => c.tagName === 'H3' && c.textContent.trim().toLowerCase() === text.toLowerCase());
45
  if (!exists) {
@@ -51,10 +57,6 @@ const { citationText, bibtex } = Astro.props as Props;
51
 
52
  const moveIntoFooter = (element, headingText) => {
53
  if (!element) return false;
54
- if (element.classList.contains('footnotes')) {
55
- const hr = element.querySelector('hr');
56
- if (hr) hr.remove();
57
- }
58
  // Remove an eventual heading already included inside the block (avoid duplicates)
59
  const firstHeading = element.querySelector(':scope > h1, :scope > h2, :scope > h3');
60
  if (firstHeading) {
@@ -68,10 +70,18 @@ const { citationText, bibtex } = Astro.props as Props;
68
  target.appendChild(element);
69
  return true;
70
  };
71
-
72
  const run = () => {
 
 
 
 
 
 
 
 
73
  const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
74
  const footnotesEl = findFirstOutsideFooter(['.footnotes']);
 
75
  const movedRefs = moveIntoFooter(referencesEl, 'References');
76
  const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
77
  return movedRefs || movedNotes;
@@ -94,3 +104,159 @@ const { citationText, bibtex } = Astro.props as Props;
94
  </script>
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  interface Props {
3
  citationText: string;
4
  bibtex: string;
5
+ licence?: string;
6
+ doi?: string;
7
  }
8
+ const { citationText, bibtex, licence, doi } = Astro.props as Props;
9
  ---
10
+ <footer class="footer">
11
  <div class="footer-inner">
12
  <section class="citation-block">
13
  <h3>Citation</h3>
 
17
  <p>BibTeX citation</p>
18
  <pre class="citation long">{bibtex}</pre>
19
  </section>
20
+ {doi && (
21
+ <section class="doi-block">
22
+ <h3>DOI</h3>
23
+ <p><a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer">{doi}</a></p>
24
+ </section>
25
+ )}
26
+ {licence && (
27
+ <section class="reuse-block">
28
+ <h3>Reuse</h3>
29
+ <p set:html={licence}></p>
30
+ </section>
31
+ )}
32
  <section class="references-block">
33
  <slot />
34
  </section>
 
38
 
39
  <script is:inline>
40
  (() => {
41
+ const getFooter = () => document.currentScript?.closest('footer') || document.querySelector('footer.footer');
42
  const footer = getFooter();
43
  if (!footer) return;
44
  const target = footer.querySelector('.references-block');
 
46
 
47
  const contentRoot = document.querySelector('section.content-grid main') || document.querySelector('main') || document.body;
48
 
 
 
 
 
 
 
 
 
49
  const ensureHeading = (text) => {
50
  const exists = Array.from(target.children).some((c) => c.tagName === 'H3' && c.textContent.trim().toLowerCase() === text.toLowerCase());
51
  if (!exists) {
 
57
 
58
  const moveIntoFooter = (element, headingText) => {
59
  if (!element) return false;
 
 
 
 
60
  // Remove an eventual heading already included inside the block (avoid duplicates)
61
  const firstHeading = element.querySelector(':scope > h1, :scope > h2, :scope > h3');
62
  if (firstHeading) {
 
70
  target.appendChild(element);
71
  return true;
72
  };
 
73
  const run = () => {
74
+ const findFirstOutsideFooter = (selectors) => {
75
+ for (const sel of selectors) {
76
+ const el = contentRoot.querySelector(sel);
77
+ if (el && !footer.contains(el)) return el;
78
+ }
79
+ return null;
80
+ };
81
+
82
  const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
83
  const footnotesEl = findFirstOutsideFooter(['.footnotes']);
84
+
85
  const movedRefs = moveIntoFooter(referencesEl, 'References');
86
  const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
87
  return movedRefs || movedNotes;
 
104
  </script>
105
 
106
 
107
+ <style is:global>
108
+ .footer {
109
+ contain: layout style;
110
+ font-size: 0.8em;
111
+ line-height: 1.7em;
112
+ margin-top: 60px;
113
+ margin-bottom: 0;
114
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
115
+ color: rgba(0, 0, 0, 0.5);
116
+ }
117
+
118
+ .footer-inner {
119
+ max-width: 1280px;
120
+ margin: 0 auto;
121
+ padding: 60px 16px 48px;
122
+ display: grid;
123
+ grid-template-columns: 220px minmax(0, 680px) 260px;
124
+ gap: 32px;
125
+ align-items: start;
126
+ }
127
+
128
+ /* Use the parent grid (3 columns like .content-grid) */
129
+ .citation-block,
130
+ .references-block,
131
+ .reuse-block,
132
+ .doi-block {
133
+ display: contents;
134
+ }
135
+
136
+ .citation-block > h3,
137
+ .references-block > h3,
138
+ .reuse-block > h3,
139
+ .doi-block > h3 {
140
+ grid-column: 1;
141
+ font-size: 15px;
142
+ margin: 0;
143
+ text-align: right;
144
+ padding-right: 30px;
145
+ }
146
+
147
+ .citation-block > :not(h3),
148
+ .references-block > :not(h3),
149
+ .reuse-block > :not(h3),
150
+ .doi-block > :not(h3) {
151
+ grid-column: 2;
152
+ }
153
+
154
+ @media (max-width: var(--bp-content-collapse)) {
155
+ .footer-inner {
156
+ grid-template-columns: 1fr;
157
+ gap: 16px;
158
+ }
159
+
160
+ .footer-inner > h3 {
161
+ grid-column: auto;
162
+ margin-top: 16px;
163
+ }
164
+ }
165
+
166
+ .citation-block h3 {
167
+ margin: 0 0 8px;
168
+ }
169
+
170
+ .citation-block h4 {
171
+ margin: 16px 0 8px;
172
+ font-size: 14px;
173
+ text-transform: uppercase;
174
+ color: var(--muted-color);
175
+ }
176
+
177
+ .citation-block p,
178
+ .reuse-block p,
179
+ .doi-block p,
180
+ .footnotes ol,
181
+ .footnotes ol p,
182
+ .references {
183
+ margin-top: 0;
184
+ }
185
+
186
+ /* Distill-like appendix citation styling */
187
+ .citation {
188
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
189
+ font-size: 11px;
190
+ line-height: 15px;
191
+ border-left: 1px solid rgba(0, 0, 0, 0.1);
192
+ padding-left: 18px;
193
+ border: 1px solid rgba(0,0,0,0.1);
194
+ background: rgba(0, 0, 0, 0.02);
195
+ padding: 10px 18px;
196
+ border-radius: 3px;
197
+ color: rgba(150, 150, 150, 1);
198
+ overflow: hidden;
199
+ margin-top: -12px;
200
+ white-space: pre-wrap;
201
+ word-wrap: break-word;
202
+ }
203
+
204
+ .citation a {
205
+ color: rgba(0, 0, 0, 0.6);
206
+ text-decoration: underline;
207
+ }
208
+
209
+ .citation.short {
210
+ margin-top: -4px;
211
+ }
212
+
213
+ .references-block h3 {
214
+ margin: 0;
215
+ }
216
+
217
+ /* Distill-like list styling for references/footnotes */
218
+ .references-block ol {
219
+ padding: 0 0 0 15px;
220
+ }
221
+
222
+ @media (min-width: 768px) {
223
+ .references-block ol {
224
+ padding: 0 0 0 30px;
225
+ margin-left: -30px;
226
+ }
227
+ }
228
+
229
+ .references-block li {
230
+ margin-bottom: 1em;
231
+ }
232
+
233
+ .references-block a {
234
+ color: var(--text-color);
235
+ }
236
+
237
+ [data-theme="dark"] .footer { border-top-color: rgba(255,255,255,.15); color: rgba(200,200,200,.8); }
238
+ [data-theme="dark"] .citation { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,.15); color: rgba(200,200,200,1); }
239
+ [data-theme="dark"] .citation a { color: rgba(255,255,255,0.75); }
240
+
241
+
242
+ @media (max-width: var(--bp-content-collapse)) {
243
+ .footer-inner {
244
+ display: block;
245
+ padding: 40px 16px;
246
+ }
247
+ }
248
+
249
+ /* Footer links: use primary color consistently */
250
+ .footer a {
251
+ color: var(--primary-color);
252
+ border-bottom: 1px solid var(--link-underline);
253
+ text-decoration: none;
254
+ }
255
+ .footer a:hover {
256
+ color: var(--primary-color-hover);
257
+ border-bottom-color: var(--link-underline-hover);
258
+ }
259
+ [data-theme="dark"] .footer a {
260
+ color: var(--primary-color);
261
+ }
262
+ </style>
app/src/components/Hero.astro CHANGED
@@ -5,12 +5,32 @@ interface Props {
5
  title: string; // may contain HTML (e.g., <br/>)
6
  titleRaw?: string; // plain title for slug/PDF (optional)
7
  description?: string;
8
- authors?: string[];
9
- affiliation?: string;
 
10
  published?: string;
 
11
  }
12
 
13
- const { title, titleRaw, description, authors = [], affiliation, published } = Astro.props as Props;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  function stripHtml(text: string): string {
16
  return String(text || '').replace(/<[^>]*>/g, '');
@@ -37,15 +57,36 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
37
  </div>
38
  </section>
39
 
40
- <header class="meta">
41
  <div class="meta-container">
42
- {authors.length > 0 && (
43
  <div class="meta-container-cell">
44
- <h3>Author {authors.length > 1 ? 's' : ''}</h3>
45
- <p>{authors.join(', ')}</p>
 
 
 
 
 
 
 
 
 
 
 
46
  </div>
47
  )}
48
- {affiliation && (
 
 
 
 
 
 
 
 
 
 
49
  <div class="meta-container-cell">
50
  <h3>Affiliation</h3>
51
  <p>{affiliation}</p>
@@ -57,49 +98,114 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
57
  <p>{published}</p>
58
  </div>
59
  )}
 
 
 
 
 
 
60
  <div class="meta-container-cell meta-container-cell--pdf">
61
  <h3>PDF</h3>
62
- <p><button id="download-pdf-btn" data-pdf-filename={pdfFilename}>Download PDF</button></p>
 
 
 
 
63
  </div>
64
  </div>
65
  </header>
66
 
67
- <script>
68
- // Attach a handler to trigger a programmatic download
69
- (() => {
70
- const ready = () => {
71
- const btn = document.getElementById('download-pdf-btn');
72
- if (!btn) return;
73
- btn.addEventListener('click', () => {
74
- const a = document.createElement('a');
75
- const pdf = btn.getAttribute('data-pdf-filename') || 'article.pdf';
76
- a.href = `/${pdf}`;
77
- a.setAttribute('download', pdf);
78
- document.body.appendChild(a);
79
- a.click();
80
- a.remove();
81
- });
82
- };
83
- if (document.readyState === 'loading') {
84
- document.addEventListener('DOMContentLoaded', ready, { once: true });
85
- } else { ready(); }
86
- })();
87
- </script>
88
 
89
  <style>
90
  /* Hero (full-width) */
91
- .hero { width: 100%; padding: 48px 16px 16px; text-align: center; }
92
- .hero-title { font-size: clamp(28px, 4vw, 48px); font-weight: 800; line-height: 1.1; margin: 0 0 8px; max-width: 100%; margin: auto; }
93
- .hero-banner { max-width: 980px; margin: 0 auto; }
94
- .hero-desc { color: var(--muted-color); font-style: italic; margin: 0 0 16px 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  /* Meta (byline-like header) */
97
- .meta { border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); padding: 1rem 0; font-size: 0.9rem; line-height: 1.8em; }
98
- .meta-container { max-width: 720px; display: flex; flex-direction: row; justify-content: space-between; margin: 0 auto; gap: 8px; }
99
- .meta-container-cell { display: flex; flex-direction: column; gap: 8px; }
100
- .meta-container-cell h3 { margin: 0; font-size: 12px; font-weight: 400; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
101
- .meta-container-cell p { margin: 0; }
102
- @media print { .meta-container-cell--pdf { display: none !important; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </style>
104
 
105
 
 
5
  title: string; // may contain HTML (e.g., <br/>)
6
  titleRaw?: string; // plain title for slug/PDF (optional)
7
  description?: string;
8
+ authors?: Array<string | { name: string; url?: string; affiliationIndices?: number[] }>;
9
+ affiliations?: Array<{ id: number; name: string; url?: string }>;
10
+ affiliation?: string; // legacy single affiliation
11
  published?: string;
12
+ doi?: string;
13
  }
14
 
15
+ const { title, titleRaw, description, authors = [], affiliations = [], affiliation, published, doi } = Astro.props as Props;
16
+
17
+ type Author = { name: string; url?: string; affiliationIndices?: number[] };
18
+
19
+ function normalizeAuthors(input: Array<string | { name?: string; url?: string; link?: string; affiliationIndices?: number[] }>): Author[] {
20
+ return (Array.isArray(input) ? input : [])
21
+ .map((a) => {
22
+ if (typeof a === 'string') {
23
+ return { name: a } as Author;
24
+ }
25
+ const name = (a?.name ?? '').toString();
26
+ const url = (a?.url ?? a?.link) as string | undefined;
27
+ const affiliationIndices = Array.isArray((a as any)?.affiliationIndices) ? (a as any).affiliationIndices : undefined;
28
+ return { name, url, affiliationIndices } as Author;
29
+ })
30
+ .filter((a) => a.name && a.name.trim().length > 0);
31
+ }
32
+
33
+ const normalizedAuthors: Author[] = normalizeAuthors(authors as any);
34
 
35
  function stripHtml(text: string): string {
36
  return String(text || '').replace(/<[^>]*>/g, '');
 
57
  </div>
58
  </section>
59
 
60
+ <header class="meta" aria-label="Article meta information">
61
  <div class="meta-container">
62
+ {normalizedAuthors.length > 0 && (
63
  <div class="meta-container-cell">
64
+ <h3>Author{normalizedAuthors.length > 1 ? 's' : ''}</h3>
65
+ <ul class="authors">
66
+ {normalizedAuthors.map((a, i) => {
67
+ const supers = Array.isArray(a.affiliationIndices) && a.affiliationIndices.length
68
+ ? <sup>{a.affiliationIndices.join(',')}</sup>
69
+ : null;
70
+ return (
71
+ <li>
72
+ {a.url ? <a href={a.url}>{a.name}</a> : a.name}{supers}
73
+ </li>
74
+ );
75
+ })}
76
+ </ul>
77
  </div>
78
  )}
79
+ {(Array.isArray(affiliations) && affiliations.length > 0) && (
80
+ <div class="meta-container-cell">
81
+ <h3>Affiliation{affiliations.length > 1 ? 's' : ''}</h3>
82
+ <ol class="affiliations">
83
+ {affiliations.map((af) => (
84
+ <li value={af.id}>{af.url ? <a href={af.url} target="_blank" rel="noopener noreferrer">{af.name}</a> : af.name}</li>
85
+ ))}
86
+ </ol>
87
+ </div>
88
+ )}
89
+ {(!affiliations || affiliations.length === 0) && affiliation && (
90
  <div class="meta-container-cell">
91
  <h3>Affiliation</h3>
92
  <p>{affiliation}</p>
 
98
  <p>{published}</p>
99
  </div>
100
  )}
101
+ <!-- {doi && (
102
+ <div class="meta-container-cell">
103
+ <h3>DOI</h3>
104
+ <p><a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer">{doi}</a></p>
105
+ </div>
106
+ )} -->
107
  <div class="meta-container-cell meta-container-cell--pdf">
108
  <h3>PDF</h3>
109
+ <p>
110
+ <a class="button" href={`/${pdfFilename}`} download={pdfFilename} aria-label={`Download PDF ${pdfFilename}`}>
111
+ Download PDF
112
+ </a>
113
+ </p>
114
  </div>
115
  </div>
116
  </header>
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  <style>
120
  /* Hero (full-width) */
121
+ .hero {
122
+ width: 100%;
123
+ padding: 48px 16px 16px;
124
+ text-align: center;
125
+ }
126
+ .hero-title {
127
+ font-size: clamp(28px, 4vw, 48px);
128
+ font-weight: 800;
129
+ line-height: 1.1;
130
+ margin: 0 0 8px;
131
+ max-width: 100%;
132
+ margin: auto;
133
+ }
134
+ .hero-banner {
135
+ max-width: 980px;
136
+ margin: 0 auto;
137
+ }
138
+ .hero-desc {
139
+ color: var(--muted-color);
140
+ font-style: italic;
141
+ margin: 0 0 16px 0;
142
+ }
143
 
144
  /* Meta (byline-like header) */
145
+ .meta {
146
+ border-top: 1px solid var(--border-color);
147
+ border-bottom: 1px solid var(--border-color);
148
+ padding: 1rem 0;
149
+ font-size: 0.9rem;
150
+ }
151
+ .meta-container {
152
+ max-width: 760px;
153
+ display: flex;
154
+ flex-direction: row;
155
+ justify-content: space-between;
156
+ margin: 0 auto;
157
+ padding: 0 var(--content-padding-x);
158
+ gap: 8px;
159
+ }
160
+ /* Subtle underline for links in meta; keep buttons without underline */
161
+ .meta-container a {
162
+ color: var(--primary-color);
163
+ text-decoration: underline;
164
+ text-underline-offset: 2px;
165
+ text-decoration-thickness: 0.06em;
166
+ text-decoration-color: var(--link-underline);
167
+ transition: text-decoration-color .15s ease-in-out;
168
+ }
169
+ .meta-container a:hover {
170
+ text-decoration-color: var(--link-underline-hover);
171
+ }
172
+ .meta-container a.button,
173
+ .meta-container .button {
174
+ text-decoration: none;
175
+ }
176
+ .meta-container-cell {
177
+ display: flex;
178
+ flex-direction: column;
179
+ gap: 8px;
180
+ }
181
+ .meta-container-cell h3 {
182
+ margin: 0;
183
+ font-size: 12px;
184
+ font-weight: 400;
185
+ color: var(--muted-color);
186
+ text-transform: uppercase;
187
+ letter-spacing: .02em;
188
+ }
189
+ .meta-container-cell p {
190
+ margin: 0;
191
+ }
192
+ .authors {
193
+ margin: 0;
194
+ list-style-type: none;
195
+ padding-left: 0;
196
+ }
197
+ .affiliations {
198
+ margin: 0;
199
+ padding-left: 1.25em;
200
+ }
201
+ .affiliations li {
202
+ margin: 0;
203
+ }
204
+ @media print {
205
+ .meta-container-cell--pdf {
206
+ display: none !important;
207
+ }
208
+ }
209
  </style>
210
 
211
 
app/src/components/HtmlEmbed.astro CHANGED
@@ -68,8 +68,8 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
68
  else execute();
69
  </script>
70
 
71
- <style>
72
- .html-embed { margin: 0; overflow: hidden; }
73
  .html-embed__title {
74
  text-align: left;
75
  font-weight: 600;
@@ -94,9 +94,38 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
94
  color: var(--muted-color);
95
  margin: 6px 0 0 0;
96
  }
97
- @media (prefers-color-scheme: dark) {
98
- [data-theme="dark"] .html-embed__card:not(.is-frameless) { background: #12151b; border-color: rgba(255,255,255,.15); }
99
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  @media print {
101
  .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; }
102
  .html-embed__card { padding: 6px; }
 
68
  else execute();
69
  </script>
70
 
71
+ <style is:global>
72
+ .html-embed { margin: 0 0 var(--block-spacing-y); overflow: hidden; }
73
  .html-embed__title {
74
  text-align: left;
75
  font-weight: 600;
 
94
  color: var(--muted-color);
95
  margin: 6px 0 0 0;
96
  }
97
+ /* Plotly fragments & controls */
98
+ .html-embed__card svg text { fill: var(--text-color) !important; }
99
+ .html-embed__card label { color: var(--text-color) !important; }
100
+ .plotly-graph-div { width: 100% !important; min-height: 320px; }
101
+ @media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
102
+ [id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
103
+ .plotly_caption { font-style: italic; margin-top: 10px; }
104
+ .plotly_controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; }
105
+ .plotly_input_container { display: flex; align-items: center; flex-direction: column; gap: 10px; }
106
+ .plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
107
+ .plotly_slider { display: flex; align-items: center; gap: 10px; }
108
+ .plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
109
+ .plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
110
+ .plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
111
+ .plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
112
+ /* Dark mode overrides for Plotly readability */
113
+ [data-theme="dark"] .html-embed__card:not(.is-frameless) { background: #12151b; border-color: rgba(255,255,255,.15); }
114
+ [data-theme="dark"] .html-embed__card .xaxislayer-above text,
115
+ [data-theme="dark"] .html-embed__card .yaxislayer-above text,
116
+ [data-theme="dark"] .html-embed__card .infolayer text,
117
+ [data-theme="dark"] .html-embed__card .legend text,
118
+ [data-theme="dark"] .html-embed__card .annotation text,
119
+ [data-theme="dark"] .html-embed__card .colorbar text,
120
+ [data-theme="dark"] .html-embed__card .hoverlayer text { fill: #fff !important; }
121
+ [data-theme="dark"] .html-embed__card .xaxislayer-above path,
122
+ [data-theme="dark"] .html-embed__card .yaxislayer-above path,
123
+ [data-theme="dark"] .html-embed__card .xlines-above,
124
+ [data-theme="dark"] .html-embed__card .ylines-above { stroke: rgba(255,255,255,.35) !important; }
125
+ [data-theme="dark"] .html-embed__card .gridlayer path { stroke: rgba(255,255,255,.15) !important; }
126
+ [data-theme="dark"] .html-embed__card .legend rect.bg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
127
+ [data-theme="dark"] .html-embed__card .hoverlayer .bg { fill: rgba(0,0,0,.8) !important; stroke: rgba(255,255,255,.2) !important; }
128
+ [data-theme="dark"] .html-embed__card .colorbar .cbbg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
129
  @media print {
130
  .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; }
131
  .html-embed__card { padding: 6px; }
app/src/components/Note.astro CHANGED
@@ -10,21 +10,27 @@ const wrapperClass = ["note", `note--${variant}`, className].filter(Boolean).joi
10
  const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
11
  ---
12
  <div class={wrapperClass} {...props}>
13
- {hasHeader && <div class="note__header">
14
- {emoji && <span class="note__emoji">{emoji}</span>}
15
- {title && <span class="note__title">{title}</span>}
16
- </div>}
17
- <div class="note__content">
18
- <slot />
 
 
 
 
19
  </div>
20
  </div>
21
 
22
  <style>
23
- .note { background: var(--surface-bg); border-left: 2px solid var(--border-color); border-radius: 4px; padding: 10px 14px; margin: 12px 0; }
24
- .note__header { display: flex; align-items: center; gap: 6px; font-weight: 600; color: var(--text-color); margin-bottom: 6px; }
25
- .note__emoji { font-size: 24px; line-height: 1; }
26
- .note__title { font-size: 13px; letter-spacing: .2px; }
27
- .note__content { color: var(--text-color); font-size: 0.95rem; }
 
 
28
  /* Ensure the very last slotted element has no bottom margin */
29
  .note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
30
 
 
10
  const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
11
  ---
12
  <div class={wrapperClass} {...props}>
13
+ <div class="note__layout">
14
+ {emoji && <div class="note__icon" aria-hidden="true">
15
+ <span class="note__emoji">{emoji}</span>
16
+ </div>}
17
+ <div class="note__body">
18
+ {title && <div class="note__title">{title}</div>}
19
+ <div class="note__content">
20
+ <slot />
21
+ </div>
22
+ </div>
23
  </div>
24
  </div>
25
 
26
  <style>
27
+ .note { background: var(--surface-bg); border-left: 2px solid var(--border-color); border-radius: 4px; padding: 10px 14px; margin: var(--block-spacing-y) 0; }
28
+ .note__layout { display: flex; align-items: center; gap: 10px; }
29
+ .note__icon { flex: 0 0 auto; line-height: 1; }
30
+ .note__emoji { font-size: 32px; line-height: 1; display: block; }
31
+ .note__body { flex: 1 1 auto; min-width: 0; }
32
+ .note__title { font-size: 13px; letter-spacing: .2px; font-weight: 600; color: var(--text-color); margin-bottom: 4px; text-align: left; }
33
+ .note__content { color: var(--text-color); font-size: 0.95rem; text-align: left; }
34
  /* Ensure the very last slotted element has no bottom margin */
35
  .note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
36
 
app/src/components/ResponsiveImage.astro CHANGED
@@ -165,3 +165,49 @@ const dataDownloadable = (downloadable === true || (imgProps as any)['data-downl
165
  </script>
166
 
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  </script>
166
 
167
 
168
+ <style>
169
+
170
+ figure { margin: var(--block-spacing-y) 0; }
171
+ figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
172
+ .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
173
+ .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
174
+
175
+ /* Zoomable overlay container (if used by any lightbox implementation) */
176
+ [data-zoom-overlay],
177
+ .zoom-overlay {
178
+ position: fixed;
179
+ inset: 0;
180
+ z-index: var(--z-overlay);
181
+ }
182
+
183
+ /* Download link inside figures */
184
+ figure .download-link { position: relative; z-index: var(--z-elevated); }
185
+
186
+ /* Opt-in zoomable images */
187
+ img[data-zoomable] { cursor: zoom-in; }
188
+ .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
189
+
190
+ /* Download button for img[data-downloadable] */
191
+ figure.has-dl-btn { position: relative; }
192
+ .dl-host { position: relative; }
193
+ .img-dl-wrap { position: relative; display: inline-block; }
194
+ .img-dl-btn {
195
+ position: absolute;
196
+ right: 8px;
197
+ bottom: 8px;
198
+ align-items: center;
199
+ justify-content: center;
200
+ width: 30px;
201
+ height: 30px;
202
+ border-radius: 6px;
203
+ color: white;
204
+ text-decoration: none;
205
+ border: 1px solid rgba(255,255,255,0.25);
206
+ z-index: var(--z-elevated);
207
+ display: none;
208
+ }
209
+ .img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
210
+ .img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
211
+ [data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
212
+ [data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
213
+ </style>
app/src/components/Sidenote.astro CHANGED
@@ -1,12 +1,37 @@
1
  ---
2
  ---
3
- <div class="margin-aside">
4
- <div class="margin-aside__main">
5
  <slot />
6
  </div>
7
- <aside class="margin-aside__aside">
8
  <slot name="aside" />
9
  </aside>
10
  </div>
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (max-width: var(--bp-content-collapse)) {
31
+ .aside__aside {
32
+ position: static;
33
+ width: auto;
34
+ margin-top: 8px;
35
+ }
36
+ }
37
+ </style>
app/src/components/{TableOfContent.astro → TableOfContents.astro} RENAMED
@@ -2,12 +2,11 @@
2
  export interface Props { tableOfContentAutoCollapse?: boolean }
3
  const { tableOfContentAutoCollapse = false } = Astro.props as Props;
4
  ---
5
- <aside class="toc" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
6
  <div class="title">Table of Contents</div>
7
  <div id="article-toc-placeholder"></div>
8
-
9
  </aside>
10
- <details class="toc-mobile">
11
  <summary>Table of Contents</summary>
12
  <div id="article-toc-mobile-placeholder"></div>
13
  </details>
@@ -98,7 +97,7 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
98
  ...(holder ? holder.querySelectorAll('a') : []),
99
  ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
100
  ];
101
- const autoCollapse = (document.querySelector('aside.toc')?.getAttribute('data-auto-collapse') === '1');
102
 
103
  // Inject styles for collapsible & animation
104
  const ensureStyles = () => {
@@ -106,10 +105,10 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
106
  const style = document.createElement('style');
107
  style.id = 'toc-collapse-style';
108
  style.textContent = `
109
- aside.toc nav.toc-collapsible > ul > li > ul,
110
- details.toc-mobile nav.toc-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
111
- aside.toc nav.toc-collapsible > ul > li.collapsed > ul,
112
- details.toc-mobile nav.toc-collapsible > ul > li.collapsed > ul { display: block; }
113
  `;
114
  document.head.appendChild(style);
115
  };
@@ -125,8 +124,8 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
125
  const setNavCollapsible = () => {
126
  const sideNav = holder ? holder.querySelector('nav') : null;
127
  const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
128
- if (sideNav) sideNav.classList.add('toc-collapsible');
129
- if (mobileNav) mobileNav.classList.add('toc-collapsible');
130
  };
131
 
132
  const measure = (el) => {
@@ -205,6 +204,9 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
205
  if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
206
  };
207
 
 
 
 
208
  window.addEventListener('scroll', onScroll);
209
  // Initialize state
210
  onScroll();
@@ -227,4 +229,128 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
227
  } else { buildTOC(); }
228
  </script>
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  export interface Props { tableOfContentAutoCollapse?: boolean }
3
  const { tableOfContentAutoCollapse = false } = Astro.props as Props;
4
  ---
5
+ <aside class="table-of-contents" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
6
  <div class="title">Table of Contents</div>
7
  <div id="article-toc-placeholder"></div>
 
8
  </aside>
9
+ <details class="table-of-contents-mobile">
10
  <summary>Table of Contents</summary>
11
  <div id="article-toc-mobile-placeholder"></div>
12
  </details>
 
97
  ...(holder ? holder.querySelectorAll('a') : []),
98
  ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
99
  ];
100
+ const autoCollapse = (document.querySelector('aside.table-of-contents')?.getAttribute('data-auto-collapse') === '1');
101
 
102
  // Inject styles for collapsible & animation
103
  const ensureStyles = () => {
 
105
  const style = document.createElement('style');
106
  style.id = 'toc-collapse-style';
107
  style.textContent = `
108
+ aside.table-of-contents nav.table-of-contents-collapsible > ul > li > ul,
109
+ details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
110
+ aside.table-of-contents nav.table-of-contents-collapsible > ul > li.collapsed > ul,
111
+ details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li.collapsed > ul { display: block; }
112
  `;
113
  document.head.appendChild(style);
114
  };
 
124
  const setNavCollapsible = () => {
125
  const sideNav = holder ? holder.querySelector('nav') : null;
126
  const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
127
+ if (sideNav) sideNav.classList.add('table-of-contents-collapsible');
128
+ if (mobileNav) mobileNav.classList.add('table-of-contents-collapsible');
129
  };
130
 
131
  const measure = (el) => {
 
204
  if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
205
  };
206
 
207
+ // If auto-collapse, collapse immediately (expand first section) before any scroll
208
+ if (autoCollapse) setCollapsedState(0);
209
+
210
  window.addEventListener('scroll', onScroll);
211
  // Initialize state
212
  onScroll();
 
229
  } else { buildTOC(); }
230
  </script>
231
 
232
+ <style is:global>
233
+ /* Sticky aside */
234
+ .table-of-contents {
235
+ position: sticky;
236
+ top: 32px;
237
+ }
238
+
239
+ .table-of-contents nav {
240
+ border-left: 1px solid var(--border-color);
241
+ padding-left: 16px;
242
+ font-size: 13px;
243
+ }
244
+
245
+ .table-of-contents .title {
246
+ font-weight: 600;
247
+ font-size: 14px;
248
+ margin-bottom: 8px;
249
+ }
250
+
251
+ /* Look & feel */
252
+ .table-of-contents nav ul {
253
+ margin: 0 0 6px;
254
+ padding-left: 1em;
255
+ }
256
+
257
+ .table-of-contents nav li {
258
+ list-style: none;
259
+ margin: .25em 0;
260
+ }
261
+
262
+ .table-of-contents nav a,
263
+ .table-of-contents nav a:link,
264
+ .table-of-contents nav a:visited {
265
+ color: var(--text-color);
266
+ text-decoration: none;
267
+ border-bottom: none;
268
+ }
269
+
270
+ .table-of-contents nav > ul > li > a {
271
+ font-weight: 700;
272
+ }
273
+
274
+ .table-of-contents nav a:hover {
275
+ text-decoration: underline solid var(--muted-color);
276
+ }
277
+
278
+ .table-of-contents nav a.active {
279
+ text-decoration: underline;
280
+ }
281
+
282
+ /* Mobile accordion */
283
+ .table-of-contents-mobile {
284
+ display: none;
285
+ margin: 8px 0 16px;
286
+ }
287
+
288
+ .table-of-contents-mobile > summary {
289
+ cursor: pointer;
290
+ list-style: none;
291
+ padding: 8px 12px;
292
+ border: 1px solid var(--border-color);
293
+ border-radius: 8px;
294
+ color: var(--text-color);
295
+ font-weight: 600;
296
+ }
297
+
298
+ .table-of-contents-mobile[open] > summary {
299
+ border-bottom-left-radius: 0;
300
+ border-bottom-right-radius: 0;
301
+ }
302
 
303
+ .table-of-contents-mobile nav {
304
+ border-left: none;
305
+ padding: 10px 12px;
306
+ font-size: 14px;
307
+ border: 1px solid var(--border-color);
308
+ border-top: none;
309
+ border-bottom-left-radius: 8px;
310
+ border-bottom-right-radius: 8px;
311
+ }
312
+
313
+ .table-of-contents-mobile nav ul {
314
+ margin: 0 0 6px;
315
+ padding-left: 1em;
316
+ }
317
+
318
+ .table-of-contents-mobile nav li {
319
+ list-style: none;
320
+ margin: .25em 0;
321
+ }
322
+
323
+ .table-of-contents-mobile nav a,
324
+ .table-of-contents-mobile nav a:link,
325
+ .table-of-contents-mobile nav a:visited {
326
+ color: var(--text-color);
327
+ text-decoration: none;
328
+ border-bottom: none;
329
+ }
330
+
331
+ .table-of-contents-mobile nav > ul > li > a {
332
+ font-weight: 700;
333
+ }
334
+
335
+ .table-of-contents-mobile nav a:hover {
336
+ text-decoration: underline solid var(--muted-color);
337
+ }
338
+
339
+ .table-of-contents-mobile nav a.active {
340
+ text-decoration: underline;
341
+ }
342
+
343
+ [data-theme="dark"] .table-of-contents nav { border-left-color: rgba(255,255,255,.15); }
344
+
345
+ /* Responsive: hide sticky TOC, show mobile */
346
+ @media (max-width: var(--bp-content-collapse)) {
347
+ .table-of-contents {
348
+ position: static;
349
+ display: none;
350
+ }
351
+
352
+ .table-of-contents-mobile {
353
+ display: block;
354
+ }
355
+ }
356
+ </style>
app/src/components/ThemeToggle.astro CHANGED
@@ -44,3 +44,10 @@
44
  </button>
45
 
46
 
 
 
 
 
 
 
 
 
44
  </button>
45
 
46
 
47
+ <style>
48
+ #theme-toggle { display: inline-flex; align-items: center; gap: 8px; border: none; background: transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; margin: 12px 16px; color: var(--text-color) !important; }
49
+ #theme-toggle .icon.dark { display: none; }
50
+ [data-theme="dark"] #theme-toggle .icon.light { display: none; }
51
+ [data-theme="dark"] #theme-toggle .icon.dark { display: inline; }
52
+ #theme-toggle .icon { filter: none !important; }
53
+ </style>
app/src/content/article.mdx CHANGED
@@ -4,20 +4,37 @@ title: "Bringing paper to life:\n A modern template for\n scientific writing
4
  subtitle: "A modern, MDX-first research article template with math, citations and interactive figures."
5
  description: "A modern, MDX-first research article template with math, citations and interactive figures."
6
  authors:
7
- - "Thibaud Frere"
8
- affiliation: "Hugging Face"
 
 
 
 
 
 
 
 
 
 
9
  published: "Sep. 01, 2025"
 
 
 
 
10
  tags:
11
  - research
12
  - template
13
- tocAutoCollapse: true
14
  ---
15
 
16
  import Introduction from "./chapters/introduction.mdx";
17
  import BestPractices from "./chapters/best-pratices.mdx";
18
  import WritingYourContent from "./chapters/writing-your-content.mdx";
19
- import AvailableBlocks from "./chapters/available-blocks.mdx";
20
  import GettingStarted from "./chapters/getting-started.mdx";
 
 
 
21
 
22
  <Introduction />
23
 
@@ -25,7 +42,10 @@ import GettingStarted from "./chapters/getting-started.mdx";
25
 
26
  <WritingYourContent />
27
 
28
- <AvailableBlocks />
 
 
29
 
30
  <BestPractices />
31
 
 
 
4
  subtitle: "A modern, MDX-first research article template with math, citations and interactive figures."
5
  description: "A modern, MDX-first research article template with math, citations and interactive figures."
6
  authors:
7
+ - name: "Thibaud Frere"
8
+ url: "https://huggingface.co/tfrere"
9
+ affiliations: [1]
10
+ - name: "Alice Example"
11
+ url: "https://example.com/~alice"
12
+ affiliations: [1, 2]
13
+ - name: "Bob Researcher"
14
+ affiliations:
15
+ - name: "Hugging Face"
16
+ url: "https://huggingface.co"
17
+ - name: "Example University"
18
+ url: "https://example.edu"
19
  published: "Sep. 01, 2025"
20
+ doi: 10.1234/abcd.efgh
21
+ licence: >
22
+ Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">Hugging Face</a>, unless noted otherwise.
23
+ Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
24
  tags:
25
  - research
26
  - template
27
+ tableOfContentsAutoCollapse: true
28
  ---
29
 
30
  import Introduction from "./chapters/introduction.mdx";
31
  import BestPractices from "./chapters/best-pratices.mdx";
32
  import WritingYourContent from "./chapters/writing-your-content.mdx";
33
+ import AvailableBlocks from "./chapters/markdown.mdx";
34
  import GettingStarted from "./chapters/getting-started.mdx";
35
+ import Markdown from "./chapters/markdown.mdx";
36
+ import Components from "./chapters/components.mdx";
37
+ import DebugComponents from "./chapters/debug-components.mdx";
38
 
39
  <Introduction />
40
 
 
42
 
43
  <WritingYourContent />
44
 
45
+ <Markdown />
46
+
47
+ <Components />
48
 
49
  <BestPractices />
50
 
51
+ <DebugComponents />
app/src/content/bibliography.bib CHANGED
@@ -6,18 +6,6 @@
6
  year={2017}
7
  }
8
 
9
- @article{example2023,
10
- title={Example Paper Title},
11
- author={Example, A. and Another, A.},
12
- journal={Journal of Examples},
13
- volume={1},
14
- number={1},
15
- pages={1--10},
16
- year={2023},
17
- publisher={Example Publisher}
18
- }
19
-
20
-
21
  @book{mckinney2017python,
22
  title={Python for Data Analysis},
23
  author={McKinney, Wes},
 
6
  year={2017}
7
  }
8
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  @book{mckinney2017python,
10
  title={Python for Data Analysis},
11
  author={McKinney, Wes},
app/src/content/chapters/{available-blocks.mdx → components.mdx} RENAMED
@@ -9,102 +9,21 @@ import FullWidth from '../../components/FullWidth.astro';
9
  import Accordion from '../../components/Accordion.astro';
10
  import ResponsiveImage from '../../components/ResponsiveImage.astro';
11
 
12
- ## Available 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
 
16
  <br/>
17
  <div className="button-group">
18
- <a className="button" href="#math">Math</a>
19
- <a className="button" href="#code-blocks">Code</a>
20
- <a className="button" href="#citations-and-notes">Citations & notes</a>
21
  <a className="button" href="#responsiveimage">ResponsiveImage</a>
22
- <a className="button" href="#mermaid-diagrams">Mermaid</a>
23
  <a className="button" href="#placement">Placement</a>
24
  <a className="button" href="#accordion">Accordion</a>
25
  <a className="button" href="#note">Note</a>
26
- <a className="button" href="#minimal-table">Minimal table</a>
27
- <a className="button" href="#audio">Audio</a>
28
- <a className="button" href="#htmlembeds">HtmlEmbeds</a>
29
  </div>
30
 
31
- ### Math
32
-
33
- KaTeX is used for math rendering.
34
-
35
- **Inline**
36
-
37
- This is an inline math equation: $x^2 + y^2 = z^2$.
38
-
39
-
40
- <small className="muted">Example</small>
41
- ```mdx
42
- $x^2 + y^2 = z^2$
43
- ```
44
-
45
- **Block**
46
-
47
- $$
48
- \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
49
- $$
50
-
51
- <small className="muted">Example</small>
52
- ```mdx
53
- $$
54
- \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
55
- $$
56
- ```
57
-
58
- ### Code
59
-
60
- Use fenced code blocks with a language for syntax highlighting.
61
-
62
- ```python
63
- def greet(name: str) -> None:
64
- print(f"Hello, {name}!")
65
-
66
- greet("Astro")
67
- ```
68
-
69
- <small className="muted">Example</small>
70
- ````mdx
71
- ```python
72
- def greet(name: str) -> None:
73
- print(f"Hello, {name}!")
74
-
75
- greet("Astro")
76
- ```
77
- ````
78
-
79
-
80
- ### Citation and footnote
81
-
82
- **Citations** use the `@` syntax (e.g., `[@vaswani2017attention]` or `@vaswani2017attention` in narrative form) and are **automatically** collected to render the **bibliography** at the end of the article. The citation keys come from `app/src/content/bibliography.bib`.
83
-
84
- **Footnotes** use an identifier like `[^f1]` and a definition anywhere in the document, e.g., `[^f1]: Your explanation`. They are **numbered** and **listed automatically** at the end of the article.
85
-
86
- Here are a few variations using the same bibliography:
87
-
88
- 1) **In-text citation** with brackets: [@example2023].
89
-
90
- 2) **Narrative citation**: As shown by @vaswani2017attention, transformers enable efficient sequence modeling.
91
-
92
- 3) **Multiple citations** and a **footnote** together: see [@vaswani2017attention; @example2023] for related work. Also note this footnote[^f1].
93
-
94
- [^f1]: Footnote attached to the sentence above.
95
-
96
- <small className="muted">Example</small>
97
- ```mdx
98
- 1) In-text citation with brackets: [@example2023].
99
-
100
- 2) Narrative citation: As shown by @vaswani2017attention, transformers enable efficient sequence modeling.
101
-
102
- 3) Multiple citations and a footnote together: see [@vaswani2017attention; @example2023] for related work. Also note this footnote[^f1].
103
-
104
- [^f1]: Footnote attached to the sentence above.
105
- ```
106
-
107
-
108
  ### ResponsiveImage
109
 
110
  **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**.
@@ -145,95 +64,58 @@ import myImage from './assets/images/placeholder.jpg'
145
  ```
146
 
147
 
 
148
 
149
- ### Mermaid diagram
150
-
151
- Native mermaid diagrams are supported. You can use the <a target="_blank" href="https://mermaid.live/edit#pako:eNpVjUFPg0AQhf_KZk6a0AYsCywHE0u1lyZ66EnoYQMDSyy7ZFlSK_DfXWiMOqd58773ZoBcFQgxlGd1yQXXhhx3mSR2ntJE6LozDe9OZLV6HPdoSKMkXkeyvdsr0gnVtrWs7m_8doZIMhxmDIkRtfyYblay5F8ljmSXHnhrVHv66xwvaiTPaf0mbP1_R2i0qZe05HHJVznXJOF6QcCBStcFxEb36ECDuuGzhGF2MzACG8wgtmuBJe_PJoNMTjbWcvmuVPOT1KqvBNj6c2dV3xbc4K7mlea_CMoCdaJ6aSCm3lIB8QCfED94dM2o77ssjFzK3MiBq2WCNWUeiza-H26YvU8OfC0_3XVII9eLQuYFIaVBGEzfyTJ22g"> live editor</a> to create your diagram and copy the code to your article.
152
 
 
153
 
154
- ```mermaid
155
- erDiagram
156
- DATASET ||--o{ SAMPLE : contains
157
- RUN }o--o{ SAMPLE : uses
158
- RUN ||--|| MODEL : trains
159
- RUN ||--o{ METRIC : logs
160
 
161
- DATASET {
162
- string id
163
- string name
164
- }
165
 
166
- SAMPLE {
167
- string id
168
- string uri
169
- }
 
170
 
171
- MODEL {
172
- string id
173
- string framework
174
- }
175
 
176
- RUN {
177
- string id
178
- date startedAt
179
- }
180
 
181
- METRIC {
182
- string name
183
- float value
184
- }
185
- ```
186
 
187
  <small className="muted">Example</small>
188
- ````mdx
189
- ```mermaid
190
- erDiagram
191
- DATASET ||--o{ SAMPLE : contains
192
- RUN }o--o{ SAMPLE : uses
193
- RUN ||--|| MODEL : trains
194
- RUN ||--o{ METRIC : logs
195
-
196
- DATASET {
197
- string id
198
- string name
199
- }
200
-
201
- SAMPLE {
202
- string id
203
- string uri
204
- }
205
-
206
- MODEL {
207
- string id
208
- string framework
209
- }
210
-
211
- RUN {
212
- string id
213
- date startedAt
214
- }
215
-
216
- METRIC {
217
- string name
218
- float value
219
- }
220
- ```
221
- ````
222
-
223
 
224
- ### Separator
 
 
 
225
 
226
- Use `---` on its own line to insert a horizontal separator between sections. This is a standard Markdown “thematic break”. Don’t confuse it with the `---` used at the very top of the file to delimit the frontmatter.
227
 
228
- ---
 
 
229
 
230
  <small className="muted">Example</small>
231
  ```mdx
232
- Intro paragraph.
233
 
234
- ---
235
-
236
- Next section begins here.
237
  ```
238
 
239
 
@@ -287,7 +169,8 @@ Small contextual callout for tips, caveats, or emphasis.
287
  | `title` | No | string | Short title displayed in header |
288
  | `emoji` | No | string | Emoji displayed before the title |
289
  | `class` | No | string | Extra classes for custom styling |
290
- | `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note |
 
291
 
292
  <Note title="Heads‑up" emoji="💡" variant="info">
293
  Use notes to surface context without breaking reading flow.
@@ -327,35 +210,6 @@ import Note from '../../components/Note.astro'
327
  ```
328
 
329
 
330
- ### Minimal table
331
-
332
- | Method | Score |
333
- |---|---|
334
- | A | 0.78 |
335
- | B | 0.86 |
336
-
337
- <small className="muted">Example</small>
338
- ```mdx
339
- | Method | Score |
340
- | --- | --- |
341
- | A | 0.78 |
342
- | B | 0.86 |
343
- ```
344
-
345
- ### Audio
346
-
347
- <audio controls src={audioDemo}/>
348
- <br/>
349
- <small className="muted">Example</small>
350
- ```mdx
351
- import audioDemo from './assets/audio/audio-example.wav'
352
-
353
- <audio controls src={audioDemo}/>
354
- ```
355
-
356
-
357
-
358
-
359
  ### HtmlEmbed
360
 
361
  The main purpose of the ```HtmlEmbed``` component is to **embed** a **Plotly** or **D3.js** chart in your article. **Libraries** are already imported in the template.
 
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
  <a className="button" href="#responsiveimage">ResponsiveImage</a>
 
20
  <a className="button" href="#placement">Placement</a>
21
  <a className="button" href="#accordion">Accordion</a>
22
  <a className="button" href="#note">Note</a>
23
+ <a className="button" href="#htmlembed">HtmlEmbed</a>
24
+ <a className="button" href="#iframe">Iframe</a>
 
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**.
 
64
  ```
65
 
66
 
67
+ ### Placement
68
 
69
+ Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
 
 
70
 
71
+ #### Sidenotes
72
 
73
+ <Sidenote>
74
+ This paragraph presents a **key idea** concisely.
75
+ <Fragment slot="aside">
76
+ **Side note** for brief context or a definition.
77
+ </Fragment>
78
+ </Sidenote>
79
 
80
+ <small className="muted">Example</small>
81
+ ```mdx
82
+ import Sidenote from '../components/Sidenote.astro'
 
83
 
84
+ <Sidenote>
85
+ Main paragraph with the core idea.
86
+ <Fragment slot="aside">Short side note.</Fragment>
87
+ </Sidenote>
88
+ ```
89
 
 
 
 
 
90
 
91
+ #### Wide example
 
 
 
92
 
93
+ <Wide>
94
+ <div className="demo-wide">demo wide</div>
95
+ </Wide>
 
 
96
 
97
  <small className="muted">Example</small>
98
+ ```mdx
99
+ import Wide from '../components/Wide.astro'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
+ <Wide>
102
+ Your content here...
103
+ </Wide>
104
+ ```
105
 
106
+ #### Full-width example
107
 
108
+ <FullWidth>
109
+ <div className="demo-full-width">demo full-width</div>
110
+ </FullWidth>
111
 
112
  <small className="muted">Example</small>
113
  ```mdx
114
+ import FullWidth from '../components/FullWidth.astro'
115
 
116
+ <FullWidth>
117
+ Your content here...
118
+ </FullWidth>
119
  ```
120
 
121
 
 
169
  | `title` | No | string | Short title displayed in header |
170
  | `emoji` | No | string | Emoji displayed before the title |
171
  | `class` | No | string | Extra classes for custom styling |
172
+ | `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note |
173
+
174
 
175
  <Note title="Heads‑up" emoji="💡" variant="info">
176
  Use notes to surface context without breaking reading flow.
 
210
  ```
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  ### HtmlEmbed
214
 
215
  The main purpose of the ```HtmlEmbed``` component is to **embed** a **Plotly** or **D3.js** chart in your article. **Libraries** are already imported in the template.
app/src/content/chapters/debug-components.mdx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ |------------------------|----------|
10
+ | `zoomable` | No |
11
+ | `downloadable` | No |
12
+ | `loading="lazy"` | No |
13
+ | `caption` | No |
14
+
15
+ <Accordion title="table">
16
+ | Prop | Required | Description
17
+ |-------------|----------|----------------------------------------------------------------------------------
18
+ | `src` | Yes | Path to the embed file in the `embeds` folder.
19
+ | `title` | No | Short title displayed above the card.
20
+ | `desc` | No | Short description displayed below the card. Supports inline HTML (e.g., links).
21
+ | `frameless` | No | Removes the card background and border for seamless embeds.
22
+ | `align` | No | Aligns the title/description text. One of `left` (default), `center`, `right`.
23
+ </Accordion>
24
+
25
+
26
+ <Accordion title="simple">
27
+ <p>Simple example</p>
28
+ </Accordion>
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" />
36
+ ```
37
+ </Accordion>
app/src/content/chapters/getting-started.mdx CHANGED
@@ -47,6 +47,8 @@ npm install
47
 
48
  <br/>And that's it!
49
 
 
 
50
  ### Development
51
 
52
  ```bash
 
47
 
48
  <br/>And that's it!
49
 
50
+ **You're ready to go!** 🎉
51
+
52
  ### Development
53
 
54
  ```bash
app/src/content/chapters/introduction.mdx CHANGED
@@ -13,24 +13,24 @@ import Sidenote from "../../components/Sidenote.astro";
13
 
14
  <Sidenote>
15
  <div className="tag-list">
16
- <span className="tag">Markdown based</span>
17
  <span className="tag">KaTeX math</span>
18
  <span className="tag">Syntax highlighting</span>
19
  <span className="tag">Citations & footnotes</span>
20
- <span className="tag">Automatic build</span>
21
  <span className="tag">Table of contents</span>
22
- <span className="tag">Dark theme</span>
23
- <span className="tag">HTML Embeds</span>
24
  <span className="tag">Plotly ready</span>
25
  <span className="tag">D3.js ready</span>
26
- <span className="tag">SEO Friendly</span>
27
- <span className="tag">Mermaid diagrams</span>
28
- <span className="tag">Lightweight bundle</span>
29
- <span className="tag">Mobile friendly</span>
30
  <span className="tag">Optimized images</span>
 
 
 
31
  <span className="tag">Automatic PDF export</span>
32
- <span className="tag">Dataviz color palettes</span>
33
- <span className="tag">Embed gradio apps</span>
34
  </div>
35
  <Fragment slot="aside">
36
  If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
@@ -42,9 +42,11 @@ The web enables what static PDFs can’t: **interactive diagrams**, **progressiv
42
 
43
  If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
44
 
45
- This project draws strong inspiration from [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship and set a high standard for interactive scientific communication.
46
 
47
  <Sidenote>
 
 
48
  To give you a sense of what inspired this template, here is a short, curated list of well‑designed and often interactive works from Distill:
49
 
50
  - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
 
13
 
14
  <Sidenote>
15
  <div className="tag-list">
16
+ <span className="tag">Markdown-based</span>
17
  <span className="tag">KaTeX math</span>
18
  <span className="tag">Syntax highlighting</span>
19
  <span className="tag">Citations & footnotes</span>
 
20
  <span className="tag">Table of contents</span>
21
+ <span className="tag">Mermaid diagrams</span>
 
22
  <span className="tag">Plotly ready</span>
23
  <span className="tag">D3.js ready</span>
24
+ <span className="tag">HTML embeds</span>
25
+ <span className="tag">Gradio app embeds</span>
26
+ <span className="tag">Dataviz color palettes</span>
 
27
  <span className="tag">Optimized images</span>
28
+ <span className="tag">Lightweight bundle</span>
29
+ <span className="tag">SEO-friendly</span>
30
+ <span className="tag">Automatic build</span>
31
  <span className="tag">Automatic PDF export</span>
32
+ <span className="tag">Dark theme</span>
33
+ <span className="tag">Mobile friendly</span>
34
  </div>
35
  <Fragment slot="aside">
36
  If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
 
42
 
43
  If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
44
 
45
+ ### Inspired by Distill
46
 
47
  <Sidenote>
48
+ This project draws strong inspiration from [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship and set a high standard for interactive scientific communication.
49
+
50
  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:
51
 
52
  - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
app/src/content/chapters/markdown.mdx ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Image } from 'astro:assets';
2
+ import placeholder from '../assets/images/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
+ ## Markdown
13
+
14
+ 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/).
15
+
16
+ <br/>
17
+ <div className="button-group">
18
+ <a className="button" href="#math">Math</a>
19
+ <a className="button" href="#code-blocks">Code</a>
20
+ <a className="button" href="#citations-and-notes">Citations</a>
21
+ <a className="button" href="#footnotes">Footnotes</a>
22
+ <a className="button" href="#mermaid-diagrams">Mermaid</a>
23
+ <a className="button" href="#separator">Separator</a>
24
+ <a className="button" href="#table">Table</a>
25
+ <a className="button" href="#audio">Audio</a>
26
+ </div>
27
+
28
+ ### Math
29
+
30
+ KaTeX is used for math rendering.
31
+
32
+ **Inline**
33
+
34
+ This is an inline math equation: $x^2 + y^2 = z^2$.
35
+
36
+
37
+ <small className="muted">Example</small>
38
+ ```mdx
39
+ $x^2 + y^2 = z^2$
40
+ ```
41
+
42
+ **Block**
43
+
44
+ $$
45
+ \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
46
+ $$
47
+
48
+ <small className="muted">Example</small>
49
+ ```mdx
50
+ $$
51
+ \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
52
+ $$
53
+ ```
54
+
55
+ ### Code
56
+
57
+ Use fenced code blocks with a language for syntax highlighting.
58
+
59
+ ```python
60
+ def greet(name: str) -> None:
61
+ print(f"Hello, {name}!")
62
+
63
+ greet("Astro")
64
+ ```
65
+
66
+ <small className="muted">Example</small>
67
+ ````mdx
68
+ ```python
69
+ def greet(name: str) -> None:
70
+ print(f"Hello, {name}!")
71
+
72
+ greet("Astro")
73
+ ```
74
+ ````
75
+
76
+ ### Citations
77
+
78
+ **Citations** use the `@` syntax (e.g., `[@vaswani2017attention]` or `@vaswani2017attention` in narrative form) and are **automatically** collected to render the **bibliography** at the end of the article. The citation keys come from `app/src/content/bibliography.bib`.
79
+
80
+ 1) In-text citation with brackets: [@vaswani2017attention].
81
+
82
+ 2) Narrative citation: As shown by @kingma2015adam, stochastic optimization is widely used.
83
+
84
+ 3) Multiple citations and a footnote together: see [@mckinney2017python; @he2016resnet] for related work.
85
+
86
+ 4) All citations in one group: [@vaswani2017attention; @mckinney2017python; @he2016resnet; @silver2017mastering; @openai2023gpt4; @doe2020thesis; @cover2006entropy; @zenodo2021dataset; @sklearn2024; @smith2024privacy; @kingma2015adam; @raffel2020t5].
87
+
88
+ <small className="muted">Example</small>
89
+ ```mdx
90
+ 1) In-text citation with brackets: [@vaswani2017attention].
91
+
92
+ 2) Narrative citation: As shown by @kingma2015adam, stochastic optimization is widely used.
93
+
94
+ 3) Multiple citations and a footnote together: see [@mckinney2017python; @he2016resnet] for related work.
95
+
96
+ 4) All citations in one group: [@vaswani2017attention; @mckinney2017python; @he2016resnet; @silver2017mastering; @openai2023gpt4; @doe2020thesis; @cover2006entropy; @zenodo2021dataset; @sklearn2024; @smith2024privacy; @kingma2015adam; @raffel2020t5].
97
+ ```
98
+
99
+ ### Footnotes
100
+
101
+ **Footnotes** use an identifier like `[^f1]` and a definition anywhere in the document, e.g., `[^f1]: Your explanation`. They are **numbered** and **listed automatically** at the end of the article.
102
+
103
+ 1) Footnote attached to the sentence above[^f1].
104
+
105
+ [^f1]: Footnote attached to the sentence above.
106
+
107
+ 2) Multi-paragraph footnote example[^f2].
108
+
109
+ [^f2]: Multi-paragraph footnote. First paragraph.
110
+
111
+ Second paragraph with a link to [Astro](https://astro.build).
112
+
113
+ 2) Footnote containing a list[^f3].
114
+
115
+ [^f3]: Footnote with a list:
116
+
117
+ - First item
118
+ - Second item
119
+
120
+ 3) Footnote with an inline code and an indented code block[^f4].
121
+
122
+ [^f4]: Footnote with code snippet:
123
+
124
+ ```ts
125
+ function add(a: number, b: number) {
126
+ return a + b;
127
+ }
128
+ ```
129
+ Result: `add(2, 3) === 5`.
130
+
131
+ 4) Footnote that includes citations inside[^f5] and another footnote[^f1].
132
+
133
+ [^f5]: Footnote containing citations [@vaswani2017attention] and [@kingma2015adam].
134
+
135
+ <small className="muted">Example</small>
136
+ ```mdx
137
+ 1) Footnote attached to the sentence above[^f1].
138
+
139
+ 2) Multi-paragraph footnote example[^f2].
140
+
141
+ 2) Footnote containing a list[^f3].
142
+
143
+ 3) Footnote with an inline code and an indented code block[^f4].
144
+
145
+ 4) Footnote that includes citations inside[^f5].
146
+
147
+ [^f1]: Footnote attached to the sentence above.
148
+
149
+ [^f2]: Multi-paragraph footnote. First paragraph.
150
+
151
+ Second paragraph with a link to [Astro](https://astro.build).
152
+
153
+ [^f3]: Footnote with a list:
154
+
155
+ - First item
156
+ - Second item
157
+
158
+ [^f4]: Footnote with code snippet:
159
+
160
+ function add(a: number, b: number) {
161
+ return a + b;
162
+ }
163
+
164
+ Result: `add(2, 3) === 5`.
165
+
166
+ [^f5]: Footnote containing citations [@vaswani2017attention] and [@kingma2015adam].
167
+ ```
168
+
169
+
170
+ ### Mermaid diagram
171
+
172
+ Native mermaid diagrams are supported. You can use the <a target="_blank" href="https://mermaid.live/edit#pako:eNpVjUFPg0AQhf_KZk6a0AYsCywHE0u1lyZ66EnoYQMDSyy7ZFlSK_DfXWiMOqd58773ZoBcFQgxlGd1yQXXhhx3mSR2ntJE6LozDe9OZLV6HPdoSKMkXkeyvdsr0gnVtrWs7m_8doZIMhxmDIkRtfyYblay5F8ljmSXHnhrVHv66xwvaiTPaf0mbP1_R2i0qZe05HHJVznXJOF6QcCBStcFxEb36ECDuuGzhGF2MzACG8wgtmuBJe_PJoNMTjbWcvmuVPOT1KqvBNj6c2dV3xbc4K7mlea_CMoCdaJ6aSCm3lIB8QCfED94dM2o77ssjFzK3MiBq2WCNWUeiza-H26YvU8OfC0_3XVII9eLQuYFIaVBGEzfyTJ22g"> live editor</a> to create your diagram and copy the code to your article.
173
+
174
+
175
+ ```mermaid
176
+ erDiagram
177
+ DATASET ||--o{ SAMPLE : contains
178
+ RUN }o--o{ SAMPLE : uses
179
+ RUN ||--|| MODEL : trains
180
+ RUN ||--o{ METRIC : logs
181
+
182
+ DATASET {
183
+ string id
184
+ string name
185
+ }
186
+
187
+ SAMPLE {
188
+ string id
189
+ string uri
190
+ }
191
+
192
+ MODEL {
193
+ string id
194
+ string framework
195
+ }
196
+
197
+ RUN {
198
+ string id
199
+ date startedAt
200
+ }
201
+
202
+ METRIC {
203
+ string name
204
+ float value
205
+ }
206
+ ```
207
+
208
+ <small className="muted">Example</small>
209
+ ````mdx
210
+ ```mermaid
211
+ erDiagram
212
+ DATASET ||--o{ SAMPLE : contains
213
+ RUN }o--o{ SAMPLE : uses
214
+ RUN ||--|| MODEL : trains
215
+ RUN ||--o{ METRIC : logs
216
+
217
+ DATASET {
218
+ string id
219
+ string name
220
+ }
221
+
222
+ SAMPLE {
223
+ string id
224
+ string uri
225
+ }
226
+
227
+ MODEL {
228
+ string id
229
+ string framework
230
+ }
231
+
232
+ RUN {
233
+ string id
234
+ date startedAt
235
+ }
236
+
237
+ METRIC {
238
+ string name
239
+ float value
240
+ }
241
+ ```
242
+ ````
243
+
244
+
245
+ ### Separator
246
+
247
+ Use `---` on its own line to insert a horizontal separator between sections. This is a standard Markdown “thematic break”. Don’t confuse it with the `---` used at the very top of the file to delimit the frontmatter.
248
+
249
+ ---
250
+
251
+ <small className="muted">Example</small>
252
+ ```mdx
253
+ Intro paragraph.
254
+
255
+ ---
256
+
257
+ Next section begins here.
258
+ ```
259
+
260
+ ### Table
261
+
262
+ | Method | Score |
263
+ |---|---|
264
+ | A | 0.78 |
265
+ | B | 0.86 |
266
+
267
+ <small className="muted">Example</small>
268
+ ```mdx
269
+ | Method | Score |
270
+ | --- | --- |
271
+ | A | 0.78 |
272
+ | B | 0.86 |
273
+ ```
274
+
275
+ ### Audio
276
+
277
+ <audio controls src={audioDemo}/>
278
+ <br/>
279
+ <small className="muted">Example</small>
280
+ ```mdx
281
+ import audioDemo from './assets/audio/audio-example.wav'
282
+
283
+ <audio controls src={audioDemo}/>
284
+ ```
285
+
app/src/content/chapters/writing-your-content.mdx CHANGED
@@ -8,7 +8,7 @@ import FullWidth from '../../components/FullWidth.astro';
8
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
9
  import audioDemo from '../assets/audio/audio-example.wav';
10
 
11
- ## Writing Your Content
12
 
13
  ### **Introduction**
14
 
@@ -21,62 +21,59 @@ Everything is **self-contained** under `app/src/content/`:
21
 
22
  The `article.mdx` file is the **main entry point** of your article.
23
 
 
 
 
 
24
  <small className="muted">app/src/content/article.mdx</small>
25
  ```mdx
26
- {/* HEADER */}
27
  ---
 
28
  title: "This is the main title"
29
  subtitle: "This will be displayed just below the banner"
30
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
31
- authors:
32
- - "Thibaud Frere"
33
- - "Alice Martin"
34
- - "Robert Brown"
35
- affiliation: "Hugging Face"
36
  published: "Feb 19, 2025"
37
  tags:
38
  - research
39
  - template
40
- {/* Optional override of the default Open Graph image */}
41
- ogImage: "https://override-example.com/your-og-image.png"
42
- {/* By default the table of contents is not collapsing */}
43
- tocAutoCollapse: true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ---
45
 
46
- {/* IMPORTS */}
47
- import { Image } from 'astro:assets';
48
- import placeholder from './assets/images/placeholder.jpg';
49
-
50
- {/* CONTENT */}
51
- # Hello, world
52
-
53
- This is a short paragraph written in Markdown. Below is an example image:
54
-
55
- <Image src={placeholder} alt="Example image" />
56
- ```
57
-
58
- ### MDX
59
-
60
- **MDX** is a mix of **Markdown** and **HTML/JSX**: write regular Markdown, and embed **interactive components** inline when needed. We’ll describe the available components you can use later in this guide.
61
-
62
- For Markdown syntax, see the complete [**Markdown documentation**](https://www.markdownguide.org/basic-syntax/).
63
-
64
- <small className="muted">Example</small>
65
- ```mdx
66
- {/* IMPORTS */}
67
- import { Image } from 'astro:assets'
68
- import placeholder from './assets/images/placeholder.png'
69
  import Sidenote from '../components/Sidenote.astro'
70
 
71
- # Mixing Markdown and components
72
-
73
- This paragraph is written in Markdown.
74
-
75
- <Sidenote>A short callout inserted via a component.</Sidenote>
76
-
77
- Below is an image imported via Astro and optimized at build time:
78
 
79
- <Image src={placeholder} alt="Sample image with Astro optimization" />
80
  ```
81
 
82
 
@@ -102,6 +99,8 @@ import MyChapter from './chapters/my-chapter.mdx';
102
 
103
  The **Table of contents** is generated **automatically** from your **H2–H4** headings. Keep headings **short** and **descriptive**; links work on **desktop** and **mobile**.
104
 
 
 
105
  ### Theme
106
 
107
  All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose.
@@ -167,57 +166,3 @@ document.documentElement.style.setProperty('--palette-count', '8');
167
  document.documentElement.style.setProperty('--palette-diverging-count', '7');
168
  window.ColorPalettes.refresh();
169
  ```
170
-
171
- ### Placement
172
-
173
- Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
174
-
175
- #### Sidenotes
176
-
177
- <Sidenote>
178
- This paragraph presents a **key idea** concisely.
179
- <Fragment slot="aside">
180
- **Side note** for brief context or a definition.
181
- </Fragment>
182
- </Sidenote>
183
-
184
- <small className="muted">Example</small>
185
- ```mdx
186
- import Sidenote from '../components/Sidenote.astro'
187
-
188
- <Sidenote>
189
- Main paragraph with the core idea.
190
- <Fragment slot="aside">Short side note.</Fragment>
191
- </Sidenote>
192
- ```
193
-
194
-
195
- #### Wide example
196
-
197
- <Wide>
198
- <div className="demo-wide">demo wide</div>
199
- </Wide>
200
-
201
- <small className="muted">Example</small>
202
- ```mdx
203
- import Wide from '../components/Wide.astro'
204
-
205
- <Wide>
206
- Your content here...
207
- </Wide>
208
- ```
209
-
210
- #### Full-width example
211
-
212
- <FullWidth>
213
- <div className="demo-full-width">demo full-width</div>
214
- </FullWidth>
215
-
216
- <small className="muted">Example</small>
217
- ```mdx
218
- import FullWidth from '../components/FullWidth.astro'
219
-
220
- <FullWidth>
221
- Your content here...
222
- </FullWidth>
223
- ```
 
8
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
9
  import audioDemo from '../assets/audio/audio-example.wav';
10
 
11
+ ## Writing your content
12
 
13
  ### **Introduction**
14
 
 
21
 
22
  The `article.mdx` file is the **main entry point** of your article.
23
 
24
+ **MDX** is a mix of **Markdown** and **HTML/JSX**: write regular Markdown, and embed **interactive components** inline when needed. We’ll describe the available options you can use later in this guide.
25
+
26
+ Here's what the file looks like in detail:
27
+
28
  <small className="muted">app/src/content/article.mdx</small>
29
  ```mdx
30
+ {/* -- Header ---------------------------- */}
31
  ---
32
+ {/* ---- Required parameters ------------- */}
33
  title: "This is the main title"
34
  subtitle: "This will be displayed just below the banner"
35
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
 
 
 
 
 
36
  published: "Feb 19, 2025"
37
  tags:
38
  - research
39
  - template
40
+ authors:
41
+ - name: "Thibaud Frere" {/* Author name */}
42
+ url: "https://huggingface.co/tfrere" {/* Optional author url */}
43
+ affiliations: [1] {/* Optional author affiliations based on the list below */}
44
+ - name: "Alice Martin"
45
+ url: "https://example.com/~alice"
46
+ affiliations: [1, 2]
47
+ - name: "Robert Brown"
48
+ url: "https://example.com/~bob"
49
+ affiliations: [2]
50
+
51
+ {/* ---- Optional parameters ------------- */}
52
+ affiliations: {/* Affiliations list */}
53
+ - name: "Hugging Face"
54
+ url: "https://huggingface.co"
55
+ - name: "Example University"
56
+ url: "https://example.edu"
57
+ doi: 10.1234/abcd.efgh {/* Add a DOI to your article */}
58
+ {/* Add a licence text to the end of your article */}
59
+ licence: Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/stfrere/research-article-template">Hugging Face</a>, unless noted otherwise. Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
60
+ seoThumbImage: "https://example.com/thumb.png" {/* Override default thumbnail */}
61
+ tableOfContentsAutoCollapse: true {/* By default the table of contents is not collapsing */}
62
  ---
63
 
64
+ {/* -- Imports -------------------------- */}
65
+ import placeholder from '../assets/images/placeholder.png'
66
+ import ResponsiveImage from '../components/ResponsiveImage.astro'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  import Sidenote from '../components/Sidenote.astro'
68
 
69
+ {/* -- Markdown content ------------------ */}
70
+ <Sidenote>
71
+ This paragraph is written in Markdown.
72
+ <Fragment slot="aside">A short callout inserted via a component.</Fragment>
73
+ </Sidenote>
74
+ <ResponsiveImage src={placeholder} alt="Sample image with optimization" />
 
75
 
76
+ This paragraph is also written in Markdown.
77
  ```
78
 
79
 
 
99
 
100
  The **Table of contents** is generated **automatically** from your **H2–H4** headings. Keep headings **short** and **descriptive**; links work on **desktop** and **mobile**.
101
 
102
+ <Note variant="info">You can make the table of contents collapse by changing the `tableOfContentsAutoCollapse` parameter in the frontmatter. Which is `true` by default.</Note>
103
+
104
  ### Theme
105
 
106
  All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose.
 
166
  document.documentElement.style.setProperty('--palette-diverging-count', '7');
167
  window.ColorPalettes.refresh();
168
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/pages/index.astro CHANGED
@@ -4,7 +4,7 @@ import Hero from '../components/Hero.astro';
4
  import Footer from '../components/Footer.astro';
5
  import ThemeToggle from '../components/ThemeToggle.astro';
6
  import Seo from '../components/Seo.astro';
7
- import TableOfContent from '../components/TableOfContent.astro';
8
  // Default OG image served from public/
9
  const ogDefaultUrl = '/thumb.jpg';
10
  import 'katex/dist/katex.min.css';
@@ -18,11 +18,78 @@ const docTitleHtml = (articleFM?.title ?? 'Untitled article')
18
  .replace(/\n/g, '<br/>');
19
  const subtitle = articleFM?.subtitle ?? '';
20
  const description = articleFM?.description ?? '';
21
- const authors = articleFM?.authors ?? [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  const published = articleFM?.published ?? undefined;
23
  const tags = articleFM?.tags ?? [];
24
- // Prefer ogImage from frontmatter if provided
25
- const fmOg = articleFM?.ogImage as string | undefined;
26
  const imageAbs: string = fmOg && fmOg.startsWith('http')
27
  ? fmOg
28
  : (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl));
@@ -43,33 +110,27 @@ const extractYear = (val: string | undefined): number | undefined => {
43
  };
44
 
45
  const year = extractYear(published);
46
- const citationAuthorsText = authors.join(', ');
47
  const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
48
 
49
- const authorsBib = authors.join(' and ');
50
- const keyAuthor = (authors[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCase();
51
  const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
52
  const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
53
- const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
54
- // Determine if TOC auto-collapse is enabled via (priority):
55
- // 1) article frontmatter: tableOfContentAutoCollapse (fallback: tocAutoCollapse)
56
- // 2) public env flag: PUBLIC_TOC_AUTO_COLLAPSE
57
- // Falls back to 'false' when not defined (string or boolean)
58
- const __env = (import.meta as any)?.env || {};
59
- const envCollapse = (
60
- String((__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE ?? __env.PUBLIC_TOC_AUTO_COLLAPSE ?? 'false')).toLowerCase() === 'true'
61
- || (__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE === true)
62
- || (__env.PUBLIC_TOC_AUTO_COLLAPSE === true)
63
- );
64
  const tableOfContentAutoCollapse = Boolean(
65
- (articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tocAutoCollapse ?? envCollapse
66
  );
 
 
67
  ---
68
  <html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
69
  <head>
70
  <meta charset="utf-8" />
71
  <meta name="viewport" content="width=device-width, initial-scale=1" />
72
- <Seo title={docTitle} description={description} authors={authors} published={published} tags={tags} image={imageAbs} />
73
  <script is:inline>
74
  (() => {
75
  try {
@@ -90,10 +151,10 @@ const tableOfContentAutoCollapse = Boolean(
90
  </head>
91
  <body>
92
  <ThemeToggle />
93
- <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
94
 
95
  <section class="content-grid">
96
- <TableOfContent tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
97
  <main>
98
  <Article />
99
  <style is:inline>
@@ -103,7 +164,7 @@ const tableOfContentAutoCollapse = Boolean(
103
  </main>
104
  </section>
105
 
106
- <Footer citationText={citationText} bibtex={bibtex} />
107
 
108
 
109
  <script>
 
4
  import Footer from '../components/Footer.astro';
5
  import ThemeToggle from '../components/ThemeToggle.astro';
6
  import Seo from '../components/Seo.astro';
7
+ import TableOfContents from '../components/TableOfContents.astro';
8
  // Default OG image served from public/
9
  const ogDefaultUrl = '/thumb.jpg';
10
  import 'katex/dist/katex.min.css';
 
18
  .replace(/\n/g, '<br/>');
19
  const subtitle = articleFM?.subtitle ?? '';
20
  const description = articleFM?.description ?? '';
21
+ // Accept authors as string[] or array of objects { name, url, affiliations? }
22
+ const rawAuthors = (articleFM as any)?.authors ?? [];
23
+ type Affiliation = { id: number; name: string; url?: string };
24
+ type Author = { name: string; url?: string; affiliationIndices?: number[] };
25
+
26
+ // Normalize affiliations from frontmatter: supports strings or objects { id?, name, url? }
27
+ const rawAffils = (articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? [];
28
+ const normalizedAffiliations: Affiliation[] = (() => {
29
+ const seen: Map<string, number> = new Map();
30
+ const list: Affiliation[] = [];
31
+ const pushUnique = (name: string, url?: string) => {
32
+ const key = `${String(name).trim()}|${url ? String(url).trim() : ''}`;
33
+ if (seen.has(key)) return seen.get(key)!;
34
+ const id = list.length + 1;
35
+ list.push({ id, name: String(name).trim(), url: url ? String(url) : undefined });
36
+ seen.set(key, id);
37
+ return id;
38
+ };
39
+ const input = Array.isArray(rawAffils) ? rawAffils : (rawAffils ? [rawAffils] : []);
40
+ for (const a of input) {
41
+ if (typeof a === 'string') {
42
+ pushUnique(a);
43
+ } else if (a && typeof a === 'object') {
44
+ const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? '';
45
+ if (!String(name).trim()) continue;
46
+ const url = a.url || a.link;
47
+ // Respect provided numeric id for display stability if present and sequential; otherwise reassign
48
+ pushUnique(String(name), url ? String(url) : undefined);
49
+ }
50
+ }
51
+ return list;
52
+ })();
53
+
54
+ // Helper: ensure an affiliation exists and return its id
55
+ const ensureAffiliation = (val: any): number | undefined => {
56
+ if (val == null) return undefined;
57
+ if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
58
+ return Math.floor(val);
59
+ }
60
+ const name = typeof val === 'string' ? val : (val?.name ?? val?.label ?? val?.text ?? val?.affiliation);
61
+ if (!name || !String(name).trim()) return undefined;
62
+ const existing = normalizedAffiliations.find(a => a.name === String(name).trim());
63
+ if (existing) return existing.id;
64
+ const id = normalizedAffiliations.length + 1;
65
+ normalizedAffiliations.push({ id, name: String(name).trim(), url: val?.url || val?.link });
66
+ return id;
67
+ };
68
+
69
+ // Normalize authors and map affiliations -> indices (Distill-like)
70
+ const normalizedAuthors: Author[] = (Array.isArray(rawAuthors) ? rawAuthors : [])
71
+ .map((a: any) => {
72
+ if (typeof a === 'string') {
73
+ return { name: a } as Author;
74
+ }
75
+ const name = String(a?.name || '').trim();
76
+ const url = a?.url || a?.link;
77
+ let indices: number[] | undefined = undefined;
78
+ const raw = a?.affiliations ?? a?.affiliation ?? a?.affils;
79
+ if (raw != null) {
80
+ const entries = Array.isArray(raw) ? raw : [raw];
81
+ const ids = entries.map(ensureAffiliation).filter((x): x is number => typeof x === 'number');
82
+ const unique = Array.from(new Set(ids)).sort((x, y) => x - y);
83
+ if (unique.length) indices = unique;
84
+ }
85
+ return { name, url, affiliationIndices: indices } as Author;
86
+ })
87
+ .filter((a: Author) => a.name && a.name.trim().length > 0);
88
+ const authorNames: string[] = normalizedAuthors.map(a => a.name);
89
  const published = articleFM?.published ?? undefined;
90
  const tags = articleFM?.tags ?? [];
91
+ // Prefer seoThumbImage from frontmatter if provided
92
+ const fmOg = articleFM?.seoThumbImage as string | undefined;
93
  const imageAbs: string = fmOg && fmOg.startsWith('http')
94
  ? fmOg
95
  : (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl));
 
110
  };
111
 
112
  const year = extractYear(published);
113
+ const citationAuthorsText = authorNames.join(', ');
114
  const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
115
 
116
+ const authorsBib = authorNames.join(' and ');
117
+ const keyAuthor = (authorNames[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCase();
118
  const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
119
  const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
120
+ const doi = (ArticleMod as any)?.frontmatter?.doi ? String((ArticleMod as any).frontmatter.doi) : undefined;
121
+ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ''}${doi ? `doi={${doi}}` : ''}\n}`;
122
+ const envCollapse = false;
 
 
 
 
 
 
 
 
123
  const tableOfContentAutoCollapse = Boolean(
124
+ (articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tableOfContentsAutoCollapse ?? envCollapse
125
  );
126
+ // Licence note (HTML allowed)
127
+ const licence = (articleFM as any)?.licence ?? (articleFM as any)?.license ?? (articleFM as any)?.licenseNote;
128
  ---
129
  <html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
130
  <head>
131
  <meta charset="utf-8" />
132
  <meta name="viewport" content="width=device-width, initial-scale=1" />
133
+ <Seo title={docTitle} description={description} authors={authorNames} published={published} tags={tags} image={imageAbs} />
134
  <script is:inline>
135
  (() => {
136
  try {
 
151
  </head>
152
  <body>
153
  <ThemeToggle />
154
+ <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={normalizedAuthors as any} affiliations={normalizedAffiliations as any} affiliation={articleFM?.affiliation} published={articleFM?.published} doi={doi} />
155
 
156
  <section class="content-grid">
157
+ <TableOfContents tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
158
  <main>
159
  <Article />
160
  <style is:inline>
 
164
  </main>
165
  </section>
166
 
167
+ <Footer citationText={citationText} bibtex={bibtex} licence={licence} doi={doi} />
168
 
169
 
170
  <script>
app/src/styles/_base.css CHANGED
@@ -1,16 +1,5 @@
1
- /* ============================================================================ */
2
- /* Base / Reset */
3
- /* ============================================================================ */
4
- html { box-sizing: border-box; }
5
- *, *::before, *::after { box-sizing: inherit; }
6
- body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji; color: var(--text-color); }
7
- audio { display: block; width: 100%; }
8
- /* Avoid constraining <main> inside grid; scope container sizing elsewhere if needed */
9
- /* main { max-width: 980px; margin: 24px auto; padding: 16px; } */
10
 
11
- /* ============================================================================ */
12
- /* Typography (inspired by Distill) */
13
- /* ============================================================================ */
14
  html { font-size: 14px; line-height: 1.6; }
15
  @media (min-width: 768px) { html { font-size: 16px; } }
16
  @media (min-width: 1024px) { html { font-size: 17px; } }
@@ -70,226 +59,12 @@ html { font-size: 14px; line-height: 1.6; }
70
  margin: var(--spacing-4) 0;
71
  }
72
 
73
- /* Rely on Shiki's own token spans; no class remap */
74
- /* Placeholder block (discreet centered text) */
75
- .placeholder-block {
76
- display: grid;
77
- place-items: center;
78
- min-height: 120px;
79
- color: var(--muted-color);
80
- font-size: 12px;
81
- border: 1px dashed var(--border-color);
82
- border-radius: 8px;
83
- background: var(--surface-bg);
84
- }
85
-
86
- /* Demo blocks for width helpers */
87
- .demo-wide,
88
- .demo-full-width {
89
- display: grid;
90
- place-items: center;
91
- min-height: 150px;
92
- color: var(--muted-color);
93
- font-size: 12px;
94
- border: 1px dashed var(--border-color);
95
- border-radius: 8px;
96
- background: var(--surface-bg);
97
- }
98
-
99
-
100
- .content-grid main table {
101
- border-collapse: collapse;
102
- /* Make markdown tables scroll horizontally when they overflow on small screens */
103
- display: block;
104
- width: 100%;
105
- max-width: none;
106
- overflow-x: auto;
107
- -webkit-overflow-scrolling: touch;
108
- margin: 0 0 var(--spacing-4);
109
- }
110
- .content-grid main th, .content-grid main td {
111
- border-bottom: 1px solid var(--border-color);
112
- padding: 6px 8px;
113
- text-align: left;
114
- font-size: 15px;
115
- white-space: nowrap; /* prevent squashing; allow horizontal scroll instead */
116
- }
117
- .content-grid main thead th { border-bottom: 1px solid var(--border-color); }
118
-
119
  .content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
120
 
121
- /*
122
- .code-block {
123
- background: rgba(120, 120, 120, 0.5);
124
- border: 1px solid var(--border-color);
125
- border-radius: 6px;
126
- padding: var(--spacing-3);
127
- font-size: 14px;
128
- overflow: auto;
129
- }
130
- */
131
-
132
- /* ============================================================================ */
133
- /* Media / Figures */
134
- /* ============================================================================ */
135
- img,
136
- picture {
137
- max-width: 100%;
138
- height: auto;
139
- display: block;
140
- position: relative;
141
- z-index: var(--z-elevated);
142
- }
143
-
144
- /* Inline feature tags */
145
- .tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 16px; }
146
- .tag {
147
- display: inline-flex;
148
- align-items: center;
149
- gap: 6px;
150
- padding: 4px 8px;
151
- font-size: 12px;
152
- line-height: 1;
153
- border-radius: 999px;
154
- background: var(--surface-bg);
155
- border: 1px solid var(--border-color);
156
- color: var(--text-color);
157
- }
158
- [data-theme="dark"] .tag { background: #1a1f27; border-color: rgba(255,255,255,.15); }
159
-
160
-
161
-
162
- /* ============================================================================ */
163
- /* Figures, captions & image credits */
164
- /* ============================================================================ */
165
- figure { margin: 12px 0; }
166
- figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
167
- .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
168
- .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
169
-
170
- /* Zoomable overlay container (if used by any lightbox implementation) */
171
- [data-zoom-overlay],
172
- .zoom-overlay {
173
- position: fixed;
174
- inset: 0;
175
- z-index: var(--z-overlay);
176
- }
177
-
178
- /* Download link inside figures */
179
- figure .download-link { position: relative; z-index: var(--z-elevated); }
180
-
181
- /* ============================================================================ */
182
- /* Buttons (minimal, clean) */
183
- /* ============================================================================ */
184
- button, .button {
185
- appearance: none;
186
- background: linear-gradient(15deg, var(--primary-color) 0%, var(--primary-color-hover) 35%);
187
- color: white!important;
188
- border: 1px solid transparent;
189
- border-radius: 6px;
190
- padding: 8px 12px;
191
- font-size: 14px;
192
- line-height: 1;
193
- cursor: pointer;
194
- display: inline-block;
195
- transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease;
196
- }
197
- /* Icon-only buttons: equal X/Y padding */
198
- button:has(> svg:only-child),
199
- .button:has(> svg:only-child) {
200
- padding: 8px !important;
201
- }
202
- button:hover, .button:hover {
203
- filter: brightness(96%);
204
- }
205
- button:active, .button:active {
206
- transform: translateY(1px);
207
- }
208
- button:focus-visible, .button:focus-visible {
209
- outline: none;
210
- }
211
- button:disabled, .button:disabled {
212
- opacity: .6;
213
- cursor: not-allowed;
214
- }
215
-
216
- /* Ghost/Muted button: subtle outline, primary color text/border */
217
- .button--ghost {
218
- background: transparent !important;
219
- color: var(--primary-color) !important;
220
- border-color: var(--primary-color) !important;
221
- }
222
- .button--ghost:hover {
223
- color: var(--primary-color-hover) !important;
224
- border-color: var(--primary-color-hover) !important;
225
- filter: none;
226
- }
227
-
228
- .button-group .button {
229
- margin: 5px;
230
- }
231
-
232
- /* ============================================================================ */
233
- /* Print styles */
234
- /* ========================================================================= */
235
- @media print {
236
- html, body { background: #fff; }
237
- /* Margins handled by Playwright; avoid extra global margins */
238
- body { margin: 0; }
239
-
240
- /* Keep the banner (hero), hide non-essential UI elements */
241
- #theme-toggle { display: none !important; }
242
-
243
- /* Links: remove underline */
244
- .content-grid main a { text-decoration: none; border-bottom: 1px solid rgba(0,0,0,.2); }
245
-
246
- /* Avoid breaks inside complex blocks */
247
- .content-grid main pre,
248
- .content-grid main blockquote,
249
- .content-grid main table,
250
- .content-grid main figure { break-inside: avoid; page-break-inside: avoid; }
251
-
252
- /* Soft page breaks around main headings */
253
- .content-grid main h2 { page-break-before: auto; page-break-after: avoid; break-after: avoid-page; }
254
-
255
- /* Small icon labels not needed when printing */
256
- .code-lang-chip { display: none !important; }
257
-
258
- /* Adjust more contrasty colors for print */
259
- :root {
260
- --border-color: rgba(0,0,0,.2);
261
- --link-underline: rgba(0,0,0,.3);
262
- --link-underline-hover: rgba(0,0,0,.4);
263
- }
264
-
265
- /* Force single column to reduce widows/orphans and awkward breaks */
266
- .content-grid { grid-template-columns: 1fr !important; }
267
- .toc, .right-aside, .toc-mobile { display: none !important; }
268
- main > nav:first-of-type { display: none !important; }
269
-
270
- /* Avoid page breaks inside complex visual blocks */
271
- .hero,
272
- .hero-banner,
273
- .d3-galaxy,
274
- .d3-galaxy svg,
275
- .html-embed__card,
276
- .html-embed__card,
277
- .js-plotly-plot,
278
- figure,
279
- pre,
280
- table,
281
- blockquote,
282
- .wide,
283
- .full-width {
284
- break-inside: avoid;
285
- page-break-inside: avoid;
286
- }
287
- /* Prefer keeping header+lead together */
288
- .hero { page-break-after: avoid; }
289
- }
290
-
291
  .muted {
292
  color: var(--muted-color);
293
  }
294
 
295
-
 
 
 
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: 14px; line-height: 1.6; }
4
  @media (min-width: 768px) { html { font-size: 16px; } }
5
  @media (min-width: 1024px) { html { font-size: 17px; } }
 
59
  margin: var(--spacing-4) 0;
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  .content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  .muted {
65
  color: var(--muted-color);
66
  }
67
 
68
+ [data-footnote-ref] {
69
+ margin-left: 4px;
70
+ }
app/src/styles/_layout.css CHANGED
@@ -1,9 +1,6 @@
1
  /* ============================================================================ */
2
- /* Layout – 3-column grid (TOC / Article / Aside) */
3
  /* ============================================================================ */
4
- :root {
5
- --content-padding-x: 16px;
6
- }
7
 
8
  .content-grid {
9
  max-width: 1280px;
@@ -16,75 +13,27 @@
16
  align-items: start;
17
  }
18
 
19
- .content-grid > main { max-width: 100%;
 
20
  margin: 0;
21
  padding: 0;
22
  }
23
 
24
- @media (max-width: 1100px) {
25
- .content-grid { overflow: hidden; }
 
 
26
  }
27
 
28
- /* TOC (left column) */
29
- .toc { position: sticky; top: 24px; }
30
- .toc nav { border-left: 1px solid var(--border-color); padding-left: 16px; font-size: 13px; }
31
- .toc .title { font-weight: 600; font-size: 14px; margin-bottom: 8px; }
32
-
33
- /* Hide in-article TOC (duplicated by sticky aside) */
34
- main > nav:first-of-type { display: none; }
35
-
36
- /* TOC look & feel */
37
- .toc nav ul { margin: 0 0 6px; padding-left: 1em; }
38
- .toc nav li { list-style: none; margin: .25em 0; }
39
- .toc nav a { color: var(--text-color); text-decoration: none; border-bottom: none; }
40
- .toc nav > ul > li > a { font-weight: 700; }
41
- .toc nav a:hover { text-decoration: underline solid var(--muted-color); }
42
- .toc nav a.active { text-decoration: underline; }
43
-
44
- /* Mobile TOC accordion */
45
- .toc-mobile { display: none; margin: 8px 0 16px; }
46
- .toc-mobile > summary { cursor: pointer; list-style: none; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-color); font-weight: 600; }
47
- .toc-mobile[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
48
- .toc-mobile nav { border-left: none; padding: 10px 12px; font-size: 14px; border: 1px solid var(--border-color); border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; }
49
- .toc-mobile nav ul { margin: 0 0 6px; padding-left: 1em; }
50
- .toc-mobile nav li { list-style: none; margin: .25em 0; }
51
- .toc-mobile nav a { color: var(--text-color); text-decoration: none; border-bottom: none; }
52
- .toc-mobile nav > ul > li > a { font-weight: 700; }
53
- .toc-mobile nav a:hover { text-decoration: underline solid var(--muted-color); }
54
- .toc-mobile nav a.active { text-decoration: underline; }
55
-
56
- /* Right aside (notes) */
57
- .right-aside { position: sticky; top: 24px; }
58
- .right-aside .aside-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; margin-bottom: 10px; font-size: 0.9rem; color: var(--text-color); }
59
 
60
  /* Responsive – collapse to single column */
61
- @media (max-width: 1100px) {
62
- .content-grid { grid-template-columns: 1fr; }
63
- .toc { position: static; display: none; }
64
- .toc-mobile { display: block; }
65
- .right-aside { display: none; }
66
- main > nav:first-of-type { display: block; }
67
- }
68
-
69
-
70
-
71
- .margin-aside { position: relative; margin: 12px 0; }
72
 
73
- .margin-aside__aside {
74
- position: absolute;
75
- top: 0;
76
- right: -260px; /* push into the right grid column (width 260 + gap 32) */
77
- width: 260px;
78
- border-radius: 8px;
79
- padding: 0 30px;
80
- font-size: 0.9rem;
81
- color: var(--muted-color);
82
- }
83
- @media (max-width: 1100px) {
84
- .margin-aside__aside {
85
- position: static;
86
- width: auto;
87
- margin-top: 8px;
88
  }
89
  }
90
 
@@ -92,22 +41,18 @@ main > nav:first-of-type { display: none; }
92
  /* ============================================================================ */
93
  /* Width helpers – slightly wider than main column, and full-width to viewport */
94
  /* ---------------------------------------------------------------------------- */
95
- /* Usage in MDX: */
96
- /* <div className="wide"> ... </div> */
97
- /* <div className="full-width"> ... </div> */
98
- /* These center the content relative to the viewport while keeping it responsive. */
99
- /* */
100
- /* Notes: */
101
- /* - These helpers work inside the main article column; they break out visually */
102
- /* to be wider or fully span the viewport. On small screens, they fall back to 100%. */
103
- /* - Adjust the target width in .wide if desired. */
104
  .wide,
105
- .full-width { box-sizing: border-box; position: relative; z-index: var(--z-elevated); }
 
 
 
 
106
 
107
  .wide {
108
  /* Target up to ~1100px while staying within viewport minus page gutters */
109
  width: min(1100px, 100vw - 32px);
110
- margin-left: 50%;
111
  transform: translateX(-50%);
112
  }
113
 
@@ -118,7 +63,7 @@ main > nav:first-of-type { display: none; }
118
  margin-right: calc(50% - 50vw);
119
  }
120
 
121
- @media (max-width: 1100px) {
122
  .wide,
123
  .full-width {
124
  width: 100%;
 
1
  /* ============================================================================ */
2
+ /* Layout – 3-column grid (Table of Contents / Article / Aside) */
3
  /* ============================================================================ */
 
 
 
4
 
5
  .content-grid {
6
  max-width: 1280px;
 
13
  align-items: start;
14
  }
15
 
16
+ .content-grid > main {
17
+ max-width: 100%;
18
  margin: 0;
19
  padding: 0;
20
  }
21
 
22
+ @media (max-width: var(--bp-content-collapse)) {
23
+ .content-grid {
24
+ overflow: hidden;
25
+ }
26
  }
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  /* Responsive – collapse to single column */
30
+ @media (max-width: var(--bp-content-collapse)) {
31
+ .content-grid {
32
+ grid-template-columns: 1fr;
33
+ }
 
 
 
 
 
 
 
34
 
35
+ main > nav:first-of-type {
36
+ display: block;
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
  }
39
 
 
41
  /* ============================================================================ */
42
  /* Width helpers – slightly wider than main column, and full-width to viewport */
43
  /* ---------------------------------------------------------------------------- */
44
+
 
 
 
 
 
 
 
 
45
  .wide,
46
+ .full-width {
47
+ box-sizing: border-box;
48
+ position: relative;
49
+ z-index: var(--z-elevated);
50
+ }
51
 
52
  .wide {
53
  /* Target up to ~1100px while staying within viewport minus page gutters */
54
  width: min(1100px, 100vw - 32px);
55
+ margin-left: calc(50% + var(--content-padding-x) * 2);
56
  transform: translateX(-50%);
57
  }
58
 
 
63
  margin-right: calc(50% - 50vw);
64
  }
65
 
66
+ @media (max-width: var(--bp-content-collapse)) {
67
  .wide,
68
  .full-width {
69
  width: 100%;
app/src/styles/_print.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ /* ============================================================================ */
3
+ /* Print styles */
4
+ /* ========================================================================= */
5
+ @media print {
6
+ html, body { background: #fff; }
7
+ /* Margins handled by Playwright; avoid extra global margins */
8
+ body { margin: 0; }
9
+
10
+ /* Keep the banner (hero), hide non-essential UI elements */
11
+ #theme-toggle { display: none !important; }
12
+
13
+ /* Links: remove underline */
14
+ .content-grid main a { text-decoration: none; border-bottom: 1px solid rgba(0,0,0,.2); }
15
+
16
+ /* Avoid breaks inside complex blocks */
17
+ .content-grid main pre,
18
+ .content-grid main blockquote,
19
+ .content-grid main table,
20
+ .content-grid main figure { break-inside: avoid; page-break-inside: avoid; }
21
+
22
+ /* Soft page breaks around main headings */
23
+ .content-grid main h2 { page-break-before: auto; page-break-after: avoid; break-after: avoid-page; }
24
+
25
+ /* Small icon labels not needed when printing */
26
+ .code-lang-chip { display: none !important; }
27
+
28
+ /* Adjust more contrasty colors for print */
29
+ :root {
30
+ --border-color: rgba(0,0,0,.2);
31
+ --link-underline: rgba(0,0,0,.3);
32
+ --link-underline-hover: rgba(0,0,0,.4);
33
+ }
34
+
35
+ /* Force single column to reduce widows/orphans and awkward breaks */
36
+ .content-grid { grid-template-columns: 1fr !important; }
37
+ .table-of-contents, .right-aside, .table-of-contents-mobile { display: none !important; }
38
+ main > nav:first-of-type { display: none !important; }
39
+
40
+ /* Avoid page breaks inside complex visual blocks */
41
+ .hero,
42
+ .hero-banner,
43
+ .d3-galaxy,
44
+ .d3-galaxy svg,
45
+ .html-embed__card,
46
+ .html-embed__card,
47
+ .js-plotly-plot,
48
+ figure,
49
+ pre,
50
+ table,
51
+ blockquote,
52
+ .wide,
53
+ .full-width {
54
+ break-inside: avoid;
55
+ page-break-inside: avoid;
56
+ }
57
+ /* Prefer keeping header+lead together */
58
+ .hero { page-break-after: avoid; }
59
+ }
60
+
61
+ .muted {
62
+ color: var(--muted-color);
63
+ }
64
+
65
+
66
+
app/src/styles/_reset.css ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ html { box-sizing: border-box; }
2
+ *, *::before, *::after { box-sizing: inherit; }
3
+ body { margin: 0; font-family: var(--default-font-family); color: var(--text-color); }
4
+ audio { display: block; width: 100%; }
5
+
6
+ img,
7
+ picture {
8
+ max-width: 100%;
9
+ height: auto;
10
+ display: block;
11
+ position: relative;
12
+ z-index: var(--z-elevated);
13
+ }
app/src/styles/_variables.css CHANGED
@@ -1,28 +1,34 @@
1
  /* ============================================================================ */
2
- /* Theme Variables (inspired by Distill) */
3
  /* ============================================================================ */
4
  :root {
5
- /* Neutral palette */
6
  --neutral-600: rgb(107, 114, 128);
7
  --neutral-400: rgb(185, 185, 185);
8
  --neutral-300: rgb(228, 228, 228);
9
  --neutral-200: rgb(245, 245, 245);
10
 
11
- /* Primary brand color */
12
- --primary-color: rgb(232, 137, 171);
13
- --primary-color-hover: rgb(212, 126, 156);
 
 
 
 
14
  --on-primary: #ffffff;
15
 
 
16
  --text-color: rgba(0,0,0,.85);
17
  --muted-color: rgba(0,0,0,.6);
18
  --border-color: rgba(0,0,0,.1);
19
-
20
- /* Light surfaces & links */
21
  --surface-bg: #fafafa;
22
  --code-bg: #f6f8fa;
23
- --link-underline: var(--primary-color); /* based on --primary-color */
 
 
24
  --link-underline-hover: var(--primary-color-hover);
25
 
 
26
  --spacing-1: 8px;
27
  --spacing-2: 12px;
28
  --spacing-3: 16px;
@@ -34,32 +40,63 @@
34
  --spacing-9: 64px;
35
  --spacing-10: 72px;
36
 
37
- /* Palettes config */
38
- --palette-count: 6; /* default number of colors per palette */
 
 
 
 
 
 
 
 
 
39
 
40
- /* Z-index scale */
41
- --z-base: 0; /* background/base */
42
- --z-content: 1; /* regular content */
43
- --z-elevated: 10; /* wide/full-width blocks, images */
44
- --z-overlay: 1000; /* overlays/lightboxes */
45
- --z-modal: 1100; /* modals/dialogs */
46
- --z-tooltip: 1200; /* tooltips/popovers */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
- /* Theme tokens for dark mode */
 
 
 
49
  [data-theme="dark"] {
50
  --text-color: rgba(255,255,255,.9);
51
  --muted-color: rgba(255,255,255,.7);
52
  --border-color: rgba(255,255,255,.15);
53
  --surface-bg: #12151b;
54
  --code-bg: #12151b;
55
- /* Primary in dark mode */
56
- --primary-color: rgb(232, 137, 171);
57
- --primary-color-hover: rgb(212, 126, 156);
 
 
58
  --on-primary: #0f1115;
59
 
60
  color-scheme: dark;
61
  background: #0f1115;
62
- }
63
-
64
-
65
-
 
1
  /* ============================================================================ */
2
+ /* Design Tokens */
3
  /* ============================================================================ */
4
  :root {
5
+ /* Neutrals */
6
  --neutral-600: rgb(107, 114, 128);
7
  --neutral-400: rgb(185, 185, 185);
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);
15
+ --primary-color: var(--primary-base);
16
+ --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
17
+ --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
18
  --on-primary: #ffffff;
19
 
20
+ /* Text & Surfaces */
21
  --text-color: rgba(0,0,0,.85);
22
  --muted-color: rgba(0,0,0,.6);
23
  --border-color: rgba(0,0,0,.1);
 
 
24
  --surface-bg: #fafafa;
25
  --code-bg: #f6f8fa;
26
+
27
+ /* Links */
28
+ --link-underline: var(--primary-color);
29
  --link-underline-hover: var(--primary-color-hover);
30
 
31
+ /* Spacing scale */
32
  --spacing-1: 8px;
33
  --spacing-2: 12px;
34
  --spacing-3: 16px;
 
40
  --spacing-9: 64px;
41
  --spacing-10: 72px;
42
 
43
+ /* Breakpoints */
44
+ --bp-xs: 480px;
45
+ --bp-sm: 640px;
46
+ --bp-md: 768px;
47
+ --bp-lg: 1024px;
48
+ --bp-content-collapse: 1100px; /* layout single-column */
49
+ --bp-xl: 1280px;
50
+
51
+ /* Layout */
52
+ --content-padding-x: 16px; /* default page gutter */
53
+ --block-spacing-y: var(--spacing-4); /* default vertical spacing between block components */
54
 
55
+ /* Config */
56
+ --palette-count: 6;
57
+
58
+ /* Button tokens */
59
+ --button-radius: 6px;
60
+ --button-padding-x: 12px;
61
+ --button-padding-y: 8px;
62
+ --button-font-size: 14px;
63
+ --button-icon-padding: 8px;
64
+ /* Big button */
65
+ --button-big-padding-x: 16px;
66
+ --button-big-padding-y: 12px;
67
+ --button-big-font-size: 16px;
68
+ --button-big-icon-padding: 12px;
69
+
70
+ /* Table tokens */
71
+ --table-border-radius: 8px;
72
+ --table-header-bg: oklch(from var(--surface-bg) calc(l - 0.02) c h);
73
+ --table-row-odd-bg: oklch(from var(--surface-bg) calc(l - 0.01) c h);
74
+
75
+ /* Z-index */
76
+ --z-base: 0;
77
+ --z-content: 1;
78
+ --z-elevated: 10;
79
+ --z-overlay: 1000;
80
+ --z-modal: 1100;
81
+ --z-tooltip: 1200;
82
  }
83
+
84
+ /* ============================================================================ */
85
+ /* Dark Theme Overrides */
86
+ /* ============================================================================ */
87
  [data-theme="dark"] {
88
  --text-color: rgba(255,255,255,.9);
89
  --muted-color: rgba(255,255,255,.7);
90
  --border-color: rgba(255,255,255,.15);
91
  --surface-bg: #12151b;
92
  --code-bg: #12151b;
93
+
94
+ /* Primary (lower L in dark) */
95
+ --primary-color: oklch(from var(--primary-base) calc(l - 0.08) c h);
96
+ --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
97
+ --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
98
  --on-primary: #0f1115;
99
 
100
  color-scheme: dark;
101
  background: #0f1115;
102
+ }
 
 
 
app/src/styles/components/_button.css ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ button, .button {
2
+ appearance: none;
3
+ background: linear-gradient(15deg, var(--primary-color) 0%, var(--primary-color-hover) 35%);
4
+ color: white!important;
5
+ border: 1px solid transparent;
6
+ border-radius: var(--button-radius);
7
+ padding: var(--button-padding-y) var(--button-padding-x);
8
+ font-size: var(--button-font-size);
9
+ line-height: 1;
10
+ cursor: pointer;
11
+ display: inline-block;
12
+ text-decoration: none;
13
+ transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease;
14
+ }
15
+ /* Icon-only buttons: equal X/Y padding */
16
+ button:has(> svg:only-child),
17
+ .button:has(> svg:only-child) {
18
+ padding: var(--button-icon-padding) !important;
19
+ }
20
+ button:hover, .button:hover {
21
+ filter: brightness(96%);
22
+ }
23
+ button:active, .button:active {
24
+ transform: translateY(1px);
25
+ }
26
+ button:focus-visible, .button:focus-visible {
27
+ outline: none;
28
+ }
29
+ button:disabled, .button:disabled {
30
+ opacity: .6;
31
+ cursor: not-allowed;
32
+ }
33
+
34
+ /* Ghost/Muted button: subtle outline, primary color text/border */
35
+ .button--ghost {
36
+ background: transparent !important;
37
+ color: var(--primary-color) !important;
38
+ border-color: var(--primary-color) !important;
39
+ }
40
+ .button--ghost:hover {
41
+ color: var(--primary-color-hover) !important;
42
+ border-color: var(--primary-color-hover) !important;
43
+ filter: none;
44
+ }
45
+
46
+ /* Big button: larger padding and font size */
47
+ .button--big {
48
+ padding: var(--button-big-padding-y) var(--button-big-padding-x) !important;
49
+ font-size: var(--button-big-font-size);
50
+ }
51
+ .button--big:has(> svg:only-child) {
52
+ padding: var(--button-big-icon-padding) !important;
53
+ }
54
+
55
+ .button-group .button {
56
+ margin: 5px;
57
+ }
58
+
app/src/styles/components/_code.css CHANGED
@@ -1,8 +1,10 @@
 
 
 
1
  code {
2
  font-size: 14px;
3
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
4
  background-color: var(--code-bg);
5
- padding: 0.2em 0.4em;
6
  border-radius: 0.3em;
7
  border: 1px solid var(--border-color);
8
  color: var(--text-color);
@@ -10,56 +12,129 @@ code {
10
  line-height: 1.5;
11
  }
12
 
13
- /* Sync Shiki variables with current theme */
14
- /* Standard wrapper look for code blocks */
15
- .astro-code { border: 1px solid var(--border-color); border-radius: 6px; padding: 0; font-size: 14px; --code-gutter-width: 2.5em; }
16
- .astro-code { position: relative; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- /* Prevent code blocks from breaking layout on small screens */
19
- .astro-code { overflow-x: auto; width: 100%; max-width: 100%; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
20
- section.content-grid pre { overflow-x: auto; width: 100%; max-width: 100%; box-sizing: border-box; -webkit-overflow-scrolling: touch; padding: 0; }
21
- section.content-grid pre code { display: inline-block; min-width: 100%; }
 
 
22
 
23
- /* Wrap long lines on mobile to avoid overflow (URLs, etc.) */
24
  /* Wrap long lines only on small screens to prevent layout overflow */
25
- @media (max-width: 1100px) {
26
  .astro-code,
27
- section.content-grid pre { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
28
- section.content-grid pre code { white-space: pre-wrap; display: block; min-width: 0; }
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
- html[data-theme='light'] .astro-code { background-color: var(--code-bg); }
32
-
33
- html[data-theme='dark'] .astro-code { background-color: var(--shiki-dark-bg); }
34
 
35
  /* Apply token color from per-span vars exposed by Shiki dual themes */
36
- html[data-theme='light'] .astro-code span { color: var(--shiki-light) !important; }
37
- html[data-theme='dark'] .astro-code span { color: var(--shiki-dark) !important; }
38
 
39
- /* Token color remapping using Shiki CSS variables on the wrapper */
40
  /* Optional: boost contrast for light theme */
41
- html[data-theme='light'] .astro-code {
42
  --shiki-foreground: #24292f;
43
  --shiki-background: #ffffff;
44
  }
45
 
46
  /* Line numbers for Shiki-rendered code blocks */
47
- .astro-code code { counter-reset: astro-code-line; display: block; background: none; border: none; }
48
- .astro-code .line { display: inline-block; position: relative; padding-left: calc(var(--code-gutter-width) + var(--spacing-1)); min-height: 1.25em; }
49
- .astro-code .line::before { counter-increment: astro-code-line; content: counter(astro-code-line); position: absolute; left: 0; top: 0; bottom: 0; width: calc(var(--code-gutter-width)); text-align: right; color: var(--muted-color); opacity: .30; user-select: none; padding-right: var(--spacing-2); border-right: 1px solid var(--border-color); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  .astro-code .line:empty::after { content: "\00a0"; }
 
51
  /* Hide trailing empty line added by parsers */
52
  .astro-code code > .line:last-child:empty { display: none; }
53
 
54
- /* (Removed JS fallback chip: label handled via CSS in _base.css) */
55
-
56
- /* Rehype-injected wrapper for non-Shiki pre blocks */
57
  .code-card { position: relative; }
 
58
  .code-card .code-copy {
59
- position: absolute; top: 6px; right: 6px; z-index: 3; padding: 6px 12px; border: none;
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
- .code-card .code-copy svg { width: 16px; height: 16px; display: block; fill: currentColor; }
62
- .code-card pre { margin: 0; margin-bottom: var(--spacing-1);}
63
 
64
  /* Discreet filetype/language label shown under the Copy button */
65
  .code-card::after {
@@ -78,15 +153,18 @@ html[data-theme='light'] .astro-code {
78
  /* When no copy button (single-line), keep the label in the top-right corner */
79
  .code-card.no-copy::after { top: 8px; right: 8px; }
80
 
81
-
82
-
83
- /* Overrides inside Accordion: remove padding and border on code containers */
 
84
  .accordion .astro-code { padding: 0; border: none; }
85
 
86
- /* Language/extension vignette for Shiki blocks (bottom-right, discreet) */
 
 
87
  .astro-code::after {
88
  content: attr(data-language);
89
- position: absolute;
90
  right: 0;
91
  bottom: 0;
92
  font-size: 10px;
@@ -96,7 +174,7 @@ html[data-theme='light'] .astro-code {
96
  background: var(--surface-bg);
97
  border-top: 1px solid var(--border-color);
98
  border-left: 1px solid var(--border-color);
99
- opacity: 0.5;
100
  border-radius: 8px 0 0 0; /* round only top-left */
101
  padding: 4px 6px;
102
  pointer-events: none;
 
1
+ /* ============================================================================ */
2
+ /* Inline code */
3
+ /* ============================================================================ */
4
  code {
5
  font-size: 14px;
6
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
7
  background-color: var(--code-bg);
 
8
  border-radius: 0.3em;
9
  border: 1px solid var(--border-color);
10
  color: var(--text-color);
 
12
  line-height: 1.5;
13
  }
14
 
15
+ /* ============================================================================ */
16
+ /* Shiki code blocks */
17
+ /* ============================================================================ */
18
+ .astro-code {
19
+ position: relative;
20
+ border: 1px solid var(--border-color);
21
+ border-radius: 6px;
22
+ padding: 0;
23
+ font-size: 14px;
24
+ --code-gutter-width: 2.5em;
25
+ }
26
+
27
+ /* Shared sizing & horizontal scroll for code containers */
28
+ .astro-code,
29
+ section.content-grid pre {
30
+ overflow-x: auto;
31
+ width: 100%;
32
+ max-width: 100%;
33
+ box-sizing: border-box;
34
+ -webkit-overflow-scrolling: touch;
35
+ padding: 0;
36
+ }
37
+
38
+ /* Consistent vertical spacing for code blocks */
39
+ .astro-code,
40
+ section.content-grid pre {
41
+ margin: 0 0 var(--block-spacing-y);
42
+ }
43
 
44
+ section.content-grid pre { margin: 0; }
45
+
46
+ section.content-grid pre code {
47
+ display: inline-block;
48
+ min-width: 100%;
49
+ }
50
 
 
51
  /* Wrap long lines only on small screens to prevent layout overflow */
52
+ @media (max-width: var(--bp-content-collapse)) {
53
  .astro-code,
54
+ section.content-grid pre {
55
+ white-space: pre-wrap;
56
+ overflow-wrap: anywhere;
57
+ word-break: break-word;
58
+ }
59
+
60
+ section.content-grid pre code {
61
+ white-space: pre-wrap;
62
+ display: block;
63
+ min-width: 0;
64
+ }
65
  }
66
 
67
+ /* Themes */
68
+ [data-theme='light'] .astro-code { background-color: var(--code-bg); }
 
69
 
70
  /* Apply token color from per-span vars exposed by Shiki dual themes */
71
+ [data-theme='light'] .astro-code span { color: var(--shiki-light) !important; }
72
+ [data-theme='dark'] .astro-code span { color: var(--shiki-dark) !important; }
73
 
 
74
  /* Optional: boost contrast for light theme */
75
+ [data-theme='light'] .astro-code {
76
  --shiki-foreground: #24292f;
77
  --shiki-background: #ffffff;
78
  }
79
 
80
  /* Line numbers for Shiki-rendered code blocks */
81
+ .astro-code code {
82
+ counter-reset: astro-code-line;
83
+ display: block;
84
+ background: none;
85
+ border: none;
86
+ }
87
+
88
+ .astro-code .line {
89
+ display: inline-block;
90
+ position: relative;
91
+ padding-left: calc(var(--code-gutter-width) + var(--spacing-1));
92
+ min-height: 1.25em;
93
+ }
94
+
95
+ .astro-code .line::before {
96
+ counter-increment: astro-code-line;
97
+ content: counter(astro-code-line);
98
+ position: absolute;
99
+ left: 0;
100
+ top: 0;
101
+ bottom: 0;
102
+ width: calc(var(--code-gutter-width));
103
+ text-align: right;
104
+ color: var(--muted-color);
105
+ opacity: .3;
106
+ user-select: none;
107
+ padding-right: var(--spacing-2);
108
+ border-right: 1px solid var(--border-color);
109
+ }
110
+
111
  .astro-code .line:empty::after { content: "\00a0"; }
112
+
113
  /* Hide trailing empty line added by parsers */
114
  .astro-code code > .line:last-child:empty { display: none; }
115
 
116
+ /* ============================================================================ */
117
+ /* Non-Shiki pre wrapper (rehype) */
118
+ /* ============================================================================ */
119
  .code-card { position: relative; }
120
+
121
  .code-card .code-copy {
122
+ position: absolute;
123
+ top: 6px;
124
+ right: 6px;
125
+ z-index: 3;
126
+ padding: 6px 12px;
127
+ border: none;
128
+ }
129
+
130
+ .code-card .code-copy svg {
131
+ width: 16px;
132
+ height: 16px;
133
+ display: block;
134
+ fill: currentColor;
135
  }
136
+
137
+ .code-card pre { margin: 0 0 var(--spacing-1); }
138
 
139
  /* Discreet filetype/language label shown under the Copy button */
140
  .code-card::after {
 
153
  /* When no copy button (single-line), keep the label in the top-right corner */
154
  .code-card.no-copy::after { top: 8px; right: 8px; }
155
 
156
+ /* ============================================================================ */
157
+ /* Contextual overrides */
158
+ /* ============================================================================ */
159
+ /* Inside Accordions: remove padding and border on code containers */
160
  .accordion .astro-code { padding: 0; border: none; }
161
 
162
+ /* ============================================================================ */
163
+ /* Language/extension vignette (bottom-right, discreet) */
164
+ /* ============================================================================ */
165
  .astro-code::after {
166
  content: attr(data-language);
167
+ position: sticky;
168
  right: 0;
169
  bottom: 0;
170
  font-size: 10px;
 
174
  background: var(--surface-bg);
175
  border-top: 1px solid var(--border-color);
176
  border-left: 1px solid var(--border-color);
177
+ opacity: 1;
178
  border-radius: 8px 0 0 0; /* round only top-left */
179
  padding: 4px 6px;
180
  pointer-events: none;
app/src/styles/components/_footer.css DELETED
@@ -1,56 +0,0 @@
1
- .distill-footer { contain: layout style; font-size: 0.8em; line-height: 1.7em; margin-top: 60px; margin-bottom: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); color: rgba(0, 0, 0, 0.5); }
2
- .footer-inner { max-width: 1280px; margin: 0 auto; padding: 60px 16px 48px; display: grid; grid-template-columns: 220px minmax(0, 680px) 260px; gap: 32px; align-items: start; }
3
-
4
- /* Use the parent grid (3 columns like .content-grid) */
5
- .citation-block,
6
- .references-block { display: contents; }
7
- .citation-block > h3,
8
- .references-block > h3 { grid-column: 1; font-size: 15px; margin: 0; text-align: right; padding-right: 30px; }
9
- .citation-block > :not(h3),
10
- .references-block > :not(h3) { grid-column: 2; }
11
- @media (max-width: 1100px) {
12
- .footer-inner { grid-template-columns: 1fr; gap: 16px; }
13
- .footer-inner > h3 { grid-column: auto; margin-top: 16px; }
14
- }
15
- .citation-block h3 { margin: 0 0 8px; }
16
- .citation-block h4 { margin: 16px 0 8px; font-size: 14px; text-transform: uppercase; color: var(--muted-color); }
17
-
18
- .citation-block p, .references {
19
- margin-top:0;
20
- }
21
-
22
- /* Distill-like appendix citation styling */
23
- .citation {
24
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
25
- font-size: 11px;
26
- line-height: 15px;
27
- border-left: 1px solid rgba(0, 0, 0, 0.1);
28
- padding-left: 18px;
29
- border: 1px solid rgba(0,0,0,0.1);
30
- background: rgba(0, 0, 0, 0.02);
31
- padding: 10px 18px;
32
- border-radius: 3px;
33
- color: rgba(150, 150, 150, 1);
34
- overflow: hidden;
35
- margin-top: -12px;
36
- white-space: pre-wrap;
37
- word-wrap: break-word;
38
- }
39
-
40
- .citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
41
-
42
- .citation.short { margin-top: -4px; }
43
-
44
- .references-block h3 { margin: 0; }
45
-
46
- /* Distill-like list styling for references/footnotes */
47
- .references-block ol { padding: 0 0 0 15px; }
48
- @media (min-width: 768px) { .references-block ol { padding: 0 0 0 30px; margin-left: -30px; } }
49
- .references-block li { margin-bottom: 1em; }
50
- .references-block a { color: rgba(0, 0, 0, 0.6); }
51
-
52
- @media (max-width: 1100px) {
53
- .footer-inner { display: block; padding: 40px 16px; }
54
- }
55
-
56
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/styles/components/_poltly.css DELETED
@@ -1,44 +0,0 @@
1
- /* ============================================================================ */
2
- /* Plotly – fragments & controls */
3
- /* ============================================================================ */
4
- .html-embed__card svg text { fill: var(--text-color) !important; }
5
- .html-embed__card label { color: var(--text-color) !important; }
6
- .plotly-graph-div { width: 100% !important; min-height: 320px; }
7
- @media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
8
- [id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
9
- .plotly_caption { font-style: italic; margin-top: 10px; }
10
- .plotly_controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; }
11
- .plotly_input_container { display: flex; align-items: center; flex-direction: column; gap: 10px; }
12
- .plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
13
- .plotly_slider { display: flex; align-items: center; gap: 10px; }
14
- .plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
15
- .plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
16
- .plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
17
- .plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
18
-
19
- /* ---------------------------------------------------------------------------- */
20
- /* Dark mode overrides for Plotly readability */
21
- /* ---------------------------------------------------------------------------- */
22
- [data-theme="dark"] .html-embed__card .xaxislayer-above text,
23
- [data-theme="dark"] .html-embed__card .yaxislayer-above text,
24
- [data-theme="dark"] .html-embed__card .infolayer text,
25
- [data-theme="dark"] .html-embed__card .legend text,
26
- [data-theme="dark"] .html-embed__card .annotation text,
27
- [data-theme="dark"] .html-embed__card .colorbar text,
28
- [data-theme="dark"] .html-embed__card .hoverlayer text { fill: #fff !important; }
29
-
30
- [data-theme="dark"] .html-embed__card .xaxislayer-above path,
31
- [data-theme="dark"] .html-embed__card .yaxislayer-above path,
32
- [data-theme="dark"] .html-embed__card .xlines-above,
33
- [data-theme="dark"] .html-embed__card .ylines-above { stroke: rgba(255,255,255,.35) !important; }
34
-
35
- [data-theme="dark"] .html-embed__card .gridlayer path { stroke: rgba(255,255,255,.15) !important; }
36
-
37
- /* Legend and hover backgrounds */
38
- [data-theme="dark"] .html-embed__card .legend rect.bg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
39
- [data-theme="dark"] .html-embed__card .hoverlayer .bg { fill: rgba(0,0,0,.8) !important; stroke: rgba(255,255,255,.2) !important; }
40
-
41
- /* Colorbar background (keep gradient intact) */
42
- [data-theme="dark"] .html-embed__card .colorbar .cbbg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
43
-
44
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/styles/components/_table.css ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .content-grid main table {
2
+ border-collapse: collapse;
3
+ table-layout: auto;
4
+ margin: 0;
5
+ }
6
+ .content-grid main th, .content-grid main td {
7
+ border-bottom: 1px solid var(--border-color);
8
+ padding: 6px 8px;
9
+ text-align: left;
10
+ font-size: 15px;
11
+ white-space: nowrap; /* prevent squashing; allow horizontal scroll instead */
12
+ }
13
+ .content-grid main thead th { border-bottom: 1px solid var(--border-color); }
14
+ .content-grid main thead th {
15
+ border-bottom: 1px solid var(--border-color);
16
+ }
17
+ .content-grid main thead th {
18
+ background: var(--table-header-bg);
19
+ padding-top: 10px;
20
+ padding-bottom: 10px;
21
+ font-weight: 600;
22
+ }
23
+
24
+ .content-grid main hr {
25
+ border: none;
26
+ border-bottom: 1px solid var(--border-color);
27
+ margin: var(--spacing-5) 0;
28
+ }
29
+
30
+ /* Scroll wrapper: keeps table 100% width but enables horizontal scroll when needed */
31
+ .content-grid main .table-scroll {
32
+ width: 100%;
33
+ overflow-x: auto;
34
+ -webkit-overflow-scrolling: touch;
35
+ border: 1px solid var(--border-color);
36
+ border-radius: var(--table-border-radius);
37
+ background: var(--surface-bg);
38
+ margin: 0 0 var(--block-spacing-y);
39
+ }
40
+ .content-grid main .table-scroll > table {
41
+ width: fit-content;
42
+ min-width: 100%;
43
+ max-width: none;
44
+ }
45
+ /* Vertical dividers between columns (no outer right border) */
46
+ .content-grid main .table-scroll > table th,
47
+ .content-grid main .table-scroll > table td {
48
+ border-right: 1px solid var(--border-color);
49
+ }
50
+ .content-grid main .table-scroll > table th:last-child,
51
+ .content-grid main .table-scroll > table td:last-child {
52
+ border-right: none;
53
+ }
54
+ .content-grid main .table-scroll > table thead th:first-child {
55
+ border-top-left-radius: var(--table-border-radius);
56
+ }
57
+ .content-grid main .table-scroll > table thead th:last-child {
58
+ border-top-right-radius: var(--table-border-radius);
59
+ }
60
+ .content-grid main .table-scroll > table tbody tr:last-child td:first-child {
61
+ border-bottom-left-radius: var(--table-border-radius);
62
+ }
63
+ .content-grid main .table-scroll > table tbody tr:last-child td:last-child {
64
+ border-bottom-right-radius: var(--table-border-radius);
65
+ }
66
+ /* Zebra striping for odd rows */
67
+ .content-grid main .table-scroll > table tbody tr:nth-child(odd) td {
68
+ background: var(--table-row-odd-bg);
69
+ }
70
+ /* Remove bottom border on last row */
71
+ .content-grid main .table-scroll > table tbody tr:last-child td {
72
+ border-bottom: none;
73
+ }
74
+
75
+ /* Accordion context: remove outer borders/radius and fit content flush */
76
+ .accordion .accordion__content .table-scroll {
77
+ border: none;
78
+ border-radius: 0;
79
+ margin: 0;
80
+ }
81
+ .accordion .accordion__content .table-scroll > table thead th:first-child,
82
+ .accordion .accordion__content .table-scroll > table thead th:last-child,
83
+ .accordion .accordion__content .table-scroll > table tbody tr:last-child td:first-child,
84
+ .accordion .accordion__content .table-scroll > table tbody tr:last-child td:last-child {
85
+ border-radius: 0;
86
+ }
87
+
88
+ /* Fallback for browsers without fit-content support */
89
+ @supports not (width: fit-content) {
90
+ .content-grid main .table-scroll > table {
91
+ width: max-content;
92
+ min-width: 100%;
93
+ }
94
+ }
95
+
app/src/styles/components/_tag.css ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 16px; }
2
+
3
+ .tag {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: 6px;
7
+ padding: 10px 16px;
8
+ font-size: 12px;
9
+ line-height: 1;
10
+ border-radius: 100px;
11
+ background: var(--surface-bg);
12
+ border: 1px solid var(--border-color);
13
+ color: var(--text-color);
14
+ }
app/src/styles/global.css CHANGED
@@ -1,56 +1,27 @@
1
  @import './_variables.css';
 
2
  @import './_base.css';
3
  @import './_layout.css';
4
- @import './components/_footer.css';
5
  @import './components/_code.css';
6
- @import './components/_poltly.css';
 
 
7
 
8
- /* Dark-mode form tweak */
9
- [data-theme="dark"] .plotly_input_container > select { background-color: #1a1f27; border-color: var(--border-color); color: var(--text-color); }
10
-
11
- [data-theme="dark"] .html-embed__card:not(.is-frameless) { background: #12151b; border-color: rgba(255,255,255,.15); }
12
- [data-theme="dark"] .right-aside .aside-card { background: #12151b; border-color: rgba(255,255,255,.15); }
13
- [data-theme="dark"] .content-grid main pre { background: #12151b; border-color: rgba(255,255,255,.15); }
14
- [data-theme="dark"] .toc nav { border-left-color: rgba(255,255,255,.15); }
15
- [data-theme="dark"] .distill-footer { border-top-color: rgba(255,255,255,.15); color: rgba(200,200,200,.8); }
16
- [data-theme="dark"] .citation { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,.15); color: rgba(200,200,200,1); }
17
- [data-theme="dark"] .citation a { color: rgba(255,255,255,0.75); }
18
-
19
- /* Opt-in zoomable images */
20
- img[data-zoomable] { cursor: zoom-in; }
21
- .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
22
-
23
- /* Download button for img[data-downloadable] */
24
- figure.has-dl-btn { position: relative; }
25
- .dl-host { position: relative; }
26
- .img-dl-wrap { position: relative; display: inline-block; }
27
- .img-dl-btn {
28
- position: absolute;
29
- right: 8px;
30
- bottom: 8px;
31
- align-items: center;
32
- justify-content: center;
33
- width: 30px;
34
- height: 30px;
35
- border-radius: 6px;
36
- color: white;
37
- text-decoration: none;
38
- border: 1px solid rgba(255,255,255,0.25);
39
- z-index: var(--z-elevated);
40
- display: none;
41
  }
42
- .img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
43
- .img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
44
- [data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
45
- [data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
46
-
47
- /* ============================================================================ */
48
- /* Theme Toggle button (moved from component) */
49
- /* ============================================================================ */
50
- #theme-toggle { display: inline-flex; align-items: center; gap: 8px; border: none; background: transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; margin: 12px 16px; color: var(--text-color) !important; }
51
- #theme-toggle .icon.dark { display: none; }
52
- [data-theme="dark"] #theme-toggle .icon.light { display: none; }
53
- [data-theme="dark"] #theme-toggle .icon.dark { display: inline; }
54
- #theme-toggle .icon { filter: none !important; }
55
-
56
 
 
 
 
 
 
1
  @import './_variables.css';
2
+ @import './_reset.css';
3
  @import './_base.css';
4
  @import './_layout.css';
5
+ @import './_print.css';
6
  @import './components/_code.css';
7
+ @import './components/_button.css';
8
+ @import './components/_table.css';
9
+ @import './components/_tag.css';
10
 
11
+ .demo-wide,
12
+ .demo-full-width {
13
+ display: grid;
14
+ place-items: center;
15
+ min-height: 150px;
16
+ color: var(--muted-color);
17
+ font-size: 12px;
18
+ border: 1px dashed var(--border-color);
19
+ border-radius: 8px;
20
+ background: var(--surface-bg);
21
+ margin-bottom: var(--block-spacing-y);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ .mermaid {
25
+ background: none!important;
26
+ margin-bottom: var(--block-spacing-y) !important;
27
+ }