tfrere HF Staff commited on
Commit
a189875
·
1 Parent(s): c7f2975

add banner experiments, improve notion import

Browse files
Dockerfile CHANGED
@@ -52,6 +52,9 @@ RUN npm run build
52
  # Generate the PDF (light theme, full wait)
53
  RUN npm run export:pdf -- --theme=light --wait=full
54
 
 
 
 
55
  # Install nginx in the build stage (we'll use this image as final to keep Node.js)
56
  RUN apt-get update && apt-get install -y nginx && apt-get clean && rm -rf /var/lib/apt/lists/*
57
 
 
52
  # Generate the PDF (light theme, full wait)
53
  RUN npm run export:pdf -- --theme=light --wait=full
54
 
55
+ # Generate LaTeX export
56
+ RUN npm run export:latex
57
+
58
  # Install nginx in the build stage (we'll use this image as final to keep Node.js)
59
  RUN apt-get update && apt-get install -y nginx && apt-get clean && rm -rf /var/lib/apt/lists/*
60
 
app/src/components/Footer.astro CHANGED
@@ -7,6 +7,7 @@ interface Props {
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">
@@ -17,39 +18,68 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
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>
 
 
 
 
 
 
 
 
 
35
  </div>
36
  </footer>
37
 
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');
45
  if (!target) return;
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) {
52
- const h = document.createElement('h3');
53
  h.textContent = text;
54
  target.appendChild(h);
55
  }
@@ -58,11 +88,17 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
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) {
63
- const txt = (firstHeading.textContent || '').trim().toLowerCase();
64
  const targetTxt = headingText.trim().toLowerCase();
65
- if (txt === targetTxt || txt.includes('reference') || txt.includes('bibliograph')) {
 
 
 
 
66
  firstHeading.remove();
67
  }
68
  }
@@ -79,11 +115,15 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
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;
88
  };
89
 
@@ -91,8 +131,8 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
91
  const done = run();
92
  if (!done) {
93
  const onReady = () => run();
94
- if (document.readyState === 'loading') {
95
- document.addEventListener('DOMContentLoaded', onReady, { once: true });
96
  } else {
97
  setTimeout(onReady, 0);
98
  }
@@ -103,7 +143,6 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
103
  })();
104
  </script>
105
 
106
-
107
  <style is:global>
108
  .footer {
109
  contain: layout style;
@@ -151,7 +190,6 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
151
  grid-column: 2;
152
  }
153
 
154
-
155
  .citation-block h3 {
156
  margin: 0 0 8px;
157
  }
@@ -174,12 +212,13 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
174
 
175
  /* Distill-like appendix citation styling */
176
  .citation {
177
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
 
178
  font-size: 11px;
179
  line-height: 15px;
180
  border-left: 1px solid rgba(0, 0, 0, 0.1);
181
  padding-left: 18px;
182
- border: 1px solid rgba(0,0,0,0.1);
183
  background: rgba(0, 0, 0, 0.02);
184
  padding: 10px 18px;
185
  border-radius: 3px;
@@ -223,10 +262,18 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
223
  color: var(--text-color);
224
  }
225
 
226
- [data-theme="dark"] .footer { border-top-color: rgba(255,255,255,.15); color: rgba(200,200,200,.8); }
227
- [data-theme="dark"] .citation { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,.15); color: rgba(200,200,200,1); }
228
- [data-theme="dark"] .citation a { color: rgba(255,255,255,0.75); }
229
-
 
 
 
 
 
 
 
 
230
 
231
  /* Footer links: use primary color consistently */
232
  .footer a {
@@ -241,4 +288,40 @@ const { citationText, bibtex, licence, doi } = Astro.props as Props;
241
  [data-theme="dark"] .footer a {
242
  color: var(--primary-color);
243
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  </style>
 
7
  }
8
  const { citationText, bibtex, licence, doi } = Astro.props as Props;
9
  ---
10
+
11
  <footer class="footer">
12
  <div class="footer-inner">
13
  <section class="citation-block">
 
18
  <p>BibTeX citation</p>
19
  <pre class="citation long">{bibtex}</pre>
20
  </section>
21
+ {
22
+ doi && (
23
+ <section class="doi-block">
24
+ <h3>DOI</h3>
25
+ <p>
26
+ <a
27
+ href={`https://doi.org/${doi}`}
28
+ target="_blank"
29
+ rel="noopener noreferrer"
30
+ >
31
+ {doi}
32
+ </a>
33
+ </p>
34
+ </section>
35
+ )
36
+ }
37
+ {
38
+ licence && (
39
+ <section class="reuse-block">
40
+ <h3>Reuse</h3>
41
+ <p set:html={licence} />
42
+ </section>
43
+ )
44
+ }
45
  <section class="references-block">
46
  <slot />
47
  </section>
48
+ <div class="template-credit">
49
+ <p>
50
+ made with love with <a
51
+ href="https://huggingface.co/spaces/tfrere/research-article-template"
52
+ target="_blank"
53
+ rel="noopener noreferrer">research article template</a
54
+ >
55
+ </p>
56
+ </div>
57
  </div>
58
  </footer>
59
 
 
60
  <script is:inline>
61
  (() => {
62
+ const getFooter = () =>
63
+ document.currentScript?.closest("footer") ||
64
+ document.querySelector("footer.footer");
65
  const footer = getFooter();
66
  if (!footer) return;
67
+ const target = footer.querySelector(".references-block");
68
  if (!target) return;
69
 
70
+ const contentRoot =
71
+ document.querySelector("section.content-grid main") ||
72
+ document.querySelector("main") ||
73
+ document.body;
74
 
75
  const ensureHeading = (text) => {
76
+ const exists = Array.from(target.children).some(
77
+ (c) =>
78
+ c.tagName === "H3" &&
79
+ c.textContent.trim().toLowerCase() === text.toLowerCase(),
80
+ );
81
  if (!exists) {
82
+ const h = document.createElement("h3");
83
  h.textContent = text;
84
  target.appendChild(h);
85
  }
 
88
  const moveIntoFooter = (element, headingText) => {
89
  if (!element) return false;
90
  // Remove an eventual heading already included inside the block (avoid duplicates)
91
+ const firstHeading = element.querySelector(
92
+ ":scope > h1, :scope > h2, :scope > h3",
93
+ );
94
  if (firstHeading) {
95
+ const txt = (firstHeading.textContent || "").trim().toLowerCase();
96
  const targetTxt = headingText.trim().toLowerCase();
97
+ if (
98
+ txt === targetTxt ||
99
+ txt.includes("reference") ||
100
+ txt.includes("bibliograph")
101
+ ) {
102
  firstHeading.remove();
103
  }
104
  }
 
115
  return null;
116
  };
117
 
118
+ const referencesEl = findFirstOutsideFooter([
119
+ "#references",
120
+ ".references",
121
+ ".bibliography",
122
+ ]);
123
+ const footnotesEl = findFirstOutsideFooter([".footnotes"]);
124
 
125
+ const movedRefs = moveIntoFooter(referencesEl, "References");
126
+ const movedNotes = moveIntoFooter(footnotesEl, "Footnotes");
127
  return movedRefs || movedNotes;
128
  };
129
 
 
131
  const done = run();
132
  if (!done) {
133
  const onReady = () => run();
134
+ if (document.readyState === "loading") {
135
+ document.addEventListener("DOMContentLoaded", onReady, { once: true });
136
  } else {
137
  setTimeout(onReady, 0);
138
  }
 
143
  })();
144
  </script>
145
 
 
146
  <style is:global>
147
  .footer {
148
  contain: layout style;
 
190
  grid-column: 2;
191
  }
192
 
 
193
  .citation-block h3 {
194
  margin: 0 0 8px;
195
  }
 
212
 
213
  /* Distill-like appendix citation styling */
214
  .citation {
215
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
216
+ "Liberation Mono", "Courier New", monospace;
217
  font-size: 11px;
218
  line-height: 15px;
219
  border-left: 1px solid rgba(0, 0, 0, 0.1);
220
  padding-left: 18px;
221
+ border: 1px solid rgba(0, 0, 0, 0.1);
222
  background: rgba(0, 0, 0, 0.02);
223
  padding: 10px 18px;
224
  border-radius: 3px;
 
262
  color: var(--text-color);
263
  }
264
 
265
+ [data-theme="dark"] .footer {
266
+ border-top-color: rgba(255, 255, 255, 0.15);
267
+ color: rgba(200, 200, 200, 0.8);
268
+ }
269
+ [data-theme="dark"] .citation {
270
+ background: rgba(255, 255, 255, 0.04);
271
+ border-color: rgba(255, 255, 255, 0.15);
272
+ color: rgba(200, 200, 200, 1);
273
+ }
274
+ [data-theme="dark"] .citation a {
275
+ color: rgba(255, 255, 255, 0.75);
276
+ }
277
 
278
  /* Footer links: use primary color consistently */
279
  .footer a {
 
288
  [data-theme="dark"] .footer a {
289
  color: var(--primary-color);
290
  }
291
+
292
+ /* Template credit - discrete at the bottom */
293
+ .template-credit {
294
+ display: contents;
295
+ }
296
+
297
+ .template-credit p {
298
+ grid-column: 2;
299
+ margin: 24px 0 0 0;
300
+ font-size: 0.85em;
301
+ color: rgba(0, 0, 0, 0.5);
302
+ }
303
+
304
+ .template-credit a {
305
+ color: rgba(0, 0, 0, 0.6);
306
+ border-bottom: 1px solid rgba(0, 0, 0, 0.15);
307
+ }
308
+
309
+ .template-credit a:hover {
310
+ color: rgba(0, 0, 0, 0.8);
311
+ border-bottom-color: rgba(0, 0, 0, 0.3);
312
+ }
313
+
314
+ [data-theme="dark"] .template-credit p {
315
+ color: rgba(200, 200, 200, 0.6);
316
+ }
317
+
318
+ [data-theme="dark"] .template-credit a {
319
+ color: rgba(200, 200, 200, 0.7);
320
+ border-bottom-color: rgba(255, 255, 255, 0.2);
321
+ }
322
+
323
+ [data-theme="dark"] .template-credit a:hover {
324
+ color: rgba(200, 200, 200, 0.9);
325
+ border-bottom-color: rgba(255, 255, 255, 0.35);
326
+ }
327
  </style>
app/src/components/Hero.astro CHANGED
@@ -37,6 +37,7 @@ function normalizeAuthors(
37
  url?: string;
38
  link?: string;
39
  affiliationIndices?: number[];
 
40
  }
41
  >,
42
  ): Author[] {
@@ -47,8 +48,11 @@ function normalizeAuthors(
47
  }
48
  const name = (a?.name ?? "").toString();
49
  const url = (a?.url ?? a?.link) as string | undefined;
 
50
  const affiliationIndices = Array.isArray((a as any)?.affiliationIndices)
51
  ? (a as any).affiliationIndices
 
 
52
  : undefined;
53
  return { name, url, affiliationIndices } as Author;
54
  })
@@ -69,9 +73,9 @@ for (const author of normalizedAuthors) {
69
  }
70
  }
71
  }
72
- const shouldShowAffiliationSupers = authorAffiliationIndexSet.size > 1;
73
  const hasMultipleAffiliations =
74
  Array.isArray(affiliations) && affiliations.length > 1;
 
75
 
76
  function stripHtml(text: string): string {
77
  return String(text || "").replace(/<[^>]*>/g, "");
@@ -404,7 +408,8 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
404
  display: flex;
405
  flex-direction: column;
406
  gap: 8px;
407
- max-width: 250px;
 
408
  }
409
  .meta-container-cell h3 {
410
  margin: 0;
 
37
  url?: string;
38
  link?: string;
39
  affiliationIndices?: number[];
40
+ affiliations?: number[];
41
  }
42
  >,
43
  ): Author[] {
 
48
  }
49
  const name = (a?.name ?? "").toString();
50
  const url = (a?.url ?? a?.link) as string | undefined;
51
+ // Support both 'affiliationIndices' and 'affiliations' as property names
52
  const affiliationIndices = Array.isArray((a as any)?.affiliationIndices)
53
  ? (a as any).affiliationIndices
54
+ : Array.isArray((a as any)?.affiliations)
55
+ ? (a as any).affiliations
56
  : undefined;
57
  return { name, url, affiliationIndices } as Author;
58
  })
 
73
  }
74
  }
75
  }
 
76
  const hasMultipleAffiliations =
77
  Array.isArray(affiliations) && affiliations.length > 1;
78
+ const shouldShowAffiliationSupers = hasMultipleAffiliations && authorAffiliationIndexSet.size > 0;
79
 
80
  function stripHtml(text: string): string {
81
  return String(text || "").replace(/<[^>]*>/g, "");
 
408
  display: flex;
409
  flex-direction: column;
410
  gap: 8px;
411
+ flex: 1;
412
+ min-width: 0;
413
  }
414
  .meta-container-cell h3 {
415
  margin: 0;
app/src/content/article.mdx CHANGED
@@ -1,18 +1,56 @@
1
  ---
2
- title: "Loading from Notion..."
3
- description: "This content will be replaced at container startup"
4
- published: "2025"
5
  authors:
6
- - name: "Author"
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  tableOfContentsAutoCollapse: true
 
8
  ---
9
 
10
- ## Loading
 
 
 
 
 
 
 
 
 
 
11
 
12
- This article is being imported from Notion. Please wait a moment...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- If you're seeing this, the Notion import is either:
15
- - Still in progress (first startup takes ~5-10 minutes)
16
- - Disabled (set ENABLE_NOTION_IMPORT=true)
17
- - Failed (check container logs)
18
 
 
1
  ---
2
+ title: "Bringing paper to life:\n A modern template for\n scientific writing"
3
+ subtitle: "Publish‑ready workflow that lets you focus on ideas, not infrastructure"
4
+ description: "Publish‑ready workflow that lets you focus on ideas, not infrastructure"
5
  authors:
6
+ - name: "Thibaud Frere"
7
+ url: "https://huggingface.co/tfrere"
8
+ affiliations: [1]
9
+ affiliations:
10
+ - name: "Hugging Face"
11
+ url: "https://huggingface.co"
12
+ published: "Sep. 01, 2025"
13
+ doi: 10.1234/abcd.efgh
14
+ licence: >
15
+ 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.
16
+ Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
17
+ tags:
18
+ - research
19
+ - template
20
  tableOfContentsAutoCollapse: true
21
+ pdfProOnly: false
22
  ---
23
 
24
+ import Introduction from "./chapters/demo/introduction.mdx";
25
+ import BuiltWithThis from "./chapters/demo/built-with-this.mdx";
26
+ import BestPractices from "./chapters/demo/best-pratices.mdx";
27
+ import WritingYourContent from "./chapters/demo/writing-your-content.mdx";
28
+ import AvailableBlocks from "./chapters/demo/markdown.mdx";
29
+ import GettingStarted from "./chapters/demo/getting-started.mdx";
30
+ import Markdown from "./chapters/demo/markdown.mdx";
31
+ import Components from "./chapters/demo/components.mdx";
32
+ import Greetings from "./chapters/demo/greetings.mdx";
33
+ import VibeCodingCharts from "./chapters/demo/vibe-coding-charts.mdx";
34
+ import ImportContent from "./chapters/demo/import-content.mdx";
35
 
36
+ <Introduction />
37
+
38
+ <BuiltWithThis />
39
+
40
+ <GettingStarted />
41
+
42
+ <WritingYourContent />
43
+
44
+ <Markdown />
45
+
46
+ <Components />
47
+
48
+ <VibeCodingCharts />
49
+
50
+ <ImportContent />
51
+
52
+ <BestPractices />
53
+
54
+ <Greetings />
55
 
 
 
 
 
56
 
app/src/content/assets/image/finevision.png ADDED

Git LFS Details

  • SHA256: e6c02ff75943442df6667b162c406c11acb5cacf219c09ab6e60774125e5fb8b
  • Pointer size: 131 Bytes
  • Size of remote file: 258 kB
app/src/content/assets/image/maintain-the-unmaintainable.png ADDED

Git LFS Details

  • SHA256: 52db98b81d3399e673679d415498cd585915955223f296b8bc49b28095542b0f
  • Pointer size: 131 Bytes
  • Size of remote file: 218 kB
app/src/content/assets/image/smoll-training-guide.png ADDED

Git LFS Details

  • SHA256: 9338752555b50cbf5f96f5a4579250d7d09ea6a0781fc7aa0b402eb41f542105
  • Pointer size: 130 Bytes
  • Size of remote file: 78.9 kB
app/src/content/bibliography.bib CHANGED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @inproceedings{vaswani2017attention,
2
+ title = {Attention Is All You Need},
3
+ author = {Vaswani, Ashish and Shazeer, Noam and Parmar, Niki and Uszkoreit, Jakob and Jones, Llion and Gomez, Aidan N and Kaiser, {
4
+ }Lukasz and Polosukhin, Illia},
5
+ booktitle = {Advances in Neural Information Processing Systems},
6
+ year = {2017}
7
+ }
8
+
9
+ @book{mckinney2017python,
10
+ title = {Python for Data Analysis},
11
+ author = {McKinney, Wes},
12
+ publisher = {O'Reilly Media},
13
+ address = {Sebastopol, CA},
14
+ year = {2017},
15
+ edition = {2},
16
+ isbn = {978-1491957660}
17
+ }
18
+
19
+ @inproceedings{he2016resnet,
20
+ title = {Deep Residual Learning for Image Recognition},
21
+ author = {He, Kaiming and Zhang, Xiangyu and Ren, Shaoqing and Sun, Jian},
22
+ booktitle = {Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition (CVPR)},
23
+ pages = {770--778},
24
+ year = {2016},
25
+ doi = {10.1109/CVPR.2016.90},
26
+ url = {https://doi.org/10.1109/CVPR.2016.90}
27
+ }
28
+
29
+ @article{silver2017mastering,
30
+ title = {Mastering the game of Go without human knowledge},
31
+ author = {Silver, David and Schrittwieser, Julian and Simonyan, Karen and Antonoglou, Ioannis and Huang, Aja and others},
32
+ journal = {Nature},
33
+ volume = {550},
34
+ number = {7676},
35
+ pages = {354--359},
36
+ year = {2017},
37
+ month = {oct},
38
+ doi = {10.1038/nature24270},
39
+ url = {https://www.nature.com/articles/nature24270}
40
+ }
41
+
42
+ @techreport{openai2023gpt4,
43
+ title = {GPT-4 Technical Report},
44
+ author = {{OpenAI}},
45
+ institution = {OpenAI},
46
+ year = {2023},
47
+ number = {arXiv:2303.08774},
48
+ archiveprefix = {arXiv},
49
+ eprint = {2303.08774},
50
+ primaryclass = {cs.CL},
51
+ url = {https://arxiv.org/abs/2303.08774}
52
+ }
53
+
54
+ @phdthesis{doe2020thesis,
55
+ title = {Learning Efficient Representations for Large-Scale Visual Recognition},
56
+ author = {Doe, Jane},
57
+ school = {Massachusetts Institute of Technology},
58
+ address = {Cambridge, MA},
59
+ year = {2020},
60
+ doi = {10.5555/mit-2020-xyz}
61
+ }
62
+
63
+ @incollection{cover2006entropy,
64
+ title = {Entropy, Relative Entropy, and Mutual Information},
65
+ author = {Cover, Thomas M. and Thomas, Joy A.},
66
+ booktitle = {Elements of Information Theory},
67
+ publisher = {Wiley},
68
+ address = {Hoboken, NJ},
69
+ edition = {2},
70
+ year = {2006},
71
+ pages = {13--55},
72
+ isbn = {978-0471241959}
73
+ }
74
+
75
+ @misc{zenodo2021dataset,
76
+ title = {ImageNet-21K Subset (Version 2.0)},
77
+ author = {Smith, John and Lee, Alice and Kumar, Ravi},
78
+ year = {2021},
79
+ howpublished = {Dataset on Zenodo},
80
+ doi = {10.5281/zenodo.1234567},
81
+ url = {https://doi.org/10.5281/zenodo.1234567},
82
+ note = {Accessed 2025-09-01}
83
+ }
84
+
85
+ @misc{sklearn2024,
86
+ title = {scikit-learn: Machine Learning in Python (Version 1.4)},
87
+ author = {Pedregosa, Fabian and Varoquaux, Ga{"e}l and Gramfort, Alexandre and others},
88
+ year = {2024},
89
+ howpublished = {Software},
90
+ doi = {10.5281/zenodo.592264},
91
+ url = {https://scikit-learn.org}
92
+ }
93
+
94
+ @inproceedings{smith2024privacy,
95
+ title = {Privacy-Preserving Training with Low-Precision Secure Aggregation},
96
+ author = {Smith, Emily and Zhang, Wei and Rossi, Marco and Patel, Neha},
97
+ booktitle = {Proceedings of the 41st International Conference on Machine Learning},
98
+ editor = {Smith, A. and Johnson, B.},
99
+ series = {Proceedings of Machine Learning Research},
100
+ volume = {235},
101
+ pages = {12345--12367},
102
+ address = {Vienna, Austria},
103
+ publisher = {PMLR},
104
+ month = {jul},
105
+ year = {2024},
106
+ url = {https://proceedings.mlr.press/v235/}
107
+ }
108
+
109
+ @article{kingma2015adam,
110
+ title = {Adam: A Method for Stochastic Optimization},
111
+ author = {Kingma, Diederik P. and Ba, Jimmy},
112
+ journal = {International Conference on Learning Representations (ICLR)},
113
+ year = {2015},
114
+ archiveprefix = {arXiv},
115
+ eprint = {1412.6980},
116
+ primaryclass = {cs.LG},
117
+ url = {https://arxiv.org/abs/1412.6980}
118
+ }
119
+
120
+ @misc{raffel2020t5,
121
+ title = {Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer},
122
+ author = {Raffel, Colin and Shazeer, Noam and Roberts, Adam and Lee, Katherine and Narang, Sharan and others},
123
+ year = {2020},
124
+ howpublished = {arXiv preprint},
125
+ archiveprefix = {arXiv},
126
+ eprint = {1910.10683},
127
+ primaryclass = {cs.LG},
128
+ doi = {10.48550/arXiv.1910.10683},
129
+ url = {https://arxiv.org/abs/1910.10683}
130
+ }
app/src/content/chapters/demo/built-with-this.mdx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Image from "../../../components/Image.astro";
2
+ import Stack from "../../../components/Stack.astro";
3
+ import maintainUnmaintainable from "../../assets/image/maintain-the-unmaintainable.png";
4
+ import finevision from "../../assets/image/finevision.png";
5
+ import smolTrainingGuide from "../../assets/image/smoll-training-guide.png";
6
+
7
+ export const title = "Built with this";
8
+
9
+ ### Built with this template
10
+
11
+ You can see how the template is used in the following examples.
12
+
13
+ <Stack direction="horizontal" gap="medium" layout="2-column" >
14
+ <a href="https://huggingface.co/spaces/transformers-community/Transformers-tenets" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
15
+ <Image
16
+ src={maintainUnmaintainable}
17
+ alt="Maintain the unmaintainable: 1M python loc, 400+ models"
18
+ />
19
+ <div class="card-title-container">
20
+ <h3 class="card-title">Maintain the unmaintainable: 1M python loc, 400+ models</h3>
21
+ <p class="card-subtitle">A peek into software engineering for the transformers library</p>
22
+ </div>
23
+ </a>
24
+
25
+ <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
26
+ <Image
27
+ src={finevision}
28
+ alt="FineVision: Open Data Is All You Need"
29
+ />
30
+ <div class="card-title-container">
31
+ <h3 class="card-title">FineVision: Open Data Is All You Need</h3>
32
+ <p class="card-subtitle">A new open dataset for data-centric training of Vision Language Models</p>
33
+ </div>
34
+ </a>
35
+
36
+ <a href="https://placeholder-url.com" target="_blank" rel="noopener noreferrer" class="card no-padding" style="flex: 1; min-width: 0; overflow: hidden;">
37
+ <Image
38
+ src={smolTrainingGuide}
39
+ alt="The Smol Training Guide: The Secrets to Building World-Class LLMs"
40
+ />
41
+ <div class="card-title-container">
42
+ <h3 class="card-title">The Smol Training Guide: The Secrets to Building World-Class LLMs</h3>
43
+ <p class="card-subtitle">A practical journey through the challenges, decisions, and messy reality behind training state-of-the-art language models</p>
44
+ </div>
45
+ </a>
46
+
47
+ <div class="card card-cta">
48
+ <div class="card-cta-content">
49
+ <h3>Your article?</h3>
50
+ <p>Build your research paper with this template</p>
51
+ </div>
52
+ </div>
53
+ </Stack>
app/src/content/chapters/demo/introduction.mdx CHANGED
@@ -58,13 +58,13 @@ This is not a CMS or a multi‑page blog—it's a **focused**, **single‑page**
58
 
59
  This project stands in the direct continuity of [Distill](https://distill.pub/) (2016–2021). Our goal is to carry that spirit forward and push it even further: **accessible scientific writing**, **high‑quality interactive explanations**, and **reproducible**, production‑ready demos.
60
 
61
- To give you a sense of what inspired this template, here is a short, curated list of **well‑designed** and often **interactive** works from Distill:
62
 
63
  - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
64
  - [Activation Atlas](https://distill.pub/2019/activation-atlas/)
65
  - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
66
- - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
67
 
68
- <Sidenote>
69
  I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
70
- </Sidenote>
 
58
 
59
  This project stands in the direct continuity of [Distill](https://distill.pub/) (2016–2021). Our goal is to carry that spirit forward and push it even further: **accessible scientific writing**, **high‑quality interactive explanations**, and **reproducible**, production‑ready demos.
60
 
61
+ {/* To give you a sense of what inspired this template, here is a short, curated list of **well‑designed** and often **interactive** works from Distill:
62
 
63
  - [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
64
  - [Activation Atlas](https://distill.pub/2019/activation-atlas/)
65
  - [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
66
+ - [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/) */}
67
 
68
+ {/* <Sidenote>
69
  I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
70
+ </Sidenote> */}
app/src/content/embeds/banner-molecules.html ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="molecular-space"
2
+ style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;position:relative;overflow:hidden;"></div>
3
+ <script>
4
+ (() => {
5
+ const ensureAnime = (cb) => {
6
+ if (window.anime && typeof window.anime === 'function') return cb();
7
+ let s = document.getElementById('anime-cdn-script');
8
+ if (!s) {
9
+ s = document.createElement('script');
10
+ s.id = 'anime-cdn-script';
11
+ s.src = 'https://cdn.jsdelivr.net/npm/animejs@3.2.1/lib/anime.min.js';
12
+ document.head.appendChild(s);
13
+ }
14
+ const onReady = () => { if (window.anime && typeof window.anime === 'function') cb(); };
15
+ s.addEventListener('load', onReady, { once: true });
16
+ if (window.anime) onReady();
17
+ };
18
+
19
+ const bootstrap = () => {
20
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
21
+ const container = (mount && mount.querySelector && mount.querySelector('.molecular-space')) || document.querySelector('.molecular-space');
22
+ if (!container) return;
23
+ if (container.dataset) {
24
+ if (container.dataset.mounted === 'true') return;
25
+ container.dataset.mounted = 'true';
26
+ }
27
+
28
+ const canvas = document.createElement('canvas');
29
+ canvas.style.display = 'block';
30
+ canvas.style.width = '100%';
31
+ canvas.style.height = '100%';
32
+ container.appendChild(canvas);
33
+ const ctx = canvas.getContext('2d');
34
+
35
+ const getColors = () => {
36
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
37
+ return {
38
+ atoms: {
39
+ C: isDark ? 'rgba(144, 144, 144, 0.9)' : 'rgba(90, 90, 90, 0.85)',
40
+ N: isDark ? 'rgba(78, 165, 183, 0.9)' : 'rgba(50, 130, 160, 0.85)',
41
+ O: isDark ? 'rgba(232, 137, 171, 0.9)' : 'rgba(220, 80, 130, 0.85)',
42
+ H: isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(240, 240, 240, 0.75)',
43
+ P: isDark ? 'rgba(206, 192, 250, 0.9)' : 'rgba(138, 100, 220, 0.85)',
44
+ S: isDark ? 'rgba(255, 200, 50, 0.9)' : 'rgba(230, 180, 30, 0.85)',
45
+ },
46
+ bond: isDark ? 'rgba(206, 192, 250, 0.3)' : 'rgba(138, 100, 220, 0.35)',
47
+ glow: isDark ? 'rgba(206, 192, 250, 0.3)' : 'rgba(138, 100, 220, 0.25)',
48
+ };
49
+ };
50
+
51
+ let colors = getColors();
52
+
53
+ const observer = new MutationObserver(() => {
54
+ colors = getColors();
55
+ });
56
+ observer.observe(document.documentElement, {
57
+ attributes: true,
58
+ attributeFilter: ['data-theme']
59
+ });
60
+
61
+ let atoms = [];
62
+ let bonds = [];
63
+ let molecules = [];
64
+ let width, height;
65
+
66
+ const atomSizes = { H: 6, C: 12, N: 11, O: 11, P: 13, S: 13 };
67
+
68
+ // Définitions de vraies molécules
69
+ const moleculeTemplates = {
70
+ water: {
71
+ name: "H₂O",
72
+ atoms: [
73
+ { type: 'O', pos: [0, 0] },
74
+ { type: 'H', pos: [-15, -12] },
75
+ { type: 'H', pos: [15, -12] }
76
+ ],
77
+ bonds: [[0, 1, 1], [0, 2, 1]]
78
+ },
79
+
80
+ co2: {
81
+ name: "CO₂",
82
+ atoms: [
83
+ { type: 'C', pos: [0, 0] },
84
+ { type: 'O', pos: [-25, 0] },
85
+ { type: 'O', pos: [25, 0] }
86
+ ],
87
+ bonds: [[0, 1, 2], [0, 2, 2]]
88
+ },
89
+
90
+ methane: {
91
+ name: "CH₄",
92
+ atoms: [
93
+ { type: 'C', pos: [0, 0] },
94
+ { type: 'H', pos: [0, -18] },
95
+ { type: 'H', pos: [17, 9] },
96
+ { type: 'H', pos: [-17, 9] },
97
+ { type: 'H', pos: [0, 12] }
98
+ ],
99
+ bonds: [[0, 1, 1], [0, 2, 1], [0, 3, 1], [0, 4, 1]]
100
+ },
101
+
102
+ ammonia: {
103
+ name: "NH₃",
104
+ atoms: [
105
+ { type: 'N', pos: [0, 0] },
106
+ { type: 'H', pos: [0, -16] },
107
+ { type: 'H', pos: [-14, 8] },
108
+ { type: 'H', pos: [14, 8] }
109
+ ],
110
+ bonds: [[0, 1, 1], [0, 2, 1], [0, 3, 1]]
111
+ },
112
+
113
+ benzene: {
114
+ name: "C₆H₆",
115
+ atoms: [
116
+ { type: 'C', pos: [20, 0] },
117
+ { type: 'C', pos: [10, 17] },
118
+ { type: 'C', pos: [-10, 17] },
119
+ { type: 'C', pos: [-20, 0] },
120
+ { type: 'C', pos: [-10, -17] },
121
+ { type: 'C', pos: [10, -17] },
122
+ { type: 'H', pos: [36, 0] },
123
+ { type: 'H', pos: [18, 31] },
124
+ { type: 'H', pos: [-18, 31] },
125
+ { type: 'H', pos: [-36, 0] },
126
+ { type: 'H', pos: [-18, -31] },
127
+ { type: 'H', pos: [18, -31] }
128
+ ],
129
+ bonds: [
130
+ [0, 1, 1], [1, 2, 2], [2, 3, 1],
131
+ [3, 4, 2], [4, 5, 1], [5, 0, 2],
132
+ [0, 6, 1], [1, 7, 1], [2, 8, 1],
133
+ [3, 9, 1], [4, 10, 1], [5, 11, 1]
134
+ ]
135
+ },
136
+
137
+ ethanol: {
138
+ name: "C₂H₅OH",
139
+ atoms: [
140
+ { type: 'C', pos: [-15, 0] },
141
+ { type: 'C', pos: [15, 0] },
142
+ { type: 'O', pos: [30, 15] },
143
+ { type: 'H', pos: [42, 20] },
144
+ { type: 'H', pos: [-25, -12] },
145
+ { type: 'H', pos: [-25, 12] },
146
+ { type: 'H', pos: [-5, 12] },
147
+ { type: 'H', pos: [25, -12] },
148
+ { type: 'H', pos: [5, -12] }
149
+ ],
150
+ bonds: [
151
+ [0, 1, 1], [1, 2, 1], [2, 3, 1],
152
+ [0, 4, 1], [0, 5, 1], [0, 6, 1],
153
+ [1, 7, 1], [1, 8, 1]
154
+ ]
155
+ },
156
+
157
+ acetone: {
158
+ name: "(CH₃)₂CO",
159
+ atoms: [
160
+ { type: 'C', pos: [0, 0] },
161
+ { type: 'O', pos: [0, -25] },
162
+ { type: 'C', pos: [-25, 10] },
163
+ { type: 'C', pos: [25, 10] },
164
+ { type: 'H', pos: [-35, 0] },
165
+ { type: 'H', pos: [-30, 22] },
166
+ { type: 'H', pos: [-15, 18] },
167
+ { type: 'H', pos: [35, 0] },
168
+ { type: 'H', pos: [30, 22] },
169
+ { type: 'H', pos: [15, 18] }
170
+ ],
171
+ bonds: [
172
+ [0, 1, 2], [0, 2, 1], [0, 3, 1],
173
+ [2, 4, 1], [2, 5, 1], [2, 6, 1],
174
+ [3, 7, 1], [3, 8, 1], [3, 9, 1]
175
+ ]
176
+ },
177
+
178
+ glucose: {
179
+ name: "C₆H₁₂O₆",
180
+ atoms: [
181
+ { type: 'C', pos: [0, -20] },
182
+ { type: 'C', pos: [20, -10] },
183
+ { type: 'C', pos: [20, 10] },
184
+ { type: 'C', pos: [0, 20] },
185
+ { type: 'C', pos: [-20, 10] },
186
+ { type: 'O', pos: [-20, -10] },
187
+ { type: 'O', pos: [0, -35] },
188
+ { type: 'H', pos: [10, -35] },
189
+ { type: 'O', pos: [35, -15] },
190
+ { type: 'H', pos: [45, -10] },
191
+ { type: 'O', pos: [35, 15] },
192
+ { type: 'H', pos: [45, 10] }
193
+ ],
194
+ bonds: [
195
+ [0, 1, 1], [1, 2, 1], [2, 3, 1],
196
+ [3, 4, 1], [4, 5, 1], [5, 0, 1],
197
+ [0, 6, 1], [6, 7, 1],
198
+ [1, 8, 1], [8, 9, 1],
199
+ [2, 10, 1], [10, 11, 1]
200
+ ]
201
+ }
202
+ };
203
+
204
+ const resize = () => {
205
+ width = container.clientWidth || 800;
206
+ height = Math.max(260, Math.round(width / 3));
207
+ canvas.width = width;
208
+ canvas.height = height;
209
+ initMolecules();
210
+ };
211
+
212
+ const initMolecules = () => {
213
+ atoms = [];
214
+ bonds = [];
215
+ molecules = [];
216
+
217
+ // Sélectionner 3-4 molécules aléatoires
218
+ const templateNames = Object.keys(moleculeTemplates);
219
+ const numMolecules = 3 + Math.floor(Math.random() * 2);
220
+ const selectedTemplates = [];
221
+
222
+ for (let i = 0; i < numMolecules; i++) {
223
+ const template = moleculeTemplates[templateNames[Math.floor(Math.random() * templateNames.length)]];
224
+ selectedTemplates.push(template);
225
+ }
226
+
227
+ selectedTemplates.forEach((template, idx) => {
228
+ createMoleculeFromTemplate(template, idx, numMolecules);
229
+ });
230
+
231
+ // Animations initiales
232
+ atoms.forEach((atom, i) => {
233
+ anime({
234
+ targets: atom,
235
+ scale: 1,
236
+ opacity: atom.targetOpacity,
237
+ duration: 800,
238
+ delay: i * 30,
239
+ easing: 'easeOutElastic(1, .6)'
240
+ });
241
+ });
242
+
243
+ bonds.forEach((bond, i) => {
244
+ anime({
245
+ targets: bond,
246
+ opacity: bond.targetOpacity,
247
+ duration: 600,
248
+ delay: 400 + i * 20,
249
+ easing: 'easeOutQuad'
250
+ });
251
+ });
252
+
253
+ // Cycle: changer de molécules
254
+ setInterval(() => {
255
+ changeMolecules();
256
+ }, 7000 + Math.random() * 3000);
257
+ };
258
+
259
+ const createMoleculeFromTemplate = (template, moleculeIdx, totalMolecules) => {
260
+ const marginX = width * 0.12;
261
+ const marginY = height * 0.25;
262
+ const centerX = marginX + (moleculeIdx / (totalMolecules - 1 || 1)) * (width - 2 * marginX);
263
+ const centerY = marginY + (Math.random() - 0.5) * (height - 2 * marginY) * 0.5 + height / 2;
264
+
265
+ const scale = 1 + Math.random() * 0.3;
266
+ const moleculeAtoms = [];
267
+
268
+ // Créer les atomes
269
+ template.atoms.forEach(atomDef => {
270
+ const atom = {
271
+ x: centerX + atomDef.pos[0] * scale,
272
+ y: centerY + atomDef.pos[1] * scale,
273
+ baseX: centerX + atomDef.pos[0] * scale,
274
+ baseY: centerY + atomDef.pos[1] * scale,
275
+ type: atomDef.type,
276
+ size: atomSizes[atomDef.type],
277
+ scale: 0,
278
+ opacity: 0,
279
+ targetOpacity: 0.85 + Math.random() * 0.15,
280
+ vibrationPhase: Math.random() * Math.PI * 2,
281
+ vibrationSpeed: 0.015 + Math.random() * 0.01,
282
+ vibrationAmplitude: 0.8 + Math.random() * 1.2,
283
+ moleculeIdx: moleculeIdx
284
+ };
285
+ atoms.push(atom);
286
+ moleculeAtoms.push(atom);
287
+ });
288
+
289
+ molecules.push({
290
+ atoms: moleculeAtoms,
291
+ centerX: centerX,
292
+ centerY: centerY,
293
+ rotation: 0,
294
+ rotationSpeed: (Math.random() - 0.5) * 0.004,
295
+ name: template.name
296
+ });
297
+
298
+ // Créer les liaisons
299
+ template.bonds.forEach(bondDef => {
300
+ bonds.push({
301
+ from: moleculeAtoms[bondDef[0]],
302
+ to: moleculeAtoms[bondDef[1]],
303
+ strength: bondDef[2],
304
+ opacity: 0,
305
+ targetOpacity: 0.5 + Math.random() * 0.2,
306
+ vibrationPhase: Math.random() * Math.PI * 2
307
+ });
308
+ });
309
+ };
310
+
311
+ const changeMolecules = () => {
312
+ // Dissoudre une molécule aléatoire
313
+ if (molecules.length === 0) return;
314
+
315
+ const moleculeToRemove = molecules[Math.floor(Math.random() * molecules.length)];
316
+
317
+ // Fade out atomes
318
+ moleculeToRemove.atoms.forEach(atom => {
319
+ anime({
320
+ targets: atom,
321
+ scale: 0,
322
+ opacity: 0,
323
+ duration: 600,
324
+ easing: 'easeInQuad',
325
+ complete: () => {
326
+ const idx = atoms.indexOf(atom);
327
+ if (idx > -1) atoms.splice(idx, 1);
328
+ }
329
+ });
330
+ });
331
+
332
+ // Fade out liaisons
333
+ const bondsToRemove = bonds.filter(b =>
334
+ moleculeToRemove.atoms.includes(b.from) || moleculeToRemove.atoms.includes(b.to)
335
+ );
336
+ bondsToRemove.forEach(bond => {
337
+ anime({
338
+ targets: bond,
339
+ opacity: 0,
340
+ duration: 400,
341
+ easing: 'easeInQuad',
342
+ complete: () => {
343
+ const idx = bonds.indexOf(bond);
344
+ if (idx > -1) bonds.splice(idx, 1);
345
+ }
346
+ });
347
+ });
348
+
349
+ molecules.splice(molecules.indexOf(moleculeToRemove), 1);
350
+
351
+ // Créer nouvelle molécule
352
+ setTimeout(() => {
353
+ const templateNames = Object.keys(moleculeTemplates);
354
+ const template = moleculeTemplates[templateNames[Math.floor(Math.random() * templateNames.length)]];
355
+ const newIdx = molecules.length;
356
+ createMoleculeFromTemplate(template, newIdx, molecules.length + 1);
357
+
358
+ // Animer la nouvelle molécule
359
+ const newMolecule = molecules[molecules.length - 1];
360
+ newMolecule.atoms.forEach((atom, i) => {
361
+ anime({
362
+ targets: atom,
363
+ scale: 1,
364
+ opacity: atom.targetOpacity,
365
+ duration: 800,
366
+ delay: i * 30,
367
+ easing: 'easeOutElastic(1, .6)'
368
+ });
369
+ });
370
+
371
+ const newBonds = bonds.filter(b => newMolecule.atoms.includes(b.from));
372
+ newBonds.forEach((bond, i) => {
373
+ anime({
374
+ targets: bond,
375
+ opacity: bond.targetOpacity,
376
+ duration: 600,
377
+ delay: i * 20,
378
+ easing: 'easeOutQuad'
379
+ });
380
+ });
381
+ }, 700);
382
+ };
383
+
384
+ const update = () => {
385
+ atoms.forEach(atom => {
386
+ atom.vibrationPhase += atom.vibrationSpeed;
387
+ const vx = Math.cos(atom.vibrationPhase) * atom.vibrationAmplitude;
388
+ const vy = Math.sin(atom.vibrationPhase * 1.3) * atom.vibrationAmplitude;
389
+ atom.x = atom.baseX + vx;
390
+ atom.y = atom.baseY + vy;
391
+ });
392
+
393
+ molecules.forEach(molecule => {
394
+ molecule.rotation += molecule.rotationSpeed;
395
+ molecule.atoms.forEach(atom => {
396
+ const dx = atom.baseX - molecule.centerX;
397
+ const dy = atom.baseY - molecule.centerY;
398
+ const dist = Math.sqrt(dx * dx + dy * dy);
399
+ const currentAngle = Math.atan2(dy, dx);
400
+ const newAngle = currentAngle + molecule.rotationSpeed;
401
+ atom.baseX = molecule.centerX + Math.cos(newAngle) * dist;
402
+ atom.baseY = molecule.centerY + Math.sin(newAngle) * dist;
403
+ });
404
+ });
405
+
406
+ bonds.forEach(bond => {
407
+ bond.vibrationPhase += 0.015;
408
+ });
409
+ };
410
+
411
+ const draw = () => {
412
+ ctx.clearRect(0, 0, width, height);
413
+
414
+ // Draw bonds
415
+ bonds.forEach(bond => {
416
+ if (bond.opacity < 0.01) return;
417
+ const from = bond.from;
418
+ const to = bond.to;
419
+ const vibration = Math.sin(bond.vibrationPhase) * 0.2;
420
+ const rgb = colors.bond.match(/[\d.]+/g);
421
+ ctx.strokeStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${bond.opacity})`;
422
+ ctx.lineWidth = 2 + vibration;
423
+
424
+ if (bond.strength === 2) {
425
+ const dx = to.x - from.x;
426
+ const dy = to.y - from.y;
427
+ const dist = Math.sqrt(dx * dx + dy * dy);
428
+ const offsetX = -dy / dist * 2.5;
429
+ const offsetY = dx / dist * 2.5;
430
+ ctx.beginPath();
431
+ ctx.moveTo(from.x + offsetX, from.y + offsetY);
432
+ ctx.lineTo(to.x + offsetX, to.y + offsetY);
433
+ ctx.stroke();
434
+ ctx.beginPath();
435
+ ctx.moveTo(from.x - offsetX, from.y - offsetY);
436
+ ctx.lineTo(to.x - offsetX, to.y - offsetY);
437
+ ctx.stroke();
438
+ } else if (bond.strength === 3) {
439
+ const dx = to.x - from.x;
440
+ const dy = to.y - from.y;
441
+ const dist = Math.sqrt(dx * dx + dy * dy);
442
+ const offsetX = -dy / dist * 3;
443
+ const offsetY = dx / dist * 3;
444
+ ctx.beginPath();
445
+ ctx.moveTo(from.x, from.y);
446
+ ctx.lineTo(to.x, to.y);
447
+ ctx.stroke();
448
+ ctx.beginPath();
449
+ ctx.moveTo(from.x + offsetX, from.y + offsetY);
450
+ ctx.lineTo(to.x + offsetX, to.y + offsetY);
451
+ ctx.stroke();
452
+ ctx.beginPath();
453
+ ctx.moveTo(from.x - offsetX, from.y - offsetY);
454
+ ctx.lineTo(to.x - offsetX, to.y - offsetY);
455
+ ctx.stroke();
456
+ } else {
457
+ ctx.beginPath();
458
+ ctx.moveTo(from.x, from.y);
459
+ ctx.lineTo(to.x, to.y);
460
+ ctx.stroke();
461
+ }
462
+ });
463
+
464
+ // Draw atoms
465
+ atoms.forEach(atom => {
466
+ if (atom.opacity < 0.01) return;
467
+ const finalSize = atom.size * atom.scale;
468
+
469
+ if (atom.scale > 1.1) {
470
+ const glowRadius = finalSize * 2;
471
+ const gradient = ctx.createRadialGradient(atom.x, atom.y, 0, atom.x, atom.y, glowRadius);
472
+ gradient.addColorStop(0, colors.glow.replace(/[\d.]+\)$/, `${atom.opacity * 0.4})`));
473
+ gradient.addColorStop(1, colors.glow.replace(/[\d.]+\)$/, '0)'));
474
+ ctx.fillStyle = gradient;
475
+ ctx.beginPath();
476
+ ctx.arc(atom.x, atom.y, glowRadius, 0, Math.PI * 2);
477
+ ctx.fill();
478
+ }
479
+
480
+ ctx.fillStyle = colors.atoms[atom.type];
481
+ ctx.beginPath();
482
+ ctx.arc(atom.x, atom.y, finalSize, 0, Math.PI * 2);
483
+ ctx.fill();
484
+
485
+ ctx.strokeStyle = `rgba(255, 255, 255, ${atom.opacity * 0.3})`;
486
+ ctx.lineWidth = 1.5;
487
+ ctx.stroke();
488
+
489
+ const highlightGradient = ctx.createRadialGradient(
490
+ atom.x - finalSize * 0.3, atom.y - finalSize * 0.3, 0,
491
+ atom.x, atom.y, finalSize
492
+ );
493
+ highlightGradient.addColorStop(0, `rgba(255, 255, 255, ${atom.opacity * 0.5})`);
494
+ highlightGradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
495
+ ctx.fillStyle = highlightGradient;
496
+ ctx.beginPath();
497
+ ctx.arc(atom.x, atom.y, finalSize, 0, Math.PI * 2);
498
+ ctx.fill();
499
+ });
500
+
501
+ update();
502
+ requestAnimationFrame(draw);
503
+ };
504
+
505
+ if (window.ResizeObserver) {
506
+ const ro = new ResizeObserver(resize);
507
+ ro.observe(container);
508
+ } else {
509
+ window.addEventListener('resize', resize);
510
+ }
511
+
512
+ resize();
513
+ draw();
514
+ };
515
+
516
+ if (document.readyState === 'loading') {
517
+ document.addEventListener('DOMContentLoaded', () => ensureAnime(bootstrap), { once: true });
518
+ } else {
519
+ ensureAnime(bootstrap);
520
+ }
521
+ })();
522
+ </script>
app/src/content/embeds/banner-neural-network-animejs.html ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="neural-flow"
2
+ style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;position:relative;overflow:hidden;"></div>
3
+ <script>
4
+ (() => {
5
+ const ensureAnime = (cb) => {
6
+ if (window.anime && typeof window.anime === 'function') return cb();
7
+ let s = document.getElementById('anime-cdn-script');
8
+ if (!s) {
9
+ s = document.createElement('script');
10
+ s.id = 'anime-cdn-script';
11
+ s.src = 'https://cdn.jsdelivr.net/npm/animejs@3.2.1/lib/anime.min.js';
12
+ document.head.appendChild(s);
13
+ }
14
+ const onReady = () => { if (window.anime && typeof window.anime === 'function') cb(); };
15
+ s.addEventListener('load', onReady, { once: true });
16
+ if (window.anime) onReady();
17
+ };
18
+
19
+ const bootstrap = () => {
20
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
21
+ const container = (mount && mount.querySelector && mount.querySelector('.neural-flow')) || document.querySelector('.neural-flow');
22
+ if (!container) return;
23
+ if (container.dataset) {
24
+ if (container.dataset.mounted === 'true') return;
25
+ container.dataset.mounted = 'true';
26
+ }
27
+
28
+ // Create canvas
29
+ const canvas = document.createElement('canvas');
30
+ canvas.style.display = 'block';
31
+ canvas.style.width = '100%';
32
+ canvas.style.height = '100%';
33
+ container.appendChild(canvas);
34
+ const ctx = canvas.getContext('2d');
35
+
36
+ // Theme colors
37
+ const getColors = () => {
38
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
39
+ return {
40
+ node: isDark ? 'rgba(206, 192, 250, 0.85)' : 'rgba(138, 100, 220, 0.8)',
41
+ nodeActive: isDark ? 'rgba(232, 137, 171, 1)' : 'rgba(220, 80, 130, 1)',
42
+ nodeGlow: isDark ? 'rgba(206, 192, 250, 0.4)' : 'rgba(138, 100, 220, 0.3)',
43
+ connection: isDark ? 'rgba(78, 165, 183, 0.08)' : 'rgba(78, 165, 183, 0.15)',
44
+ connectionActive: isDark ? 'rgba(232, 137, 171, 0.6)' : 'rgba(220, 80, 130, 0.5)',
45
+ accent: isDark ? 'rgba(78, 165, 183, 0.9)' : 'rgba(50, 130, 160, 0.85)',
46
+ particle: isDark ? 'rgba(232, 137, 171, 1)' : 'rgba(220, 80, 130, 1)',
47
+ };
48
+ };
49
+
50
+ let colors = getColors();
51
+
52
+ // Watch for theme changes
53
+ const observer = new MutationObserver(() => {
54
+ colors = getColors();
55
+ });
56
+ observer.observe(document.documentElement, {
57
+ attributes: true,
58
+ attributeFilter: ['data-theme']
59
+ });
60
+
61
+ // Neural network structure
62
+ const layers = [
63
+ { nodes: 6, name: 'input' },
64
+ { nodes: 10, name: 'hidden1' },
65
+ { nodes: 8, name: 'hidden2' },
66
+ { nodes: 4, name: 'output' }
67
+ ];
68
+
69
+ let nodes = [];
70
+ let connections = [];
71
+ let particles = [];
72
+ let width, height;
73
+
74
+ const resize = () => {
75
+ width = container.clientWidth || 800;
76
+ height = Math.max(260, Math.round(width / 3));
77
+ canvas.width = width;
78
+ canvas.height = height;
79
+ initNetwork();
80
+ };
81
+
82
+ const initNetwork = () => {
83
+ nodes = [];
84
+ connections = [];
85
+ particles = [];
86
+
87
+ const layerSpacing = width / (layers.length + 1);
88
+ const margin = height * 0.15;
89
+
90
+ // Create ALL nodes first
91
+ let nodeIndex = 0;
92
+ const layerStartIndices = [];
93
+
94
+ layers.forEach((layer, layerIdx) => {
95
+ layerStartIndices.push(nodeIndex);
96
+ const x = layerSpacing * (layerIdx + 1);
97
+ const availableHeight = height - 2 * margin;
98
+ const nodeSpacing = availableHeight / (layer.nodes + 1);
99
+
100
+ for (let i = 0; i < layer.nodes; i++) {
101
+ const y = margin + nodeSpacing * (i + 1);
102
+ const node = {
103
+ x,
104
+ y,
105
+ layer: layerIdx,
106
+ index: i,
107
+ radius: 0,
108
+ targetRadius: 3.5 + Math.random() * 1.5,
109
+ pulse: Math.random() * Math.PI * 2,
110
+ activation: 0,
111
+ baseActivity: Math.random() * 0.1
112
+ };
113
+ nodes.push(node);
114
+ nodeIndex++;
115
+ }
116
+ });
117
+
118
+ // Create FULLY CONNECTED network
119
+ layers.forEach((layer, layerIdx) => {
120
+ if (layerIdx < layers.length - 1) {
121
+ const currentLayerStart = layerStartIndices[layerIdx];
122
+ const nextLayerStart = layerStartIndices[layerIdx + 1];
123
+ const nextLayerNodes = layers[layerIdx + 1].nodes;
124
+
125
+ for (let i = 0; i < layer.nodes; i++) {
126
+ for (let j = 0; j < nextLayerNodes; j++) {
127
+ connections.push({
128
+ from: currentLayerStart + i,
129
+ to: nextLayerStart + j,
130
+ weight: Math.random(),
131
+ opacity: 0,
132
+ activation: 0
133
+ });
134
+ }
135
+ }
136
+ }
137
+ });
138
+
139
+ // Initial animation - nodes appear (plus rapide)
140
+ nodes.forEach((node, i) => {
141
+ anime({
142
+ targets: node,
143
+ radius: node.targetRadius,
144
+ duration: 800,
145
+ delay: i * 8,
146
+ easing: 'easeOutElastic(1, .6)'
147
+ });
148
+ });
149
+
150
+ // Connections fade in (plus rapide)
151
+ connections.forEach((conn, i) => {
152
+ anime({
153
+ targets: conn,
154
+ opacity: 1,
155
+ duration: 400,
156
+ delay: 300 + i * 1,
157
+ easing: 'easeOutQuad'
158
+ });
159
+ });
160
+
161
+ // Start forward propagation cycles (plus rapide)
162
+ setTimeout(() => {
163
+ startForwardPass();
164
+ setInterval(startForwardPass, 2500 + Math.random() * 1000);
165
+ }, 1000);
166
+ };
167
+
168
+ // Différents patterns d'activation
169
+ const activationPatterns = [
170
+ // Pattern 1: Tous les inputs
171
+ (inputNodes) => inputNodes,
172
+
173
+ // Pattern 2: Un seul input (signal focal)
174
+ (inputNodes) => [inputNodes[Math.floor(Math.random() * inputNodes.length)]],
175
+
176
+ // Pattern 3: Moitié haute
177
+ (inputNodes) => inputNodes.slice(0, Math.ceil(inputNodes.length / 2)),
178
+
179
+ // Pattern 4: Moitié basse
180
+ (inputNodes) => inputNodes.slice(Math.floor(inputNodes.length / 2)),
181
+
182
+ // Pattern 5: Pattern alterné
183
+ (inputNodes) => inputNodes.filter((_, i) => i % 2 === 0),
184
+
185
+ // Pattern 6: 2-3 inputs aléatoires
186
+ (inputNodes) => {
187
+ const num = 2 + Math.floor(Math.random() * 2);
188
+ return [...inputNodes].sort(() => Math.random() - 0.5).slice(0, num);
189
+ },
190
+
191
+ // Pattern 7: Cascade (un par un avec délai)
192
+ (inputNodes) => inputNodes.slice(0, 3 + Math.floor(Math.random() * 3))
193
+ ];
194
+
195
+ const startForwardPass = () => {
196
+ const inputNodes = nodes.filter(n => n.layer === 0);
197
+
198
+ // Choisir un pattern aléatoire
199
+ const pattern = activationPatterns[Math.floor(Math.random() * activationPatterns.length)];
200
+ const activeInputs = pattern(inputNodes);
201
+
202
+ // Activer les inputs (plus rapide)
203
+ activeInputs.forEach((node, idx) => {
204
+ anime({
205
+ targets: node,
206
+ activation: 0.8 + Math.random() * 0.2,
207
+ duration: 200,
208
+ delay: idx * 60,
209
+ easing: 'easeOutQuad',
210
+ complete: () => {
211
+ anime({
212
+ targets: node,
213
+ activation: node.baseActivity,
214
+ duration: 250,
215
+ delay: 400,
216
+ easing: 'easeInQuad'
217
+ });
218
+ }
219
+ });
220
+ });
221
+
222
+ // Propager à travers TOUTES les couches (plus rapide)
223
+ for (let layerIdx = 0; layerIdx < layers.length - 1; layerIdx++) {
224
+ setTimeout(() => {
225
+ propagateLayer(layerIdx);
226
+ }, 250 + layerIdx * 350);
227
+ }
228
+ };
229
+
230
+ const propagateLayer = (fromLayerIdx) => {
231
+ const fromNodes = nodes.filter(n => n.layer === fromLayerIdx);
232
+ const toNodes = nodes.filter(n => n.layer === fromLayerIdx + 1);
233
+
234
+ const layerConnections = connections.filter(c => {
235
+ const fromNode = nodes[c.from];
236
+ const toNode = nodes[c.to];
237
+ return fromNode.layer === fromLayerIdx && toNode.layer === fromLayerIdx + 1;
238
+ });
239
+
240
+ // Activer connections et créer particules
241
+ layerConnections.forEach((conn, idx) => {
242
+ const fromNode = nodes[conn.from];
243
+ const activationStrength = fromNode.activation * conn.weight;
244
+
245
+ if (activationStrength > 0.2) {
246
+ anime({
247
+ targets: conn,
248
+ activation: activationStrength,
249
+ duration: 300,
250
+ delay: idx * 1,
251
+ easing: 'easeOutQuad',
252
+ complete: () => {
253
+ anime({
254
+ targets: conn,
255
+ activation: 0,
256
+ duration: 250,
257
+ easing: 'easeInQuad'
258
+ });
259
+ }
260
+ });
261
+
262
+ // Créer particule qui voyage le long de la connexion
263
+ if (Math.random() < 0.3) { // Pas sur toutes les connexions
264
+ createParticle(conn, activationStrength);
265
+ }
266
+ }
267
+ });
268
+
269
+ // Activer les nœuds cibles (plus rapide)
270
+ setTimeout(() => {
271
+ toNodes.forEach(toNode => {
272
+ const toNodeIdx = nodes.indexOf(toNode);
273
+ const incomingConns = layerConnections.filter(c => c.to === toNodeIdx);
274
+
275
+ let sum = 0;
276
+ incomingConns.forEach(conn => {
277
+ const fromNode = nodes[conn.from];
278
+ sum += fromNode.activation * conn.weight;
279
+ });
280
+
281
+ const activation = Math.min(1, sum / incomingConns.length * 1.5);
282
+
283
+ if (activation > 0.25) {
284
+ anime({
285
+ targets: toNode,
286
+ activation: activation,
287
+ duration: 200,
288
+ easing: 'easeOutQuad',
289
+ complete: () => {
290
+ anime({
291
+ targets: toNode,
292
+ activation: toNode.baseActivity,
293
+ duration: 400,
294
+ delay: 300,
295
+ easing: 'easeInQuad'
296
+ });
297
+ }
298
+ });
299
+ }
300
+ });
301
+ }, 150);
302
+ };
303
+
304
+ const createParticle = (connection, strength) => {
305
+ const fromNode = nodes[connection.from];
306
+ const toNode = nodes[connection.to];
307
+ if (!fromNode || !toNode) return;
308
+
309
+ const particle = {
310
+ fromX: fromNode.x,
311
+ fromY: fromNode.y,
312
+ toX: toNode.x,
313
+ toY: toNode.y,
314
+ progress: 0,
315
+ strength: strength,
316
+ size: 1.5 + strength * 1.5,
317
+ trail: []
318
+ };
319
+
320
+ particles.push(particle);
321
+
322
+ anime({
323
+ targets: particle,
324
+ progress: 1,
325
+ duration: 350,
326
+ easing: 'easeInOutQuad',
327
+ complete: () => {
328
+ // Retirer la particule
329
+ const idx = particles.indexOf(particle);
330
+ if (idx > -1) particles.splice(idx, 1);
331
+ }
332
+ });
333
+ };
334
+
335
+ const draw = () => {
336
+ // Pas de background - transparent
337
+ ctx.clearRect(0, 0, width, height);
338
+
339
+ // Draw connections
340
+ connections.forEach(conn => {
341
+ if (conn.opacity < 0.01) return;
342
+
343
+ const fromNode = nodes[conn.from];
344
+ const toNode = nodes[conn.to];
345
+ if (!fromNode || !toNode) return;
346
+
347
+ const baseOpacity = conn.opacity * conn.weight * 0.5;
348
+ const activeOpacity = conn.activation;
349
+ const totalOpacity = Math.max(baseOpacity, activeOpacity);
350
+
351
+ if (totalOpacity < 0.01) return;
352
+
353
+ const isActive = conn.activation > 0.1;
354
+ const connectionColor = isActive ? colors.connectionActive : colors.connection;
355
+
356
+ ctx.beginPath();
357
+ ctx.moveTo(fromNode.x, fromNode.y);
358
+ ctx.lineTo(toNode.x, toNode.y);
359
+
360
+ const rgb = connectionColor.match(/[\d.]+/g);
361
+ ctx.strokeStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${totalOpacity})`;
362
+ ctx.lineWidth = isActive ? 1.5 : 0.8;
363
+ ctx.stroke();
364
+ });
365
+
366
+ // Draw particles
367
+ particles.forEach(particle => {
368
+ const x = particle.fromX + (particle.toX - particle.fromX) * particle.progress;
369
+ const y = particle.fromY + (particle.toY - particle.fromY) * particle.progress;
370
+
371
+ // Trail
372
+ particle.trail.push({ x, y });
373
+ if (particle.trail.length > 5) particle.trail.shift();
374
+
375
+ particle.trail.forEach((point, i) => {
376
+ const alpha = (i / particle.trail.length) * particle.strength;
377
+ const size = particle.size * alpha * 0.6;
378
+
379
+ ctx.beginPath();
380
+ ctx.arc(point.x, point.y, size, 0, Math.PI * 2);
381
+ const rgb = colors.particle.match(/[\d.]+/g);
382
+ ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha * 0.5})`;
383
+ ctx.fill();
384
+ });
385
+
386
+ // Particle principale
387
+ ctx.beginPath();
388
+ ctx.arc(x, y, particle.size, 0, Math.PI * 2);
389
+ ctx.fillStyle = colors.particle;
390
+ ctx.shadowBlur = 8;
391
+ ctx.shadowColor = colors.particle;
392
+ ctx.fill();
393
+ ctx.shadowBlur = 0;
394
+ });
395
+
396
+ // Draw nodes
397
+ nodes.forEach((node, i) => {
398
+ if (node.radius < 0.1) return;
399
+
400
+ node.pulse += 0.015;
401
+ const pulseSize = 1 + Math.sin(node.pulse) * 0.08;
402
+ const activationBoost = node.activation * 1.8;
403
+ const finalRadius = node.radius * pulseSize + activationBoost;
404
+
405
+ // Glow for active nodes
406
+ if (node.activation > 0.15) {
407
+ const glowRadius = finalRadius * 4;
408
+ const gradient = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, glowRadius);
409
+ const glowAlpha = node.activation * 0.5;
410
+ gradient.addColorStop(0, colors.nodeGlow.replace(/[\d.]+\)$/, `${glowAlpha})`));
411
+ gradient.addColorStop(1, colors.nodeGlow.replace(/[\d.]+\)$/, '0)'));
412
+ ctx.beginPath();
413
+ ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
414
+ ctx.fillStyle = gradient;
415
+ ctx.fill();
416
+ }
417
+
418
+ // Node color based on activation
419
+ const t = Math.min(1, node.activation / 0.8);
420
+
421
+ const baseRgb = colors.node.match(/[\d.]+/g);
422
+ const activeRgb = colors.nodeActive.match(/[\d.]+/g);
423
+ const r = parseFloat(baseRgb[0]) + (parseFloat(activeRgb[0]) - parseFloat(baseRgb[0])) * t;
424
+ const g = parseFloat(baseRgb[1]) + (parseFloat(activeRgb[1]) - parseFloat(baseRgb[1])) * t;
425
+ const b = parseFloat(baseRgb[2]) + (parseFloat(activeRgb[2]) - parseFloat(baseRgb[2])) * t;
426
+ const a = parseFloat(baseRgb[3]) + (parseFloat(activeRgb[3]) - parseFloat(baseRgb[3])) * t;
427
+
428
+ // Node
429
+ ctx.beginPath();
430
+ ctx.arc(node.x, node.y, finalRadius, 0, Math.PI * 2);
431
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
432
+ ctx.fill();
433
+
434
+ // Inner core for active nodes
435
+ if (node.activation > 0.4) {
436
+ ctx.beginPath();
437
+ ctx.arc(node.x, node.y, finalRadius * 0.4, 0, Math.PI * 2);
438
+ ctx.fillStyle = colors.accent.replace(/[\d.]+\)$/, `${node.activation})`);
439
+ ctx.fill();
440
+ }
441
+ });
442
+
443
+ requestAnimationFrame(draw);
444
+ };
445
+
446
+ // Start
447
+ if (window.ResizeObserver) {
448
+ const ro = new ResizeObserver(resize);
449
+ ro.observe(container);
450
+ } else {
451
+ window.addEventListener('resize', resize);
452
+ }
453
+
454
+ resize();
455
+ draw();
456
+ };
457
+
458
+ if (document.readyState === 'loading') {
459
+ document.addEventListener('DOMContentLoaded', () => ensureAnime(bootstrap), { once: true });
460
+ } else {
461
+ ensureAnime(bootstrap);
462
+ }
463
+ })();
464
+ </script>
app/src/content/embeds/banner-threejs-galaxy.html ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="threejs-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div>
2
+ <script type="importmap">
3
+ {
4
+ "imports": {
5
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
6
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
7
+ }
8
+ }
9
+ </script>
10
+ <style>
11
+ .threejs-galaxy {
12
+ overflow: visible;
13
+ background: transparent;
14
+ }
15
+
16
+ .threejs-galaxy canvas {
17
+ display: block;
18
+ width: 100%;
19
+ height: 100%;
20
+ }
21
+
22
+ .threejs-galaxy .tp-dfwv {
23
+ position: absolute !important;
24
+ top: 16px !important;
25
+ right: 16px !important;
26
+ z-index: 100 !important;
27
+ }
28
+ </style>
29
+ <script type="module">
30
+ import * as THREE from 'three';
31
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
32
+ import { Pane } from 'https://cdn.jsdelivr.net/npm/tweakpane@4.0.3/dist/tweakpane.js';
33
+
34
+ const container = document.querySelector('.threejs-galaxy');
35
+ if (!container || container.dataset.mounted === 'true') {
36
+ if (container) console.log('Container already mounted');
37
+ } else {
38
+ container.dataset.mounted = 'true';
39
+
40
+ // === Scene Setup ===
41
+ const scene = new THREE.Scene();
42
+
43
+ const camera = new THREE.PerspectiveCamera(
44
+ 35,
45
+ container.clientWidth / Math.max(260, Math.round(container.clientWidth / 3)),
46
+ 0.1,
47
+ 100
48
+ );
49
+ // Vue du dessus avec angle pour voir la profondeur - plus proche pour remplir l'espace
50
+ camera.position.set(-0.03, 1.75, 5.71);
51
+ camera.rotation.set(-0.43, -0.01, -0.01);
52
+
53
+ const renderer = new THREE.WebGLRenderer({
54
+ antialias: true,
55
+ alpha: true,
56
+ powerPreference: 'high-performance'
57
+ });
58
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
59
+ renderer.setClearColor(0x000000, 0);
60
+ container.appendChild(renderer.domElement);
61
+
62
+ // === OrbitControls ===
63
+ const controls = new OrbitControls(camera, renderer.domElement);
64
+ controls.enableDamping = true;
65
+ controls.dampingFactor = 0.05;
66
+ controls.autoRotate = true;
67
+ controls.autoRotateSpeed = 0.5;
68
+ controls.enableZoom = true;
69
+ controls.enablePan = true;
70
+ controls.panSpeed = 0.5;
71
+ controls.minDistance = 3;
72
+ controls.maxDistance = 12;
73
+ controls.target.set(0.04, -0.75, 0.26);
74
+
75
+ // Track pane visibility for logging
76
+ let paneVisible = false;
77
+
78
+ // Log camera position and rotation on change (only when pane is visible)
79
+ controls.addEventListener('change', () => {
80
+ if (paneVisible) {
81
+ console.log('Camera Position:', `camera.position.set(${camera.position.x.toFixed(2)}, ${camera.position.y.toFixed(2)}, ${camera.position.z.toFixed(2)});`);
82
+ console.log('Camera Rotation:', `camera.rotation.set(${camera.rotation.x.toFixed(2)}, ${camera.rotation.y.toFixed(2)}, ${camera.rotation.z.toFixed(2)});`);
83
+ console.log('Target (Center):', `controls.target.set(${controls.target.x.toFixed(2)}, ${controls.target.y.toFixed(2)}, ${controls.target.z.toFixed(2)});`);
84
+ console.log('---');
85
+ }
86
+ });
87
+
88
+ // === Galaxy Parameters ===
89
+ // Detect current theme
90
+ const isDarkMode = () => document.documentElement.getAttribute('data-theme') === 'dark';
91
+
92
+ const params = {
93
+ count: 12000,
94
+ size: 150,
95
+ whiteSize: 25,
96
+ sizeVariation: 0.8,
97
+ radius: 4,
98
+ branches: 2,
99
+ spin: 3.0,
100
+ randomness: 0.3,
101
+ randomnessPower: 3,
102
+ centerSizeBoost: 1.5,
103
+ insideColor: isDarkMode() ? '#ff6030' : '#ff8050',
104
+ outsideColor: isDarkMode() ? '#1b3984' : '#3d5fa8',
105
+ fov: 35
106
+ };
107
+
108
+ // === Tweakpane ===
109
+ const pane = new Pane({
110
+ container: container,
111
+ title: 'Galaxy Controls'
112
+ });
113
+
114
+ // Hide pane by default
115
+ pane.element.style.display = 'none';
116
+
117
+ let geometry = null;
118
+ let material = null;
119
+ let points = null;
120
+ let whiteGeometry = null;
121
+ let whiteMaterial = null;
122
+ let whitePoints = null;
123
+
124
+ // === Animation Clock ===
125
+ const clock = new THREE.Clock();
126
+
127
+ // === Generate Galaxy Function ===
128
+ const generateGalaxy = () => {
129
+ // Destroy old galaxy
130
+ if (points !== null) {
131
+ geometry.dispose();
132
+ material.dispose();
133
+ scene.remove(points);
134
+ }
135
+
136
+ // Destroy old white points
137
+ if (whitePoints !== null) {
138
+ whiteGeometry.dispose();
139
+ whiteMaterial.dispose();
140
+ scene.remove(whitePoints);
141
+ }
142
+
143
+ // New geometry
144
+ geometry = new THREE.BufferGeometry();
145
+
146
+ const positions = new Float32Array(params.count * 3);
147
+ const colors = new Float32Array(params.count * 3);
148
+ const scales = new Float32Array(params.count);
149
+
150
+ const colorInside = new THREE.Color(params.insideColor);
151
+ const colorOutside = new THREE.Color(params.outsideColor);
152
+
153
+ for (let i = 0; i < params.count; i++) {
154
+ const i3 = i * 3;
155
+
156
+ // Position sur le rayon
157
+ const radius = Math.random() * params.radius;
158
+ const radiusRatio = radius / params.radius;
159
+
160
+ // Angle de la branche
161
+ const branchAngle = (i % params.branches) / params.branches * Math.PI * 2;
162
+
163
+ // Angle de spin (twist)
164
+ const spinAngle = radius * params.spin;
165
+
166
+ // Randomness
167
+ const randomX = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius;
168
+ const randomY = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius * 0.3;
169
+ const randomZ = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius;
170
+
171
+ // Position finale en 3D
172
+ positions[i3] = Math.cos(branchAngle + spinAngle) * radius + randomX;
173
+ positions[i3 + 1] = randomY;
174
+ positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;
175
+
176
+ // Couleur
177
+ const mixedColor = colorInside.clone();
178
+ mixedColor.lerp(colorOutside, radiusRatio);
179
+
180
+ colors[i3] = mixedColor.r;
181
+ colors[i3 + 1] = mixedColor.g;
182
+ colors[i3 + 2] = mixedColor.b;
183
+
184
+ // Échelle : plus gros au centre, linéairement décroissant vers l'extérieur
185
+ const centerScale = (1.0 + params.centerSizeBoost) - radiusRatio * params.centerSizeBoost;
186
+ // Variation aléatoire contrôlée par sizeVariation
187
+ const randomScale = Math.pow(Math.random(), 2.0) * params.sizeVariation;
188
+ scales[i] = randomScale + centerScale;
189
+ }
190
+
191
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
192
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
193
+ geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
194
+
195
+ // === Shader Material ===
196
+ material = new THREE.ShaderMaterial({
197
+ depthWrite: false,
198
+ blending: THREE.AdditiveBlending,
199
+ vertexColors: true,
200
+ uniforms: {
201
+ uTime: { value: 0 },
202
+ uSize: { value: params.size * renderer.getPixelRatio() }
203
+ },
204
+ vertexShader: `
205
+ uniform float uTime;
206
+ uniform float uSize;
207
+
208
+ attribute float aScale;
209
+
210
+ varying vec3 vColor;
211
+
212
+ void main() {
213
+ vec4 modelPosition = modelMatrix * vec4(position, 1.0);
214
+
215
+ vec4 viewPosition = viewMatrix * modelPosition;
216
+ vec4 projectedPosition = projectionMatrix * viewPosition;
217
+ gl_Position = projectedPosition;
218
+
219
+ // Taille des points
220
+ gl_PointSize = uSize * aScale;
221
+ gl_PointSize *= (1.0 / -viewPosition.z);
222
+
223
+ vColor = color;
224
+ }
225
+ `,
226
+ fragmentShader: `
227
+ varying vec3 vColor;
228
+
229
+ void main() {
230
+ float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
231
+
232
+ // Noyau brillant
233
+ float core = 1.0 - smoothstep(0.0, 0.25, distanceToCenter);
234
+ core = pow(core, 2.0);
235
+
236
+ // Halo externe
237
+ float halo = 1.0 - smoothstep(0.15, 0.5, distanceToCenter);
238
+ halo = pow(halo, 3.0);
239
+
240
+ // Combinaison
241
+ float strength = max(core, halo * 0.3);
242
+
243
+ // Couleur finale (adaptée au thème)
244
+ float coreIntensity = ${isDarkMode() ? '0.8' : '0.7'};
245
+ float haloIntensity = ${isDarkMode() ? '0.4' : '0.35'};
246
+ vec3 coreColor = vColor * coreIntensity;
247
+ vec3 haloColor = vColor * haloIntensity;
248
+ vec3 finalColor = mix(haloColor, coreColor, core);
249
+
250
+ // Alpha adapté au thème
251
+ float alpha = strength * ${isDarkMode() ? '0.6' : '0.5'};
252
+
253
+ gl_FragColor = vec4(finalColor, alpha);
254
+ }
255
+ `
256
+ });
257
+
258
+ // === Points ===
259
+ points = new THREE.Points(geometry, material);
260
+ scene.add(points);
261
+
262
+ // === White Points (50% random subset) ===
263
+ const whiteCount = Math.floor(params.count * 0.5);
264
+ const whitePositions = new Float32Array(whiteCount * 3);
265
+ const whiteScales = new Float32Array(whiteCount);
266
+
267
+ // Sélectionner aléatoirement 50% des indices
268
+ const indices = Array.from({ length: params.count }, (_, i) => i);
269
+ // Mélanger les indices (Fisher-Yates shuffle)
270
+ for (let i = indices.length - 1; i > 0; i--) {
271
+ const j = Math.floor(Math.random() * (i + 1));
272
+ [indices[i], indices[j]] = [indices[j], indices[i]];
273
+ }
274
+ const selectedIndices = indices.slice(0, whiteCount);
275
+
276
+ // Copier les positions sélectionnées et créer des échelles plus petites
277
+ for (let i = 0; i < whiteCount; i++) {
278
+ const sourceIdx = selectedIndices[i];
279
+ whitePositions[i * 3] = positions[sourceIdx * 3];
280
+ whitePositions[i * 3 + 1] = positions[sourceIdx * 3 + 1];
281
+ whitePositions[i * 3 + 2] = positions[sourceIdx * 3 + 2];
282
+
283
+ // Échelles beaucoup plus petites pour les points blancs
284
+ whiteScales[i] = (Math.random() * 0.2 + 0.2) / 6;
285
+ }
286
+
287
+ whiteGeometry = new THREE.BufferGeometry();
288
+ whiteGeometry.setAttribute('position', new THREE.BufferAttribute(whitePositions, 3));
289
+ whiteGeometry.setAttribute('aScale', new THREE.BufferAttribute(whiteScales, 1));
290
+
291
+ // Matériau pour les points blancs
292
+ whiteMaterial = new THREE.ShaderMaterial({
293
+ depthWrite: false,
294
+ blending: THREE.AdditiveBlending,
295
+ uniforms: {
296
+ uTime: { value: 0 },
297
+ uSize: { value: params.whiteSize * renderer.getPixelRatio() }
298
+ },
299
+ vertexShader: `
300
+ uniform float uTime;
301
+ uniform float uSize;
302
+
303
+ attribute float aScale;
304
+
305
+ void main() {
306
+ vec4 modelPosition = modelMatrix * vec4(position, 1.0);
307
+
308
+ vec4 viewPosition = viewMatrix * modelPosition;
309
+ vec4 projectedPosition = projectionMatrix * viewPosition;
310
+ gl_Position = projectedPosition;
311
+
312
+ gl_PointSize = uSize * aScale;
313
+ gl_PointSize *= (1.0 / -viewPosition.z);
314
+ }
315
+ `,
316
+ fragmentShader: `
317
+ void main() {
318
+ float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
319
+
320
+ // Créer une boule bien définie
321
+ float strength = 1.0 - smoothstep(0.3, 0.5, distanceToCenter);
322
+ strength = pow(strength, 2.0);
323
+
324
+ // Couleur blanche (adaptée au thème)
325
+ vec3 whiteColor = vec3(${isDarkMode() ? '1.0, 1.0, 1.0' : '0.95, 0.95, 0.98'});
326
+
327
+ gl_FragColor = vec4(whiteColor, strength * ${isDarkMode() ? '0.8' : '0.9'});
328
+ }
329
+ `
330
+ });
331
+
332
+ whitePoints = new THREE.Points(whiteGeometry, whiteMaterial);
333
+ scene.add(whitePoints);
334
+
335
+ };
336
+
337
+ // Generate initial galaxy
338
+ generateGalaxy();
339
+
340
+ // === Tweakpane Controls ===
341
+ pane.addBinding(params, 'count', {
342
+ label: 'Particles',
343
+ min: 1000,
344
+ max: 50000,
345
+ step: 1000
346
+ }).on('change', () => {
347
+ generateGalaxy();
348
+ });
349
+
350
+ pane.addBinding(params, 'spin', {
351
+ label: 'Twist',
352
+ min: 0,
353
+ max: 5,
354
+ step: 0.1
355
+ }).on('change', () => {
356
+ generateGalaxy();
357
+ });
358
+
359
+ pane.addBinding(params, 'size', {
360
+ label: 'Point Size',
361
+ min: 10,
362
+ max: 200,
363
+ step: 1
364
+ }).on('change', () => {
365
+ if (material) {
366
+ material.uniforms.uSize.value = params.size * renderer.getPixelRatio();
367
+ }
368
+ });
369
+
370
+ pane.addBinding(params, 'sizeVariation', {
371
+ label: 'Size Variation',
372
+ min: 0,
373
+ max: 2,
374
+ step: 0.05
375
+ }).on('change', () => {
376
+ generateGalaxy();
377
+ });
378
+
379
+ pane.addBinding(params, 'whiteSize', {
380
+ label: 'White Size',
381
+ min: 5,
382
+ max: 100,
383
+ step: 1
384
+ }).on('change', () => {
385
+ if (whiteMaterial) {
386
+ whiteMaterial.uniforms.uSize.value = params.whiteSize * renderer.getPixelRatio();
387
+ }
388
+ });
389
+
390
+ pane.addBinding(params, 'centerSizeBoost', {
391
+ label: 'Center Boost',
392
+ min: 0,
393
+ max: 3,
394
+ step: 0.1
395
+ }).on('change', () => {
396
+ generateGalaxy();
397
+ });
398
+
399
+ pane.addBinding(params, 'branches', {
400
+ label: 'Branches',
401
+ min: 2,
402
+ max: 6,
403
+ step: 1
404
+ }).on('change', () => {
405
+ generateGalaxy();
406
+ });
407
+
408
+ pane.addBinding(params, 'fov', {
409
+ label: 'FOV (Zoom)',
410
+ min: 20,
411
+ max: 75,
412
+ step: 1
413
+ }).on('change', () => {
414
+ camera.fov = params.fov;
415
+ camera.updateProjectionMatrix();
416
+ });
417
+
418
+ // === Animation ===
419
+ const animate = () => {
420
+ requestAnimationFrame(animate);
421
+
422
+ const elapsedTime = clock.getElapsedTime();
423
+
424
+ if (material) {
425
+ material.uniforms.uTime.value = elapsedTime;
426
+ }
427
+
428
+ // Mise à jour des contrôles OrbitControls (gère la rotation automatique)
429
+ controls.update();
430
+
431
+ renderer.render(scene, camera);
432
+ };
433
+
434
+ // === Resize ===
435
+ const onResize = () => {
436
+ const width = container.clientWidth;
437
+ const height = Math.max(260, Math.round(width / 3));
438
+
439
+ camera.aspect = width / height;
440
+ camera.updateProjectionMatrix();
441
+
442
+ renderer.setSize(width, height);
443
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
444
+
445
+ if (material) {
446
+ material.uniforms.uSize.value = params.size * renderer.getPixelRatio();
447
+ }
448
+ if (whiteMaterial) {
449
+ whiteMaterial.uniforms.uSize.value = params.whiteSize * renderer.getPixelRatio();
450
+ }
451
+ };
452
+
453
+ onResize();
454
+
455
+ if (window.ResizeObserver) {
456
+ new ResizeObserver(onResize).observe(container);
457
+ } else {
458
+ window.addEventListener('resize', onResize);
459
+ }
460
+
461
+ // === Theme Support ===
462
+ const updateTheme = () => {
463
+ renderer.setClearColor(0x000000, 0);
464
+ // Update colors based on theme
465
+ params.insideColor = isDarkMode() ? '#ff6030' : '#ff8050';
466
+ params.outsideColor = isDarkMode() ? '#1b3984' : '#3d5fa8';
467
+ // Regenerate galaxy with new colors
468
+ generateGalaxy();
469
+ };
470
+
471
+ const observer = new MutationObserver(updateTheme);
472
+ observer.observe(document.documentElement, {
473
+ attributes: true,
474
+ attributeFilter: ['data-theme']
475
+ });
476
+
477
+ // === Keyboard Controls ===
478
+ window.addEventListener('keydown', (event) => {
479
+ if (event.key === 'd' || event.key === 'D') {
480
+ paneVisible = !paneVisible;
481
+ if (paneVisible) {
482
+ pane.element.style.display = '';
483
+ } else {
484
+ pane.element.style.display = 'none';
485
+ }
486
+ }
487
+ });
488
+
489
+ // === Start ===
490
+ animate();
491
+
492
+ // === Cleanup ===
493
+ container._cleanup = () => {
494
+ observer.disconnect();
495
+ if (geometry) geometry.dispose();
496
+ if (material) material.dispose();
497
+ if (whiteGeometry) whiteGeometry.dispose();
498
+ if (whiteMaterial) whiteMaterial.dispose();
499
+ controls.dispose();
500
+ renderer.dispose();
501
+ pane.dispose();
502
+ };
503
+ }
504
+ </script>
app/src/content/embeds/banner-umap-lucioles.html ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-latent-space"></div>
2
+ <style>
3
+ .d3-latent-space {
4
+ width: 100%;
5
+ margin: 10px 0;
6
+ aspect-ratio: 3/1;
7
+ min-height: 260px;
8
+ overflow: hidden;
9
+ background: transparent;
10
+ border-radius: 12px;
11
+ border: 1px solid var(--border-color);
12
+ }
13
+
14
+ .d3-latent-space canvas {
15
+ top: 0;
16
+ left: 0;
17
+ width: 100%;
18
+ height: 100%;
19
+ }
20
+
21
+ .d3-latent-space .tp-dfwv {
22
+ top: 16px;
23
+ right: 16px;
24
+ z-index: 10;
25
+ }
26
+
27
+ .d3-latent-space .d3-tooltip {
28
+ position: absolute;
29
+ background: color-mix(in srgb, var(--surface-bg) 95%, transparent);
30
+ backdrop-filter: blur(16px) saturate(1.2);
31
+ border: 1px solid var(--border-color);
32
+ border-radius: 12px;
33
+ padding: 14px 18px;
34
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.12);
35
+ pointer-events: none;
36
+ opacity: 0;
37
+ transform: translate(-50%, -120%);
38
+ transition: opacity 0.15s ease;
39
+ z-index: 10;
40
+ max-width: 400px;
41
+ }
42
+
43
+ .d3-latent-space .tooltip-category {
44
+ font-size: 10px;
45
+ font-weight: 800;
46
+ text-transform: uppercase;
47
+ letter-spacing: 1px;
48
+ margin-bottom: 8px;
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 6px;
52
+ }
53
+
54
+ .d3-latent-space .tooltip-badge {
55
+ display: inline-block;
56
+ width: 8px;
57
+ height: 8px;
58
+ border-radius: 50%;
59
+ box-shadow: 0 0 8px currentColor;
60
+ }
61
+
62
+ .d3-latent-space .tooltip-question {
63
+ font-size: 12px;
64
+ font-weight: 600;
65
+ color: var(--text-color);
66
+ margin-bottom: 6px;
67
+ line-height: 1.4;
68
+ }
69
+
70
+ .d3-latent-space .tooltip-answer {
71
+ font-size: 11px;
72
+ color: var(--muted-color);
73
+ line-height: 1.4;
74
+ border-top: 1px solid var(--border-color);
75
+ padding-top: 8px;
76
+ margin-top: 6px;
77
+ }
78
+ </style>
79
+ <script>
80
+ (() => {
81
+ const ensureD3 = (cb) => {
82
+ if (window.d3 && typeof window.d3.csvParse === 'function') return cb();
83
+ let s = document.getElementById('d3-cdn-script');
84
+ if (!s) {
85
+ s = document.createElement('script');
86
+ s.id = 'd3-cdn-script';
87
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
88
+ document.head.appendChild(s);
89
+ }
90
+ const onReady = () => { if (window.d3 && typeof window.d3.csvParse === 'function') cb(); };
91
+ s.addEventListener('load', onReady, { once: true });
92
+ if (window.d3) onReady();
93
+ };
94
+
95
+ const ensureAnime = (cb) => {
96
+ if (window.anime) return cb();
97
+ let s = document.getElementById('anime-cdn-script');
98
+ if (!s) {
99
+ s = document.createElement('script');
100
+ s.id = 'anime-cdn-script';
101
+ s.src = 'https://cdn.jsdelivr.net/npm/animejs@3.2.1/lib/anime.min.js';
102
+ document.head.appendChild(s);
103
+ }
104
+ const onReady = () => { if (window.anime) cb(); };
105
+ s.addEventListener('load', onReady, { once: true });
106
+ if (window.anime) onReady();
107
+ };
108
+
109
+ const ensureTweakpane = (cb) => {
110
+ if (window.Tweakpane) return cb();
111
+ let s = document.getElementById('tweakpane-cdn-script');
112
+ if (!s) {
113
+ s = document.createElement('script');
114
+ s.id = 'tweakpane-cdn-script';
115
+ s.src = 'https://cdn.jsdelivr.net/npm/tweakpane@3.1.10/dist/tweakpane.min.js';
116
+ document.head.appendChild(s);
117
+ }
118
+ const onReady = () => { if (window.Tweakpane) cb(); };
119
+ s.addEventListener('load', onReady, { once: true });
120
+ if (window.Tweakpane) onReady();
121
+ };
122
+
123
+ const bootstrap = () => {
124
+ const scriptEl = document.currentScript;
125
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
126
+ if (!(container && container.classList && container.classList.contains('d3-latent-space'))) {
127
+ const candidates = Array.from(document.querySelectorAll('.d3-latent-space'))
128
+ .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
129
+ container = candidates[candidates.length - 1] || null;
130
+ }
131
+ if (!container) return;
132
+ if (container.dataset) {
133
+ if (container.dataset.mounted === 'true') return;
134
+ container.dataset.mounted = 'true';
135
+ }
136
+
137
+ // Setup canvas
138
+ const canvas = document.createElement('canvas');
139
+ container.appendChild(canvas);
140
+ const ctx = canvas.getContext('2d');
141
+
142
+ // Tooltip
143
+ const tooltip = document.createElement('div');
144
+ tooltip.className = 'd3-tooltip';
145
+ container.appendChild(tooltip);
146
+
147
+ let width = container.clientWidth || 800;
148
+ let height = Math.max(260, Math.round(width / 3));
149
+ let points = [];
150
+ let categories = new Map();
151
+ let selectedCategory = null;
152
+ let animationFrame;
153
+ let time = 0;
154
+
155
+ // Tweakpane params
156
+ const params = {
157
+ baseSize: 2.5
158
+ };
159
+
160
+ const resizeCanvas = () => {
161
+ width = container.clientWidth || 800;
162
+ height = Math.max(260, Math.round(width / 3));
163
+ canvas.width = width * window.devicePixelRatio || 1;
164
+ canvas.height = height * window.devicePixelRatio || 1;
165
+ canvas.style.width = width + 'px';
166
+ canvas.style.height = height + 'px';
167
+ ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
168
+ };
169
+
170
+ const getColors = () => {
171
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
172
+ const colors = window.ColorPalettes
173
+ ? window.ColorPalettes.getColors('categorical', 10)
174
+ : ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#B983FF', '#FF85A2', '#5DADE2', '#52BE80'];
175
+ return { isDark, colors };
176
+ };
177
+
178
+ class Point {
179
+ constructor(data, color, index) {
180
+ this.originalX = parseFloat(data.x);
181
+ this.originalY = parseFloat(data.y);
182
+ this.category = data.primary_category;
183
+ this.title = data.title;
184
+ this.authors = data.authors;
185
+ this.year = data.year;
186
+ this.abstract = data.abstract;
187
+ this.color = color;
188
+ this.index = index;
189
+
190
+ // Animation properties
191
+ this.x = this.originalX;
192
+ this.y = this.originalY;
193
+ this.displayX = 0;
194
+ this.displayY = 0;
195
+ this.opacity = 0;
196
+ this.sizeVariation = 0.5 + Math.random() * 1;
197
+ this.size = params.baseSize * this.sizeVariation;
198
+ this.baseSize = this.size;
199
+ this.glowIntensity = 0;
200
+ this.phase = Math.random() * Math.PI * 2;
201
+ this.speed = 0.3 + Math.random() * 0.4;
202
+ }
203
+
204
+ updateSize() {
205
+ this.baseSize = params.baseSize * this.sizeVariation;
206
+ if (!this.isHighlighted) {
207
+ this.size = this.baseSize;
208
+ }
209
+ }
210
+
211
+ update(time, selectedCat) {
212
+ // Position statique - pas de mouvement
213
+ this.x = this.originalX;
214
+ this.y = this.originalY;
215
+
216
+ // Highlight if selected or dim if other selected
217
+ if (selectedCat === null) {
218
+ this.glowIntensity = 0.8;
219
+ this.isHighlighted = false;
220
+ this.size = this.baseSize;
221
+ } else if (this.category === selectedCat) {
222
+ this.glowIntensity = 1;
223
+ this.isHighlighted = true;
224
+ this.size = this.baseSize * 1.4;
225
+ } else {
226
+ this.glowIntensity = 0.1;
227
+ this.isHighlighted = true;
228
+ this.size = this.baseSize * 0.6;
229
+ }
230
+ }
231
+
232
+ draw(ctx, scaleX, scaleY, offsetX, offsetY) {
233
+ this.displayX = this.x * scaleX + offsetX;
234
+ this.displayY = this.y * scaleY + offsetY;
235
+
236
+ const alpha = this.opacity * this.glowIntensity;
237
+
238
+ // Outer glow
239
+ if (this.glowIntensity > 0.3) {
240
+ const gradient = ctx.createRadialGradient(
241
+ this.displayX, this.displayY, 0,
242
+ this.displayX, this.displayY, this.size * 4
243
+ );
244
+ gradient.addColorStop(0, this.color + Math.floor(alpha * 60).toString(16).padStart(2, '0'));
245
+ gradient.addColorStop(0.5, this.color + Math.floor(alpha * 30).toString(16).padStart(2, '0'));
246
+ gradient.addColorStop(1, this.color + '00');
247
+
248
+ ctx.fillStyle = gradient;
249
+ ctx.beginPath();
250
+ ctx.arc(this.displayX, this.displayY, this.size * 4, 0, Math.PI * 2);
251
+ ctx.fill();
252
+ }
253
+
254
+ // Core point
255
+ ctx.fillStyle = this.color + Math.floor(alpha * 255).toString(16).padStart(2, '0');
256
+ ctx.beginPath();
257
+ ctx.arc(this.displayX, this.displayY, this.size, 0, Math.PI * 2);
258
+ ctx.fill();
259
+ }
260
+
261
+ isNear(mx, my, threshold = 20) {
262
+ const dx = this.displayX - mx;
263
+ const dy = this.displayY - my;
264
+ return Math.sqrt(dx * dx + dy * dy) < threshold;
265
+ }
266
+ }
267
+
268
+ const loadData = async () => {
269
+ try {
270
+ console.log('Loading research papers data...');
271
+ const response = await fetch('/data/data.json', { cache: 'no-cache' });
272
+ if (!response.ok) {
273
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
274
+ }
275
+
276
+ console.log('Parsing JSON...');
277
+ const rawData = await response.json();
278
+ console.log(`Loaded ${rawData.length} papers`);
279
+
280
+ // Sample data for performance (max 3000 points)
281
+ const sampledData = rawData.length > 3000
282
+ ? rawData.filter((_, i) => i % Math.ceil(rawData.length / 3000) === 0)
283
+ : rawData;
284
+
285
+ // Group by primary category
286
+ sampledData.forEach(paper => {
287
+ const cat = paper.primary_category || 'Unknown';
288
+ if (!categories.has(cat)) {
289
+ categories.set(cat, []);
290
+ }
291
+ categories.get(cat).push(paper);
292
+ });
293
+
294
+ // Create points with colors
295
+ const { colors } = getColors();
296
+ const categoryList = Array.from(categories.keys());
297
+
298
+ categoryList.forEach((cat, i) => {
299
+ const color = colors[i % colors.length];
300
+ categories.get(cat).forEach((data, j) => {
301
+ points.push(new Point(data, color, i * 100 + j));
302
+ });
303
+ });
304
+
305
+ // Animate points in
306
+ points.forEach((point, i) => {
307
+ anime({
308
+ targets: point,
309
+ opacity: [0, 1],
310
+ duration: 1500,
311
+ delay: i * 2,
312
+ easing: 'easeOutQuad'
313
+ });
314
+ });
315
+
316
+ // Setup Tweakpane
317
+ let pane;
318
+ try {
319
+ if (window.Tweakpane && window.Tweakpane.Pane) {
320
+ pane = new window.Tweakpane.Pane({
321
+ container: container,
322
+ title: 'Controls'
323
+ });
324
+ } else if (window.Tweakpane) {
325
+ pane = new window.Tweakpane({
326
+ container: container,
327
+ title: 'Controls'
328
+ });
329
+ }
330
+
331
+ if (pane) {
332
+ const input = pane.addInput ? pane.addInput(params, 'baseSize', {
333
+ label: 'Point Size',
334
+ min: 0.5,
335
+ max: 8,
336
+ step: 0.1
337
+ }) : pane.addBinding ? pane.addBinding(params, 'baseSize', {
338
+ label: 'Point Size',
339
+ min: 0.5,
340
+ max: 8,
341
+ step: 0.1
342
+ }) : null;
343
+
344
+ if (input) {
345
+ input.on('change', () => {
346
+ points.forEach(p => p.updateSize());
347
+ });
348
+ }
349
+ }
350
+ } catch (err) {
351
+ console.warn('Tweakpane initialization failed:', err);
352
+ // Fallback to HTML slider
353
+ const controls = document.createElement('div');
354
+ controls.style.cssText = 'position:absolute;top:16px;right:16px;background:var(--surface-bg);border:1px solid var(--border-color);border-radius:8px;padding:12px;z-index:10;';
355
+ controls.innerHTML = `
356
+ <label style="font-size:11px;font-weight:700;color:var(--text-color);display:block;margin-bottom:6px;">Point Size</label>
357
+ <input type="range" min="0.5" max="8" step="0.1" value="2.5" style="width:120px;">
358
+ <span style="font-size:11px;color:var(--muted-color);margin-left:8px;">2.5</span>
359
+ `;
360
+ const slider = controls.querySelector('input');
361
+ const label = controls.querySelector('span');
362
+ slider.addEventListener('input', (e) => {
363
+ params.baseSize = parseFloat(e.target.value);
364
+ label.textContent = params.baseSize.toFixed(1);
365
+ points.forEach(p => p.updateSize());
366
+ });
367
+ container.appendChild(controls);
368
+ }
369
+
370
+ console.log(`Created ${points.length} points from ${categoryList.length} categories`);
371
+ render();
372
+
373
+ } catch (error) {
374
+ console.error('Error loading data:', error);
375
+ const errorMsg = error.message || error.toString();
376
+ container.innerHTML = `<pre style="color:red;padding:20px;margin:0;font-size:12px;">Error: ${errorMsg}<br><br>Trying to load: /data/data.json<br>Check console for details.</pre>`;
377
+ }
378
+ };
379
+
380
+ const render = () => {
381
+ time += 16;
382
+ ctx.clearRect(0, 0, width, height);
383
+
384
+ if (points.length === 0) {
385
+ animationFrame = requestAnimationFrame(render);
386
+ return;
387
+ }
388
+
389
+ // Calculate bounds
390
+ const xValues = points.map(p => p.x);
391
+ const yValues = points.map(p => p.y);
392
+ const minX = Math.min(...xValues);
393
+ const maxX = Math.max(...xValues);
394
+ const minY = Math.min(...yValues);
395
+ const maxY = Math.max(...yValues);
396
+
397
+ const padding = 40;
398
+ const scaleX = (width - padding * 2) / (maxX - minX);
399
+ const scaleY = (height - padding * 2) / (maxY - minY);
400
+
401
+ const offsetX = padding - minX * scaleX;
402
+ const offsetY = padding - minY * scaleY;
403
+
404
+ // Update and draw points
405
+ points.forEach(point => {
406
+ point.update(time, selectedCategory);
407
+ point.draw(ctx, scaleX, scaleY, offsetX, offsetY);
408
+ });
409
+
410
+ animationFrame = requestAnimationFrame(render);
411
+ };
412
+
413
+ // Mouse interaction
414
+ let hoveredPoint = null;
415
+ canvas.addEventListener('mousemove', (e) => {
416
+ const rect = canvas.getBoundingClientRect();
417
+ const mx = e.clientX - rect.left;
418
+ const my = e.clientY - rect.top;
419
+
420
+ const closest = points.find(p => p.isNear(mx, my));
421
+
422
+ if (closest && closest !== hoveredPoint) {
423
+ hoveredPoint = closest;
424
+ const authorsStr = Array.isArray(closest.authors)
425
+ ? (closest.authors.length > 3
426
+ ? `${closest.authors.slice(0, 3).join(', ')} et al.`
427
+ : closest.authors.join(', '))
428
+ : closest.authors || 'Unknown';
429
+
430
+ tooltip.innerHTML = `
431
+ <div class="tooltip-category">
432
+ <span class="tooltip-badge" style="background: ${closest.color}; color: ${closest.color}"></span>
433
+ ${closest.category} · ${closest.year}
434
+ </div>
435
+ <div class="tooltip-question">${closest.title.substring(0, 120)}${closest.title.length > 120 ? '...' : ''}</div>
436
+ <div class="tooltip-answer">${authorsStr}<br>${closest.abstract.substring(0, 180)}${closest.abstract.length > 180 ? '...' : ''}</div>
437
+ `;
438
+ tooltip.style.left = mx + 'px';
439
+ tooltip.style.top = my + 'px';
440
+ tooltip.style.opacity = '1';
441
+ canvas.style.cursor = 'pointer';
442
+ } else if (!closest) {
443
+ hoveredPoint = null;
444
+ tooltip.style.opacity = '0';
445
+ canvas.style.cursor = 'crosshair';
446
+ }
447
+ });
448
+
449
+ canvas.addEventListener('mouseleave', () => {
450
+ hoveredPoint = null;
451
+ tooltip.style.opacity = '0';
452
+ canvas.style.cursor = 'crosshair';
453
+ });
454
+
455
+ // Resize handling
456
+ resizeCanvas();
457
+ if (window.ResizeObserver) {
458
+ const ro = new ResizeObserver(() => resizeCanvas());
459
+ ro.observe(container);
460
+ } else {
461
+ window.addEventListener('resize', resizeCanvas);
462
+ }
463
+
464
+ // Theme observer
465
+ const observer = new MutationObserver(() => {
466
+ const { colors } = getColors();
467
+ const categoryList = Array.from(categories.keys());
468
+ points.forEach(point => {
469
+ const catIndex = categoryList.indexOf(point.category);
470
+ if (catIndex >= 0) {
471
+ point.color = colors[catIndex % colors.length];
472
+ }
473
+ });
474
+ });
475
+ observer.observe(document.documentElement, {
476
+ attributes: true,
477
+ attributeFilter: ['data-theme']
478
+ });
479
+
480
+ loadData();
481
+ };
482
+
483
+ if (document.readyState === 'loading') {
484
+ document.addEventListener('DOMContentLoaded', () => ensureD3(() => ensureAnime(() => ensureTweakpane(bootstrap))), { once: true });
485
+ } else {
486
+ ensureD3(() => ensureAnime(() => ensureTweakpane(bootstrap)));
487
+ }
488
+ })();
489
+ </script>
app/src/pages/index.astro CHANGED
@@ -148,8 +148,7 @@ const keyAuthor = (authorNames[0] || "article")
148
  const keyTitle = titleFlat
149
  .toLowerCase()
150
  .replace(/[^a-z0-9]+/g, "_")
151
- .replace(/^_|_$/g, "")
152
- .slice(0, 24);
153
  const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
154
  const doi = (ArticleMod as any)?.frontmatter?.doi
155
  ? String((ArticleMod as any).frontmatter.doi)
 
148
  const keyTitle = titleFlat
149
  .toLowerCase()
150
  .replace(/[^a-z0-9]+/g, "_")
151
+ .replace(/^_|_$/g, "");
 
152
  const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
153
  const doi = (ArticleMod as any)?.frontmatter?.doi
154
  ? String((ArticleMod as any).frontmatter.doi)
app/src/styles/_variables.css CHANGED
@@ -11,7 +11,7 @@
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 337);
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);
 
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 47);
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);
app/src/styles/components/_card.css CHANGED
@@ -1,9 +1,127 @@
1
  .card {
2
  background: var(--surface-bg);
3
- border: 1px solid var(--border-color);
4
- border-radius: 10px;
5
- padding: var(--spacing-2);
6
  z-index: calc(var(--z-elevated) + 1);
7
  position: relative;
8
- margin-bottom: var(--block-spacing-y);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
 
1
  .card {
2
  background: var(--surface-bg);
3
+ border: 1px solid var(--border-color) !important;
4
+ border-radius: 12px;
5
+ padding: var(--spacing-3);
6
  z-index: calc(var(--z-elevated) + 1);
7
  position: relative;
8
+ margin-bottom: 0;
9
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
10
+ transition: all 0.2s ease;
11
+ overflow: hidden;
12
+ text-decoration: none;
13
+ color: inherit;
14
+ display: block;
15
+ }
16
+
17
+ .card:hover {
18
+ text-decoration: none;
19
+ color: inherit;
20
+ }
21
+
22
+ .card.no-padding {
23
+ padding: 0;
24
+ }
25
+
26
+ .card:hover {
27
+ transform: translateY(-2px);
28
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
29
+ border-color: var(--muted-color) !important;
30
+ }
31
+
32
+
33
+ .card figcaption {
34
+ color: var(--text-color) !important;
35
+ font-weight: 600 !important;
36
+ font-size: 1rem !important;
37
+ text-align: center !important;
38
+ padding: var(--spacing-3) !important;
39
+ margin: 0 !important;
40
+ }
41
+
42
+ .card .ri-root,
43
+ .card .ri-root figure {
44
+ margin: 0 !important;
45
+ }
46
+
47
+ .card img {
48
+ border-radius: 8px 8px 0 0 !important;
49
+ width: 100%;
50
+ height: 120px;
51
+ object-fit: cover;
52
+ border-bottom: 1px solid var(--border-color) !important;
53
+ transition: all 0.2s ease;
54
+ }
55
+
56
+ .card:hover img {
57
+ border-color: var(--muted-color) !important;
58
+ }
59
+
60
+
61
+ .card h3,
62
+ .card h4,
63
+ .card h5 {
64
+ margin-top: 0 !important;
65
+ font-weight: 900 !important;
66
+ width: 100%;
67
+ }
68
+
69
+ .card::after {
70
+ display: none !important;
71
+ }
72
+
73
+ .card-title-container {
74
+ padding: var(--spacing-3) var(--spacing-4) var(--spacing-4) var(--spacing-4);
75
+ }
76
+
77
+ .card-title {
78
+ color: var(--text-color);
79
+ font-size: 0.9rem;
80
+ margin: 0 0 var(--spacing-1) 0 !important;
81
+ font-weight: 600;
82
+ line-height: 1.4;
83
+ white-space: normal;
84
+ }
85
+
86
+ .card-subtitle {
87
+ color: var(--muted-color);
88
+ font-size: 0.75rem;
89
+ margin: 0 !important;
90
+ line-height: 1.5;
91
+ white-space: normal;
92
+ }
93
+
94
+ .card-cta {
95
+ flex: 1;
96
+ min-width: 0;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ background: var(--surface-bg);
101
+ border: 2px dashed var(--border-color) !important;
102
+ }
103
+
104
+ .card-cta:hover {
105
+ transform: none;
106
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
107
+ border: 2px dashed var(--border-color) !important;
108
+ }
109
+
110
+ .card-cta-content {
111
+ text-align: center;
112
+ padding: var(--spacing-4);
113
+ }
114
+
115
+ .card-cta h3,
116
+ .card-cta h4,
117
+ .card-cta h5 {
118
+ color: var(--text-color);
119
+ font-weight: 900 !important;
120
+ margin-bottom: var(--spacing-1) !important;
121
+ }
122
+
123
+ .card-cta p {
124
+ color: var(--muted-color);
125
+ font-size: 0.9rem;
126
+ margin: 0 !important;
127
  }