thibaud frere commited on
Commit
c24ea90
·
1 Parent(s): b9e7b9b
README.md CHANGED
@@ -12,7 +12,6 @@ thumbnail: https://huggingface.co/spaces/tfrere/research-paper-template/thumb.jp
12
 
13
  TO DO :
14
 
15
- - fix banner pour l'export pdf et la thumb
16
  - rename le titre ?
17
  - Vérifier la biliographie comment elle marche
18
 
 
12
 
13
  TO DO :
14
 
 
15
  - rename le titre ?
16
  - Vérifier la biliographie comment elle marche
17
 
app/.astro/astro/content.d.ts CHANGED
@@ -1,185 +0,0 @@
1
- declare module 'astro:content' {
2
- interface Render {
3
- '.mdx': Promise<{
4
- Content: import('astro').MarkdownInstance<{}>['Content'];
5
- headings: import('astro').MarkdownHeading[];
6
- remarkPluginFrontmatter: Record<string, any>;
7
- components: import('astro').MDXInstance<{}>['components'];
8
- }>;
9
- }
10
- }
11
-
12
- declare module 'astro:content' {
13
- interface RenderResult {
14
- Content: import('astro/runtime/server/index.js').AstroComponentFactory;
15
- headings: import('astro').MarkdownHeading[];
16
- remarkPluginFrontmatter: Record<string, any>;
17
- }
18
- interface Render {
19
- '.md': Promise<RenderResult>;
20
- }
21
-
22
- export interface RenderedContent {
23
- html: string;
24
- metadata?: {
25
- imagePaths: Array<string>;
26
- [key: string]: unknown;
27
- };
28
- }
29
- }
30
-
31
- declare module 'astro:content' {
32
- type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
33
-
34
- export type CollectionKey = keyof AnyEntryMap;
35
- export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
36
-
37
- export type ContentCollectionKey = keyof ContentEntryMap;
38
- export type DataCollectionKey = keyof DataEntryMap;
39
-
40
- type AllValuesOf<T> = T extends any ? T[keyof T] : never;
41
- type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
42
- ContentEntryMap[C]
43
- >['slug'];
44
-
45
- /** @deprecated Use `getEntry` instead. */
46
- export function getEntryBySlug<
47
- C extends keyof ContentEntryMap,
48
- E extends ValidContentEntrySlug<C> | (string & {}),
49
- >(
50
- collection: C,
51
- // Note that this has to accept a regular string too, for SSR
52
- entrySlug: E,
53
- ): E extends ValidContentEntrySlug<C>
54
- ? Promise<CollectionEntry<C>>
55
- : Promise<CollectionEntry<C> | undefined>;
56
-
57
- /** @deprecated Use `getEntry` instead. */
58
- export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
59
- collection: C,
60
- entryId: E,
61
- ): Promise<CollectionEntry<C>>;
62
-
63
- export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
64
- collection: C,
65
- filter?: (entry: CollectionEntry<C>) => entry is E,
66
- ): Promise<E[]>;
67
- export function getCollection<C extends keyof AnyEntryMap>(
68
- collection: C,
69
- filter?: (entry: CollectionEntry<C>) => unknown,
70
- ): Promise<CollectionEntry<C>[]>;
71
-
72
- export function getEntry<
73
- C extends keyof ContentEntryMap,
74
- E extends ValidContentEntrySlug<C> | (string & {}),
75
- >(entry: {
76
- collection: C;
77
- slug: E;
78
- }): E extends ValidContentEntrySlug<C>
79
- ? Promise<CollectionEntry<C>>
80
- : Promise<CollectionEntry<C> | undefined>;
81
- export function getEntry<
82
- C extends keyof DataEntryMap,
83
- E extends keyof DataEntryMap[C] | (string & {}),
84
- >(entry: {
85
- collection: C;
86
- id: E;
87
- }): E extends keyof DataEntryMap[C]
88
- ? Promise<DataEntryMap[C][E]>
89
- : Promise<CollectionEntry<C> | undefined>;
90
- export function getEntry<
91
- C extends keyof ContentEntryMap,
92
- E extends ValidContentEntrySlug<C> | (string & {}),
93
- >(
94
- collection: C,
95
- slug: E,
96
- ): E extends ValidContentEntrySlug<C>
97
- ? Promise<CollectionEntry<C>>
98
- : Promise<CollectionEntry<C> | undefined>;
99
- export function getEntry<
100
- C extends keyof DataEntryMap,
101
- E extends keyof DataEntryMap[C] | (string & {}),
102
- >(
103
- collection: C,
104
- id: E,
105
- ): E extends keyof DataEntryMap[C]
106
- ? Promise<DataEntryMap[C][E]>
107
- : Promise<CollectionEntry<C> | undefined>;
108
-
109
- /** Resolve an array of entry references from the same collection */
110
- export function getEntries<C extends keyof ContentEntryMap>(
111
- entries: {
112
- collection: C;
113
- slug: ValidContentEntrySlug<C>;
114
- }[],
115
- ): Promise<CollectionEntry<C>[]>;
116
- export function getEntries<C extends keyof DataEntryMap>(
117
- entries: {
118
- collection: C;
119
- id: keyof DataEntryMap[C];
120
- }[],
121
- ): Promise<CollectionEntry<C>[]>;
122
-
123
- export function render<C extends keyof AnyEntryMap>(
124
- entry: AnyEntryMap[C][string],
125
- ): Promise<RenderResult>;
126
-
127
- export function reference<C extends keyof AnyEntryMap>(
128
- collection: C,
129
- ): import('astro/zod').ZodEffects<
130
- import('astro/zod').ZodString,
131
- C extends keyof ContentEntryMap
132
- ? {
133
- collection: C;
134
- slug: ValidContentEntrySlug<C>;
135
- }
136
- : {
137
- collection: C;
138
- id: keyof DataEntryMap[C];
139
- }
140
- >;
141
- // Allow generic `string` to avoid excessive type errors in the config
142
- // if `dev` is not running to update as you edit.
143
- // Invalid collection names will be caught at build time.
144
- export function reference<C extends string>(
145
- collection: C,
146
- ): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
147
-
148
- type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
149
- type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
150
- ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
151
- >;
152
-
153
- type ContentEntryMap = {
154
- "chapters": {
155
- "best-pratices.mdx": {
156
- id: "best-pratices.mdx";
157
- slug: "best-pratices";
158
- body: string;
159
- collection: "chapters";
160
- data: any
161
- } & { render(): Render[".mdx"] };
162
- "writing-you-content.mdx": {
163
- id: "writing-you-content.mdx";
164
- slug: "writing-you-content";
165
- body: string;
166
- collection: "chapters";
167
- data: any
168
- } & { render(): Render[".mdx"] };
169
- };
170
-
171
- };
172
-
173
- type DataEntryMap = {
174
- "fragments": Record<string, {
175
- id: string;
176
- collection: "fragments";
177
- data: any;
178
- }>;
179
-
180
- };
181
-
182
- type AnyEntryMap = ContentEntryMap & DataEntryMap;
183
-
184
- export type ContentConfig = never;
185
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/astro.config.mjs CHANGED
@@ -3,7 +3,6 @@ import mdx from '@astrojs/mdx';
3
  import mermaid from 'astro-mermaid';
4
  import remarkMath from 'remark-math';
5
  import rehypeKatex from 'rehype-katex';
6
- import remarkToc from 'remark-toc';
7
  import remarkFootnotes from 'remark-footnotes';
8
  import rehypeSlug from 'rehype-slug';
9
  import rehypeAutolinkHeadings from 'rehype-autolink-headings';
@@ -30,7 +29,6 @@ export default defineConfig({
30
  }
31
  },
32
  remarkPlugins: [
33
- [remarkToc, { heading: 'Table of Contents', maxDepth: 3 }],
34
  remarkMath,
35
  [remarkFootnotes, { inlineNotes: true }]
36
  ],
 
3
  import mermaid from 'astro-mermaid';
4
  import remarkMath from 'remark-math';
5
  import rehypeKatex from 'rehype-katex';
 
6
  import remarkFootnotes from 'remark-footnotes';
7
  import rehypeSlug from 'rehype-slug';
8
  import rehypeAutolinkHeadings from 'rehype-autolink-headings';
 
29
  }
30
  },
31
  remarkPlugins: [
 
32
  remarkMath,
33
  [remarkFootnotes, { inlineNotes: true }]
34
  ],
app/src/assets/{images → icones}/moon.svg RENAMED
File without changes
app/src/assets/{images → icones}/sun.svg RENAMED
File without changes
app/src/components/Note.astro CHANGED
@@ -1,23 +1,24 @@
1
  ---
2
- const { title = "Note", emoji = "📝", class: className, ...props } = Astro.props;
3
  const wrapperClass = ["note", className].filter(Boolean).join(" ");
 
4
  ---
5
  <div class={wrapperClass} {...props}>
6
- <div class="note__header">
7
- <span class="note__emoji">{emoji}</span>
8
  {title && <span class="note__title">{title}</span>}
9
- </div>
10
  <div class="note__content">
11
  <slot />
12
  </div>
13
  </div>
14
 
15
  <style>
16
- .note { background: var(--surface-bg); border-left: 2px solid rgba(0, 0, 0, 0.1); border-radius: 4px; padding: 10px 14px; margin: 12px 0; }
17
  .note__header { display: flex; align-items: center; gap: 6px; font-weight: 600; color: var(--text-color); margin-bottom: 6px; }
18
  .note__emoji { font-size: 24px; line-height: 1; }
19
  .note__title { font-size: 13px; letter-spacing: .2px; }
20
  .note__content { color: var(--text-color); font-size: 0.95rem; }
 
21
  </style>
22
 
23
-
 
1
  ---
2
+ const { title, emoji, class: className, ...props } = Astro.props;
3
  const wrapperClass = ["note", className].filter(Boolean).join(" ");
4
+ const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
5
  ---
6
  <div class={wrapperClass} {...props}>
7
+ {hasHeader && <div class="note__header">
8
+ {emoji && <span class="note__emoji">{emoji}</span>}
9
  {title && <span class="note__title">{title}</span>}
10
+ </div>}
11
  <div class="note__content">
12
  <slot />
13
  </div>
14
  </div>
15
 
16
  <style>
17
+ .note { background: var(--surface-bg); border-left: 2px solid var(--border-color); border-radius: 4px; padding: 10px 14px; margin: 12px 0; }
18
  .note__header { display: flex; align-items: center; gap: 6px; font-weight: 600; color: var(--text-color); margin-bottom: 6px; }
19
  .note__emoji { font-size: 24px; line-height: 1; }
20
  .note__title { font-size: 13px; letter-spacing: .2px; }
21
  .note__content { color: var(--text-color); font-size: 0.95rem; }
22
+ .note__content > p:last-of-type { margin-bottom: 0 !important; }
23
  </style>
24
 
 
app/src/components/ThemeToggle.astro CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- import sunIconUrl from "../assets/images/sun.svg?url";
3
- import moonIconUrl from "../assets/images/moon.svg?url";
4
  ---
5
  <button id="theme-toggle" aria-label="Toggle color theme">
6
  <img class="icon light" src={sunIconUrl} alt="light" width="20" height="20" />
 
1
  ---
2
+ import sunIconUrl from "../assets/icones/sun.svg?url";
3
+ import moonIconUrl from "../assets/icones/moon.svg?url";
4
  ---
5
  <button id="theme-toggle" aria-label="Toggle color theme">
6
  <img class="icon light" src={sunIconUrl} alt="light" width="20" height="20" />
app/src/content/article.mdx CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: "From Idea to Interaction:\n A Modern Template for Scientific Writing
3
  "
4
  subtitle: "Markdown-first research article template with math, citations, and interactive figures."
5
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
@@ -23,8 +23,11 @@ import placeholder from "../assets/images/placeholder.png";
23
  import audioDemo from "../assets/audio/audio-example.wav";
24
  import Aside from "../components/Aside.astro";
25
  import visualPoster from "../assets/images/visual-vocabulary-poster.png";
 
26
  import BestPractices from "./chapters/best-pratices.mdx";
27
- import WritingYourContent from "./chapters/writing-you-content.mdx";
 
 
28
 
29
  <Aside>
30
  Welcome to this single-page research article template built with **Markdown**.
@@ -86,7 +89,7 @@ Not every contribution fits a PDF. Treat demos, visualizations, and interactive
86
  This project is heavely inspired by [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship.
87
 
88
 
89
- ### Notable examples of excellent scientific articles
90
 
91
  A short, curated list of well‑designed and often interactive work:
92
 
@@ -96,56 +99,18 @@ A short, curated list of well‑designed and often interactive work:
96
  - **ConvNetJS — Neural networks in the browser**: [cs.stanford.edu/people/karpathy/convnetjs](http://cs.stanford.edu/people/karpathy/convnetjs/)
97
  - **Explorable Explanations — Collection**: [explorableexplanations.com](http://explorableexplanations.com/)
98
  - **Distill — Why Momentum Really Works**: [distill.pub/2017/momentum](https://distill.pub/2017/momentum/)
 
99
 
100
-
101
- ## Getting Started
102
-
103
- ### Installation
104
-
105
- <Aside>
106
- ```bash
107
- git lfs install
108
- git lfs pull
109
- cd app
110
- npm install
111
- ```
112
- <Fragment slot="aside">
113
- You can use yarn alternatively to npm.
114
- </Fragment>
115
- </Aside>
116
-
117
-
118
- ### Development
119
-
120
- ```bash
121
- npm run dev
122
- ```
123
-
124
- ### Build
125
-
126
- ```bash
127
- npm run build
128
- ```
129
-
130
- Serving the `dist/` directory on any static host is enough to deliver the site.
131
-
132
- A [slug-title].pdf and thumb.jpg are also generated at build time. You can find them in the public folder.
133
-
134
- ### Deploy
135
-
136
- The easiest way to get online is to clone [this Hugging Face Space](https://huggingface.co/spaces/tfrere/science-blog-template) and push your changes; every push triggers an automatic build and deploy.
137
-
138
- ### Large files (Git LFS)
139
-
140
- **Track binaries** (e.g., `.png`, `.wav`) with **Git LFS** to keep the repository lean. This project is preconfigured to store such files via **LFS**.
141
-
142
 
143
  <WritingYourContent />
144
 
 
 
145
  <BestPractices />
146
 
147
 
148
- ## Conclusions
149
 
150
  This template provides a **practical baseline** for writing and sharing technical articles with **math**, **citations**, and **interactive figures**. **Start simple**, **iterate** on structure and style, and keep content **maintainable** for future readers and collaborators.
151
 
 
1
  ---
2
+ title: "From Idea to Life:\n A Modern Template for Scientific Writing
3
  "
4
  subtitle: "Markdown-first research article template with math, citations, and interactive figures."
5
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
 
23
  import audioDemo from "../assets/audio/audio-example.wav";
24
  import Aside from "../components/Aside.astro";
25
  import visualPoster from "../assets/images/visual-vocabulary-poster.png";
26
+
27
  import BestPractices from "./chapters/best-pratices.mdx";
28
+ import WritingYourContent from "./chapters/writing-your-content.mdx";
29
+ import AvailableBlocks from "./chapters/available-blocks.mdx";
30
+ import GettingStarted from "./chapters/getting-started.mdx";
31
 
32
  <Aside>
33
  Welcome to this single-page research article template built with **Markdown**.
 
89
  This project is heavely inspired by [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship.
90
 
91
 
92
+ {/* ### Notable examples of excellent scientific articles
93
 
94
  A short, curated list of well‑designed and often interactive work:
95
 
 
99
  - **ConvNetJS — Neural networks in the browser**: [cs.stanford.edu/people/karpathy/convnetjs](http://cs.stanford.edu/people/karpathy/convnetjs/)
100
  - **Explorable Explanations — Collection**: [explorableexplanations.com](http://explorableexplanations.com/)
101
  - **Distill — Why Momentum Really Works**: [distill.pub/2017/momentum](https://distill.pub/2017/momentum/)
102
+ */}
103
 
104
+ <GettingStarted />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  <WritingYourContent />
107
 
108
+ <AvailableBlocks />
109
+
110
  <BestPractices />
111
 
112
 
113
+ ## Conclusion
114
 
115
  This template provides a **practical baseline** for writing and sharing technical articles with **math**, **citations**, and **interactive figures**. **Start simple**, **iterate** on structure and style, and keep content **maintainable** for future readers and collaborators.
116
 
app/src/content/chapters/{writing-you-content.mdx → available-blocks.mdx} RENAMED
@@ -1,87 +1,12 @@
1
- {/* IMPORTS */}
2
  import { Image } from 'astro:assets';
3
  import placeholder from '../../assets/images/placeholder.png';
 
 
4
  import Aside from '../../components/Aside.astro';
5
  import Wide from '../../components/Wide.astro';
6
  import FullBleed from '../../components/FullBleed.astro';
7
- import HtmlFragment from '../../components/HtmlFragment.astro';
8
- import audioDemo from '../../assets/audio/audio-example.wav';
9
-
10
- ## Writing Your Content
11
-
12
- ### Introduction
13
-
14
- Your article lives in two places:
15
-
16
- - `app/src/content/` — where you can find the article.mdx, bibliography.bib and html fragments.
17
- - `app/src/assets/` — images, audio, and other static assets. (handled by git lfs)
18
-
19
- This is MDX, its basically a markdown file with html and astro components.
20
- The **initial skeleton** of an article looks like this.
21
-
22
- ```mdx
23
- {/* HEADER */}
24
- ---
25
- title: "This is the main title"
26
- subtitle: "This will be displayed just below the banner"
27
- description: "A modern, MDX-first research article template with math, citations, and interactive figures."
28
- authors:
29
- - "John Doe"
30
- - "Alice Martin"
31
- - "Robert Brown"
32
- affiliation: "Hugging Face"
33
- published: "Feb 19, 2025"
34
- tags:
35
- - research
36
- - template
37
- ogImage: "https://example.com/your-og-image.png"
38
- ---
39
-
40
- {/* IMPORTS */}
41
- import { Image } from 'astro:assets';
42
- import placeholder from '../assets/images/placeholder.jpg';
43
-
44
- {/* CONTENT */}
45
- # Hello, world
46
-
47
- This is a short paragraph written in Markdown. Below is an example image:
48
-
49
- <Image src={placeholder} alt="Example image" />
50
- ```
51
-
52
- ### Chapters
53
 
54
-
55
- **If** your article becomes **too long**, you can **organize** it into **separate chapters**.
56
-
57
- Simply **create a new file** in the `app/src/content/chapters` **directory**.
58
- Then, **include** your new chapter in the main article by adding the following lines:
59
-
60
- ```mdx
61
- import MyChapter from './chapters/my-chapter.mdx';
62
- <MyChapter />
63
- ```
64
-
65
- You can see an example of this in the <a href="">`app/src/content/chapters/best-pratices.mdx`</a> file.
66
-
67
- ### Theme
68
-
69
- All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose. Feel free to update this color to match your **brand**.
70
-
71
- You can **override** the theme by changing the main variable in the `app/src/styles/_variables.css` file.
72
-
73
- You can use the **color picker** below to choose the right color.
74
- <Aside>
75
- <div className="">
76
- <HtmlFragment src="color-picker.html" />
77
- </div>
78
- <Fragment slot="aside">
79
- There is also a color <a href="#use-the-right-color">palette generator</a> that will help you choose the right color for your data visualizations.
80
- </Fragment>
81
- </Aside>
82
-
83
-
84
- ### Available blocks
85
 
86
  All the following blocks are available in the article.mdx file. You can also create your own blocks by creating a new component in the components folder.
87
 
@@ -90,9 +15,10 @@ All the following blocks are available in the article.mdx file. You can also cre
90
  <a className="button" href="#math">Math</a>
91
  <a className="button" href="#images">Images</a>
92
  <a className="button" href="#code-blocks">Code</a>
 
93
  <a className="button" href="#citations-and-notes">Citations & notes</a>
94
- <a className="button" href="#asides">Asides</a>
95
- <a className="button" href="#minimal-table">Table</a>
96
  <a className="button" href="#audio">Audio</a>
97
  <a className="button" href="#embeds">Embeds</a>
98
  </div>
@@ -344,9 +270,6 @@ Plotly version
344
  <div className="plot-card">
345
  <HtmlFragment src="line.html" />
346
  </div>
347
- <div className="plot-card">
348
- <HtmlFragment src="bar.html" />
349
- </div>
350
 
351
  <small className="muted">Example</small>
352
  ```mdx
 
 
1
  import { Image } from 'astro:assets';
2
  import placeholder from '../../assets/images/placeholder.png';
3
+ import audioDemo from '../../assets/audio/audio-example.wav';
4
+ import HtmlFragment from '../../components/HtmlFragment.astro';
5
  import Aside from '../../components/Aside.astro';
6
  import Wide from '../../components/Wide.astro';
7
  import FullBleed from '../../components/FullBleed.astro';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ ## Available blocks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  All the following blocks are available in the article.mdx file. You can also create your own blocks by creating a new component in the components folder.
12
 
 
15
  <a className="button" href="#math">Math</a>
16
  <a className="button" href="#images">Images</a>
17
  <a className="button" href="#code-blocks">Code</a>
18
+ <a className="button" href="#mermaid-diagrams">Mermaid</a>
19
  <a className="button" href="#citations-and-notes">Citations & notes</a>
20
+ <a className="button" href="#placement">Placement</a>
21
+ <a className="button" href="#minimal-table">Minimal table</a>
22
  <a className="button" href="#audio">Audio</a>
23
  <a className="button" href="#embeds">Embeds</a>
24
  </div>
 
270
  <div className="plot-card">
271
  <HtmlFragment src="line.html" />
272
  </div>
 
 
 
273
 
274
  <small className="muted">Example</small>
275
  ```mdx
app/src/content/chapters/best-pratices.mdx CHANGED
@@ -1,6 +1,7 @@
1
 
2
  import { Image } from 'astro:assets';
3
  import visualPoster from '../../assets/images/visual-vocabulary-poster.png';
 
4
 
5
 
6
  ## Best Practices
@@ -15,6 +16,26 @@ Favor **concise captions** and callouts that clarify what to look at and why it
15
  **Introduce symbols and variables** the first time they appear, and prefer **well‑known identities** over custom shorthand. When formulas carry the message, add one sentence of **plain‑language interpretation** right after.
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  {/* ### Use the right color
19
  A palette encodes **meaning** (categories, magnitudes, oppositions), preserves **readability** and **accessibility** (sufficient contrast, color‑vision safety), and ensures **perceptually smooth transitions**. The three families below illustrate when to use **categorical**, **sequential**, or **diverging** colors and how they evolve from the same **reference hue**.
20
 
@@ -35,12 +56,11 @@ A palette encodes **meaning** (categories, magnitudes, oppositions), preserves *
35
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
36
 
37
  <figure>
38
- <a href={visualPoster.src} target="_blank" rel="noopener noreferrer">
39
  <Image src={visualPoster} alt="Visual Vocabulary: choosing the right chart by task" />
40
  </a>
41
  <figcaption>
42
- A handy reference to select chart types by purpose. Click to enlarge.
43
- — <a href="https://ft-interactive.github.io/visual-vocabulary/" target="_blank" rel="noopener noreferrer">Website</a>
44
- </figcaption>
45
  </figure>
46
 
 
1
 
2
  import { Image } from 'astro:assets';
3
  import visualPoster from '../../assets/images/visual-vocabulary-poster.png';
4
+ import Note from '../../components/Note.astro';
5
 
6
 
7
  ## Best Practices
 
16
  **Introduce symbols and variables** the first time they appear, and prefer **well‑known identities** over custom shorthand. When formulas carry the message, add one sentence of **plain‑language interpretation** right after.
17
 
18
 
19
+ <div className="note-grid" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; align-items: start;">
20
+ <Note>
21
+ For example, in linear regression with features $x \in \mathbb{R}^d$, weights $w \in \mathbb{R}^d$, and bias $b$, the prediction is:
22
+
23
+ $$
24
+ \hat{y} = w^\top x + b
25
+ $$
26
+
27
+ A common training objective is the mean squared error over $N$ samples:
28
+
29
+ $$
30
+ \mathcal{L}(w,b) = \frac{1}{N} \sum_{i=1}^{N} (w^\top x_i + b - y_i)^2
31
+ $$
32
+
33
+ Interpretation: the model fits a hyperplane that minimizes the average squared prediction error.
34
+ </Note>
35
+
36
+
37
+ </div>
38
+
39
  {/* ### Use the right color
40
  A palette encodes **meaning** (categories, magnitudes, oppositions), preserves **readability** and **accessibility** (sufficient contrast, color‑vision safety), and ensures **perceptually smooth transitions**. The three families below illustrate when to use **categorical**, **sequential**, or **diverging** colors and how they evolve from the same **reference hue**.
41
 
 
56
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
57
 
58
  <figure>
59
+ <a href={"https://raw.githubusercontent.com/Financial-Times/chart-doctor/refs/heads/main/visual-vocabulary/poster.png"} target="_blank" rel="noopener noreferrer">
60
  <Image src={visualPoster} alt="Visual Vocabulary: choosing the right chart by task" />
61
  </a>
62
  <figcaption>
63
+ A handy reference to select chart types by purpose click to enlarge.
64
+ </figcaption>
 
65
  </figure>
66
 
app/src/content/chapters/getting-started.mdx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Aside from '../../components/Aside.astro';
2
+
3
+ ## Getting Started
4
+
5
+ ### Installation
6
+
7
+ <Aside>
8
+ ```bash
9
+ git lfs install
10
+ git lfs pull
11
+ cd app
12
+ npm install
13
+ ```
14
+ <Fragment slot="aside">
15
+ You can also use yarn.
16
+ </Fragment>
17
+ </Aside>
18
+
19
+
20
+ ### Development
21
+
22
+ ```bash
23
+ npm run dev
24
+ ```
25
+
26
+ ### Build
27
+
28
+ ```bash
29
+ npm run build
30
+ ```
31
+
32
+ Serving the `dist/` directory on any static host is enough to deliver the site.
33
+
34
+ A [slug-title].pdf and thumb.jpg are also generated at build time. You can find them in the public folder.
35
+
36
+ ### Deploy
37
+
38
+ The recommended way is to **clone this [🤗 Hugging Face Space](https://huggingface.co/spaces/tfrere/science-blog-template)**.
39
+
40
+ Once you have cloned the Space, **just push your changes** to your repository.
41
+
42
+ **Every push automatically triggers a build and deploy** — no extra configuration needed.
app/src/content/chapters/writing-your-content.mdx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {/* IMPORTS */}
2
+ import { Image } from 'astro:assets';
3
+ import placeholder from '../../assets/images/placeholder.png';
4
+ import Aside from '../../components/Aside.astro';
5
+ import Wide from '../../components/Wide.astro';
6
+ import FullBleed from '../../components/FullBleed.astro';
7
+ import HtmlFragment from '../../components/HtmlFragment.astro';
8
+ import audioDemo from '../../assets/audio/audio-example.wav';
9
+
10
+ ## Writing Your Content
11
+
12
+ ### Introduction
13
+
14
+ Your article lives in two places
15
+
16
+ - `app/src/content/` — where you can find the `article.mdx`, `bibliography.bib` and html fragments.
17
+ - `app/src/assets/` — images, audio, and other static assets. (handled by git lfs)
18
+
19
+ The `article.mdx` file is a markdown file with html and astro components. It is the main file that contains your article.
20
+
21
+ <small className="muted">Example</small>
22
+ ```mdx
23
+ {/* HEADER */}
24
+ ---
25
+ title: "This is the main title"
26
+ subtitle: "This will be displayed just below the banner"
27
+ description: "A modern, MDX-first research article template with math, citations, and interactive figures."
28
+ authors:
29
+ - "John Doe"
30
+ - "Alice Martin"
31
+ - "Robert Brown"
32
+ affiliation: "Hugging Face"
33
+ published: "Feb 19, 2025"
34
+ tags:
35
+ - research
36
+ - template
37
+ ogImage: "https://example.com/your-og-image.png"
38
+ ---
39
+
40
+ {/* IMPORTS */}
41
+ import { Image } from 'astro:assets';
42
+ import placeholder from '../assets/images/placeholder.jpg';
43
+
44
+ {/* CONTENT */}
45
+ # Hello, world
46
+
47
+ This is a short paragraph written in Markdown. Below is an example image:
48
+
49
+ <Image src={placeholder} alt="Example image" />
50
+ ```
51
+
52
+ ### Markdown
53
+
54
+ You can use **markdown** to write your content. Here is the complete [markdown documentation](https://www.markdownguide.org/basic-syntax/).
55
+
56
+ <small className="muted">Example</small>
57
+ ```mdx
58
+ # This is the main title
59
+
60
+ This is a short paragraph containing **bold** and *italic* text.
61
+
62
+ ## This is a second level heading
63
+
64
+ - This is a list item
65
+ - This is a second list item
66
+ - This is a third list item
67
+ ```
68
+
69
+
70
+ ### Chapters
71
+
72
+
73
+ **If** your article becomes **too long** for one file, you can **organize** it into **separate chapters**.
74
+
75
+ Simply **create a new file** in the `app/src/content/chapters` **directory**.
76
+ Then, **include** your new chapter in the main article.
77
+
78
+
79
+ <small className="muted">Example</small>
80
+ ```mdx
81
+ import MyChapter from './chapters/my-chapter.mdx';
82
+
83
+ <MyChapter />
84
+ ```
85
+
86
+ You can see an example of this in the <a target="_blank" href="https://huggingface.co/spaces/tfrere/research-article-template/blob/main/app/src/content/chapters/best-pratices.mdx">app/src/content/chapters/best-pratices.mdx</a> file.
87
+
88
+ ### Table of contents
89
+
90
+ The **Table of contents** is generated **automatically** from your **H2–H4** headings. Keep headings **short** and **descriptive**; links work on **desktop** and **mobile**.
91
+
92
+ ### Theme
93
+
94
+ All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose.
95
+
96
+ You can **update this main color** to match your brand by changing the `--primary-color` variable in the `app/src/styles/_variables.css` file.
97
+
98
+ Use the **color picker** below to see how the primary color affects the theme.
99
+
100
+ #### Brand color
101
+
102
+ <Aside>
103
+ <HtmlFragment src="color-picker.html" />
104
+ <Fragment slot="aside">
105
+ You can use the color picker to select the right color.
106
+
107
+ Here is an example of an <a href="#">interactive element</a>.
108
+ </Fragment>
109
+ </Aside>
110
+
111
+
112
+ #### Color palettes
113
+
114
+ Here is a suggestion of **color palettes** for your **data visualizations** that align with your **brand identity**. These palettes are generated from your `--primary-color`.
115
+
116
+ <HtmlFragment src="palettes.html" />
app/src/content/fragments/banner.html CHANGED
@@ -106,8 +106,8 @@
106
  const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
107
 
108
 
109
- // Group with blend mode so points softly accumulate light
110
- const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points').style('mix-blend-mode', 'screen');
111
 
112
  // Ensure container can host an absolute tooltip
113
  container.style.position = container.style.position || 'relative';
 
106
  const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
107
 
108
 
109
+ // Group for points (no blend mode for better print/PDF visibility)
110
+ const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
111
 
112
  // Ensure container can host an absolute tooltip
113
  container.style.position = container.style.position || 'relative';
app/src/content/fragments/color-picker.html CHANGED
@@ -1,33 +1,36 @@
1
  <div class="color-picker" style="width:100%; margin: 10px 0;">
2
  <style>
3
  .color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
4
- .color-picker .current-card { display:flex; flex-direction: column; align-items:stretch; gap:12px; padding:14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
 
 
5
  .color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
6
  .color-picker .current-swatch { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); }
7
  .color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
8
  .color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
9
  .color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
10
  /* theme preview styles removed */
11
- .color-picker .picker__bar { display:flex; align-items:center; gap:12px; }
12
- .color-picker .picker__label { font-weight:700; font-size: 13px; color: var(--text-color); }
13
- .color-picker .hue-slider { position:relative; height:16px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none; flex: 1 1 320px; min-width: 200px; }
14
  .color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
15
- .color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
16
- .color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); min-width: 54px; text-align: right; }
17
- @media (max-width: 560px) { .color-picker .picker__bar { gap:8px; } }
18
  </style>
19
  <div class="picker__stack">
20
  <div class="current-card">
21
- <div class="current-main">
22
- <div class="current-swatch" aria-label="Current color" title="Current color"></div>
23
- <div class="current-text">
24
- <div class="current-name">—</div>
25
- <div class="current-hex">—</div>
26
- <div class="current-extra current-lch">—</div>
27
- <div class="current-extra current-rgb">—</div>
 
 
28
  </div>
29
  </div>
30
- <div class="picker__bar">
31
  <div class="picker__label">Hue</div>
32
  <div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
33
  <div class="hue-knob"></div>
@@ -57,7 +60,7 @@
57
  };
58
 
59
  // Minimal embedded color-name list (same as palettes)
60
- const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Brutal Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Big Bang Pink","hex":"#ff0099"},{"name":"Flaming Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
61
  if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
62
 
63
  // Shared event bus so multiple instances stay in sync
@@ -95,6 +98,10 @@
95
  const bus = window.__colorPickerBus;
96
  const instanceId = Math.random().toString(36).slice(2);
97
 
 
 
 
 
98
  const getName = (hex) => {
99
  const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : COLOR_NAMES;
100
  if (list && window.chroma) {
@@ -113,8 +120,11 @@
113
  };
114
 
115
  const updateUI = (h, adjusting) => {
116
- const pct = (h / 360) * 100;
117
- if (knob) knob.style.left = pct + '%';
 
 
 
118
  if (hueValue) hueValue.textContent = `${Math.round(h)}°`;
119
  if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h)));
120
  // Use LCH for consistent chroma across hues
@@ -140,15 +150,18 @@
140
  const hoverL = Math.max(0, Math.min(100, L - 8));
141
  const hoverHex = chroma.lch(hoverL, C, h).hex();
142
  const rootEl = document.documentElement;
143
- rootEl.style.setProperty('--primary', baseHex);
144
- rootEl.style.setProperty('--primary-hover', hoverHex);
145
  };
146
 
147
  const getHueFromEvent = (ev) => {
148
  const rect = slider.getBoundingClientRect();
149
  const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
150
  const x = clientX - rect.left;
151
- const t = Math.max(0, Math.min(1, x / rect.width));
 
 
 
152
  return t * 360;
153
  };
154
 
@@ -160,7 +173,7 @@
160
 
161
  // Init depuis la couleur de thème si disponible
162
  try {
163
- const cssPrimary = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
164
  if (cssPrimary) {
165
  const initH = chroma(cssPrimary).get('hsl.h') || 0;
166
  updateUI(initH, false);
 
1
  <div class="color-picker" style="width:100%; margin: 10px 0;">
2
  <style>
3
  .color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
4
+ .color-picker .current-card { display:grid; grid-template-columns: 30% 70%; align-items: center; gap:14px; padding:14px 32px 14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
5
+ .color-picker .current-left { display:flex; flex-direction: column; gap:8px; min-width: 0; }
6
+ .color-picker .current-right { display:flex; flex-direction: column; gap:8px; padding-left: 14px; border-left: 1px solid var(--border-color); }
7
  .color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
8
  .color-picker .current-swatch { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); }
9
  .color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
10
  .color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
11
  .color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
12
  /* theme preview styles removed */
13
+ .color-picker .picker__label { font-weight:700; font-size: 12px; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
14
+ .color-picker .hue-slider { position:relative; height:16px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none; flex: 1 1 auto; min-width: 200px; }
 
15
  .color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
16
+ .color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
17
+ .color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); font-size: 12px; }
18
+ @media (max-width: 720px) { .color-picker .current-card { grid-template-columns: 1fr; } .color-picker .current-right { padding-left: 0; border-left: none; } }
19
  </style>
20
  <div class="picker__stack">
21
  <div class="current-card">
22
+ <div class="current-left">
23
+ <div class="current-main">
24
+ <div class="current-swatch" aria-label="Current color" title="Current color"></div>
25
+ <div class="current-text">
26
+ <div class="current-name">—</div>
27
+ <div class="current-hex">—</div>
28
+ <div class="current-extra current-lch">—</div>
29
+ <div class="current-extra current-rgb">—</div>
30
+ </div>
31
  </div>
32
  </div>
33
+ <div class="current-right">
34
  <div class="picker__label">Hue</div>
35
  <div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
36
  <div class="hue-knob"></div>
 
60
  };
61
 
62
  // Minimal embedded color-name list (same as palettes)
63
+ const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Big bang Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Pink","hex":"#ff0099"},{"name":"Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
64
  if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
65
 
66
  // Shared event bus so multiple instances stay in sync
 
98
  const bus = window.__colorPickerBus;
99
  const instanceId = Math.random().toString(36).slice(2);
100
 
101
+ const getKnobRadius = () => {
102
+ try { const w = knob ? knob.getBoundingClientRect().width : 0; return w ? w / 2 : 8; } catch { return 8; }
103
+ };
104
+
105
  const getName = (hex) => {
106
  const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : COLOR_NAMES;
107
  if (list && window.chroma) {
 
120
  };
121
 
122
  const updateUI = (h, adjusting) => {
123
+ const rect = slider.getBoundingClientRect();
124
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
125
+ const t = Math.max(0, Math.min(1, (h / 360)));
126
+ const leftPx = r + t * Math.max(0, (rect.width - 2 * r));
127
+ if (knob) knob.style.left = (leftPx / rect.width * 100) + '%';
128
  if (hueValue) hueValue.textContent = `${Math.round(h)}°`;
129
  if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h)));
130
  // Use LCH for consistent chroma across hues
 
150
  const hoverL = Math.max(0, Math.min(100, L - 8));
151
  const hoverHex = chroma.lch(hoverL, C, h).hex();
152
  const rootEl = document.documentElement;
153
+ rootEl.style.setProperty('--primary-color', baseHex);
154
+ rootEl.style.setProperty('--primary-color-hover', hoverHex);
155
  };
156
 
157
  const getHueFromEvent = (ev) => {
158
  const rect = slider.getBoundingClientRect();
159
  const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
160
  const x = clientX - rect.left;
161
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
162
+ const effX = Math.max(r, Math.min(rect.width - r, x));
163
+ const denom = Math.max(1, rect.width - 2 * r);
164
+ const t = (effX - r) / denom;
165
  return t * 360;
166
  };
167
 
 
173
 
174
  // Init depuis la couleur de thème si disponible
175
  try {
176
+ const cssPrimary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
177
  if (cssPrimary) {
178
  const initH = chroma(cssPrimary).get('hsl.h') || 0;
179
  updateUI(initH, false);
app/src/content/fragments/d3-bar.html CHANGED
@@ -4,8 +4,8 @@
4
  .d3-bar .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }
5
  .d3-bar .controls select { font-size: 12px; padding: 8px 28px 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background-color: var(--surface-bg); color: var(--text-color); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 12px; -webkit-appearance: none; -moz-appearance: none; appearance: none; cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease; }
6
  [data-theme="dark"] .d3-bar .controls select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
7
- .d3-bar .controls select:hover { border-color: var(--primary); }
8
- .d3-bar .controls select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(232,137,171,.25); outline: none; }
9
  .d3-bar .legend { font-size: 12px; line-height: 1.35; color: var(--text-color); }
10
  </style>
11
  <script>
@@ -31,7 +31,7 @@
31
  const components = [
32
  { key: 'parameters', color: 'rgb(78, 165, 183)' },
33
  { key: 'gradients', color: 'rgb(227, 138, 66)' },
34
- { key: 'optimizer', color: 'var(--primary)' },
35
  { key: 'activations', color: 'rgb(206, 192, 250)' },
36
  ];
37
  const modelSizes = ["1B","3B","8B","70B","405B"];
@@ -84,7 +84,8 @@
84
 
85
  // State
86
  let currentSize = modelSizes[0];
87
- let currentMode = 'none';
 
88
 
89
  // Layout & scales
90
  let width=800, height=360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
@@ -130,7 +131,7 @@
130
  // Axes
131
  gAxes.selectAll('*').remove();
132
  gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
133
- gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('.2f'))).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
134
 
135
  // Axis labels
136
  gAxes.append('text').attr('class','axis-label axis-label--x').attr('x', innerWidth/2).attr('y', innerHeight + 44).attr('text-anchor','middle').style('font-size','12px').style('fill', tickColor).text('Sequence Length');
 
4
  .d3-bar .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }
5
  .d3-bar .controls select { font-size: 12px; padding: 8px 28px 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background-color: var(--surface-bg); color: var(--text-color); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 12px; -webkit-appearance: none; -moz-appearance: none; appearance: none; cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease; }
6
  [data-theme="dark"] .d3-bar .controls select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
7
+ .d3-bar .controls select:hover { border-color: var(--primary-color); }
8
+ .d3-bar .controls select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(232,137,171,.25); outline: none; }
9
  .d3-bar .legend { font-size: 12px; line-height: 1.35; color: var(--text-color); }
10
  </style>
11
  <script>
 
31
  const components = [
32
  { key: 'parameters', color: 'rgb(78, 165, 183)' },
33
  { key: 'gradients', color: 'rgb(227, 138, 66)' },
34
+ { key: 'optimizer', color: 'var(--primary-color)' },
35
  { key: 'activations', color: 'rgb(206, 192, 250)' },
36
  ];
37
  const modelSizes = ["1B","3B","8B","70B","405B"];
 
84
 
85
  // State
86
  let currentSize = modelSizes[0];
87
+ let currentMode = 'selective';
88
+ selRecomp.value = currentMode;
89
 
90
  // Layout & scales
91
  let width=800, height=360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
 
131
  // Axes
132
  gAxes.selectAll('*').remove();
133
  gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
134
+ gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
135
 
136
  // Axis labels
137
  gAxes.append('text').attr('class','axis-label axis-label--x').attr('x', innerWidth/2).attr('y', innerHeight + 44).attr('text-anchor','middle').style('font-size','12px').style('fill', tickColor).text('Sequence Length');
app/src/content/fragments/d3-line.html CHANGED
@@ -21,16 +21,16 @@
21
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
22
  }
23
  .d3-line .d3-line__controls select:hover {
24
- border-color: var(--primary);
25
  }
26
  .d3-line .d3-line__controls select:focus {
27
- border-color: var(--primary);
28
  box-shadow: 0 0 0 3px rgba(232,137,171,.25);
29
  outline: none;
30
  }
31
  .d3-line .d3-line__controls label { gap: 8px; }
32
 
33
- /* Range slider themed with --primary */
34
  .d3-line .d3-line__controls input[type="range"] {
35
  -webkit-appearance: none;
36
  appearance: none;
@@ -51,7 +51,7 @@
51
  width: 16px;
52
  height: 16px;
53
  border-radius: 50%;
54
- background: var(--primary);
55
  border: 2px solid var(--on-primary);
56
  margin-top: -5px;
57
  cursor: pointer;
@@ -65,12 +65,12 @@
65
  width: 16px;
66
  height: 16px;
67
  border-radius: 50%;
68
- background: var(--primary);
69
  border: 2px solid var(--on-primary);
70
  cursor: pointer;
71
  }
72
  /* Improved line color via CSS */
73
- .d3-line .lines path.improved { stroke: var(--primary); }
74
  </style>
75
  <script>
76
  (() => {
@@ -181,7 +181,7 @@
181
 
182
  // Colors
183
  const colorBase = '#64748b'; // slate-500
184
- const colorImproved = 'var(--primary)';
185
  const colorTarget = '#4b5563'; // gray-600
186
  const legendBgLight = 'rgba(255,255,255,0.85)';
187
  const legendBgDark = 'rgba(17,17,23,0.85)';
@@ -221,13 +221,13 @@
221
  .y((d) => yScale(d));
222
 
223
  const pathBase = gLines.append('path').attr('fill', 'none').attr('stroke', colorBase).attr('stroke-width', 2);
224
- const pathImp = gLines.append('path').attr('class', 'improved').attr('fill', 'none').style('stroke', 'var(--primary)').attr('stroke-width', 2);
225
  const pathTgt = gLines.append('path').attr('fill', 'none').attr('stroke', colorTarget).attr('stroke-width', 2).attr('stroke-dasharray', '6,6');
226
 
227
  // Hover elements
228
  const hoverLine = gHover.append('line').attr('stroke-width', 1);
229
  const hoverDotB = gHover.append('circle').attr('r', 3.5).attr('fill', colorBase).attr('stroke', '#fff').attr('stroke-width', 1);
230
- const hoverDotI = gHover.append('circle').attr('class', 'improved').attr('r', 3.5).style('fill', 'var(--primary)').attr('stroke', '#fff').attr('stroke-width', 1);
231
  const hoverDotT = gHover.append('circle').attr('r', 3.5).attr('fill', colorTarget).attr('stroke', '#fff').attr('stroke-width', 1);
232
 
233
  const overlay = gHover.append('rect').attr('fill', 'transparent').style('cursor', 'crosshair');
 
21
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
22
  }
23
  .d3-line .d3-line__controls select:hover {
24
+ border-color: var(--primary-color);
25
  }
26
  .d3-line .d3-line__controls select:focus {
27
+ border-color: var(--primary-color);
28
  box-shadow: 0 0 0 3px rgba(232,137,171,.25);
29
  outline: none;
30
  }
31
  .d3-line .d3-line__controls label { gap: 8px; }
32
 
33
+ /* Range slider themed with --primary-color */
34
  .d3-line .d3-line__controls input[type="range"] {
35
  -webkit-appearance: none;
36
  appearance: none;
 
51
  width: 16px;
52
  height: 16px;
53
  border-radius: 50%;
54
+ background: var(--primary-color);
55
  border: 2px solid var(--on-primary);
56
  margin-top: -5px;
57
  cursor: pointer;
 
65
  width: 16px;
66
  height: 16px;
67
  border-radius: 50%;
68
+ background: var(--primary-color);
69
  border: 2px solid var(--on-primary);
70
  cursor: pointer;
71
  }
72
  /* Improved line color via CSS */
73
+ .d3-line .lines path.improved { stroke: var(--primary-color); }
74
  </style>
75
  <script>
76
  (() => {
 
181
 
182
  // Colors
183
  const colorBase = '#64748b'; // slate-500
184
+ const colorImproved = 'var(--primary-color)';
185
  const colorTarget = '#4b5563'; // gray-600
186
  const legendBgLight = 'rgba(255,255,255,0.85)';
187
  const legendBgDark = 'rgba(17,17,23,0.85)';
 
221
  .y((d) => yScale(d));
222
 
223
  const pathBase = gLines.append('path').attr('fill', 'none').attr('stroke', colorBase).attr('stroke-width', 2);
224
+ const pathImp = gLines.append('path').attr('class', 'improved').attr('fill', 'none').style('stroke', 'var(--primary-color)').attr('stroke-width', 2);
225
  const pathTgt = gLines.append('path').attr('fill', 'none').attr('stroke', colorTarget).attr('stroke-width', 2).attr('stroke-dasharray', '6,6');
226
 
227
  // Hover elements
228
  const hoverLine = gHover.append('line').attr('stroke-width', 1);
229
  const hoverDotB = gHover.append('circle').attr('r', 3.5).attr('fill', colorBase).attr('stroke', '#fff').attr('stroke-width', 1);
230
+ const hoverDotI = gHover.append('circle').attr('class', 'improved').attr('r', 3.5).style('fill', 'var(--primary-color)').attr('stroke', '#fff').attr('stroke-width', 1);
231
  const hoverDotT = gHover.append('circle').attr('r', 3.5).attr('fill', colorTarget).attr('stroke', '#fff').attr('stroke-width', 1);
232
 
233
  const overlay = gHover.append('rect').attr('fill', 'transparent').style('cursor', 'crosshair');
app/src/content/fragments/heatmap.html CHANGED
@@ -26,7 +26,7 @@
26
  { key: 'model', label: 'Model', color: '#a78bfa' },
27
  { key: 'forward', label: 'Forward', color: '#14b8a6' },
28
  { key: 'backward', label: 'Backward', color: '#f59e0b' },
29
- { key: 'gradients', label: 'Gradients', color: 'var(--primary)' },
30
  { key: 'optimization', label: 'Optimization', color: '#10b981' },
31
  { key: 'updated', label: 'Updated', color: '#7c3aed' },
32
  ];
 
26
  { key: 'model', label: 'Model', color: '#a78bfa' },
27
  { key: 'forward', label: 'Forward', color: '#14b8a6' },
28
  { key: 'backward', label: 'Backward', color: '#f59e0b' },
29
+ { key: 'gradients', label: 'Gradients', color: 'var(--primary-color)' },
30
  { key: 'optimization', label: 'Optimization', color: '#10b981' },
31
  { key: 'updated', label: 'Updated', color: '#7c3aed' },
32
  ];
app/src/content/fragments/palettes.html CHANGED
@@ -1,337 +1,116 @@
1
  <div class="palettes" style="width:100%; margin: 10px 0;">
2
  <style>
3
- .palettes .palettes__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
4
- @media (max-width: 820px) { .palettes .palettes__grid { grid-template-columns: 1fr; } }
5
- .palettes .palette-card { position: relative; }
6
- .palettes .palette-card__header { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-top-left-radius: 10px; border-top-right-radius: 10px; background: transparent; }
7
- .palettes .palette-card__badge { position: relative; width: 44px; height: 44px; border-radius: 50%; margin: 14px auto 6px; background: transparent; border: none; box-shadow: none; }
8
- .palettes .palette-card__badge .badge-hole { position:absolute; top:50%; left:50%; width: 26px; height: 26px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--surface-bg); border: none; box-shadow: none; }
9
- .palettes .palette-card__badge .badge-marker { position:absolute; width:6px; height:6px; border-radius:50%; border: none; box-shadow: none; }
10
- .palettes .palette-card__swatches { display: flex; gap: 0; margin-top: 8px; }
11
- .palettes .palette-card__swatches .sw { flex: 1 1 0; height: 28px; min-width: 0; border: none; }
12
- .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 6px; border-bottom-left-radius: 6px; }
13
- .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; }
14
- .palettes .palette-card__footer { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
15
- .palettes .copy-btn { width: 100%; margin: 0; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color)!important; font-size: 12px; cursor: pointer; }
16
- .palettes .palettes__meta { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
17
- .palettes .current-color { display: flex; align-items: center; gap: 10px; }
18
- .palettes .current-swatch { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border-color); }
19
- .palettes .current-text { display: flex; flex-direction: column; line-height: 1.1; }
20
- .palettes .current-name { font-size: 14px; font-weight: 700; color: var(--text-color); }
21
- .palettes .current-hex { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; }
22
- .palettes .cvd-select { padding: 4px 8px; font-size: 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); }
23
  </style>
24
- <div class="palettes__controls" style="display:flex; flex-direction:column; gap:10px; margin-bottom:24px;">
25
- <div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
26
- <div style="font-weight:700;">Pick a hue</div>
27
- <div class="hue-value" style="font-variant-numeric: tabular-nums; color: var(--muted-color);">H 337°</div>
28
- </div>
29
- <div class="hue-slider" style="position:relative; height:18px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none;">
30
- <div class="hue-knob" style="position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2;"></div>
31
- </div>
32
- <div class="palettes__meta">
33
- <div class="current-color">
34
- <div class="current-swatch" aria-label="Current color" title="Current color"></div>
35
- <div class="current-text">
36
- <div class="current-name">—</div>
37
- <div class="current-hex">—</div>
38
- </div>
39
- </div>
40
- <label style="display:flex; align-items:center; gap:8px;">
41
- <span style="font-size:12px; color: var(--muted-color);">Color blindness preview</span>
42
- <select class="cvd-select" title="Choose a color‑blindness simulation">
43
- <option value="none">None</option>
44
- <option value="protanopia">Protanopia (<2%)</option>
45
- <option value="deuteranopia">Deuteranopia (<2%)</option>
46
- <option value="tritanopia">Tritanopia (<0.1%)</option>
47
- <option value="achromatopsia">Monochromacy (<0.1%)</option>
48
- </select>
49
- </label>
50
- </div>
51
- </div>
52
  <div class="palettes__grid"></div>
53
  </div>
54
  <script>
55
  (() => {
56
- const ensureLibs = (cb) => {
57
  const loadScript = (id, src, onload, onerror) => {
58
  let s = document.getElementById(id);
59
  if (s) { return onload && onload(); }
60
- s = document.createElement('script');
61
- s.id = id;
62
- s.src = src;
63
- s.async = true;
64
  if (onload) s.addEventListener('load', onload, { once: true });
65
  if (onerror) s.addEventListener('error', onerror, { once: true });
66
  document.head.appendChild(s);
67
  };
68
- const ensureD3 = (next) => {
69
- if (window.d3) return next();
70
- loadScript('d3-cdn', 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js', next, () => {
71
- loadScript('d3-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js', next);
72
- });
73
- };
74
  const ensureChroma = (next) => {
75
  if (window.chroma) return next();
76
- loadScript('chroma-cdn', 'https://unpkg.com/chroma[email protected]/dist/chroma.min.js', next, () => {
77
- loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', next);
 
 
 
78
  });
79
  };
80
- // ntc.js removed; rely on embedded color-name list
81
- ensureD3(() => ensureChroma(cb));
82
- };
83
 
84
- // Embedded reduced color-name list (~50 items), minified
85
- const COLOR_NAMES=[{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Brutal Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Big Bang Pink","hex":"#ff0099"},{"name":"Flaming Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
86
- window.__colorNames = COLOR_NAMES;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
- const bootstrap = () => {
89
- console.log('[palettes] bootstrap start');
90
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
91
  const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
92
- if (!root || root.dataset.mounted) return; root.dataset.mounted = 'true';
93
-
94
  const grid = root.querySelector('.palettes__grid');
95
- const slider = root.querySelector('.hue-slider');
96
- const knob = root.querySelector('.hue-knob');
97
- const hueValue = root.querySelector('.hue-value');
98
- const currentSwatch = root.querySelector('.current-swatch');
99
- const currentName = root.querySelector('.current-name');
100
- const currentHex = root.querySelector('.current-hex');
101
- const simSelect = root.querySelector('.cvd-select');
102
- console.log('[palettes] elements', { root: !!root, grid: !!grid, slider: !!slider, knob: !!knob, hueValue: !!hueValue });
103
-
104
- // Shared bus to sync with color-picker instances
105
- if (!window.__colorPickerBus) {
106
- window.__colorPickerBus = (() => {
107
- let hue = 337; let adjusting = false; const listeners = new Set();
108
- return {
109
- get: () => ({ hue, adjusting }),
110
- publish: (sourceId, nextHue, isAdjusting) => { hue = ((nextHue % 360) + 360) % 360; adjusting = !!isAdjusting; listeners.forEach(fn => { try { fn({ sourceId, hue, adjusting }); } catch {} }); },
111
- subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }
112
- };
113
- })();
114
- }
115
- const bus = window.__colorPickerBus;
116
- const instanceId = Math.random().toString(36).slice(2);
117
-
118
- // Cards data with full descriptions
119
- const cards = [
120
- { key: 'categorical', title: 'Categorical', desc: 'Categorical colors help users map non-numeric meaning to objects in a visualization. These are designed to be visually distinct from one another. Maximum of six.', generator: (base) => {
121
- // Generate in LCH for perceptually distinct colors
122
- const baseH = chroma(base).get('hsl.h');
123
- const L = 70; // comfortable lightness
124
- const C = 80; // moderate chroma to stay within gamut
125
- return Array.from({ length: 6 }, (_, i) => chroma.lch(L, C, (baseH + i * 60) % 360).hex());
126
- } },
127
- { key: 'sequential', title: 'Sequential', desc: 'Sequential colors have numeric meaning. These are a gradation of colors that go from light to dark. They are used in a heatmap context.', generator: (base) => {
128
- const c = chroma(base).saturate(0.3);
129
- return chroma
130
- .scale([c.brighten(2), c, c.darken(2)])
131
- .mode('lab')
132
- .correctLightness(true)
133
- .colors(6);
134
- } },
135
- { key: 'diverging', title: 'Diverging', desc: 'Diverging colors also have numeric meaning. They’re useful when dealing with negative values or ranges that have two extremes with a baseline in the middle.', generator: (base) => {
136
- const c = chroma(base);
137
- const baseH = c.get('hsl.h');
138
- const compH = (baseH + 180) % 360;
139
- const left = chroma.hsl(compH, 0.75, 0.55);
140
- const right = chroma.hsl(baseH, 0.75, 0.55);
141
- const center = '#ffffff';
142
- // Build two symmetric ramps that meet (without duplicating the neutral)
143
- const leftRamp = chroma.scale([left, center]).mode('lch').correctLightness(true).colors(4);
144
- const rightRamp = chroma.scale([center, right]).mode('lch').correctLightness(true).colors(4);
145
- return [leftRamp[0], leftRamp[1], leftRamp[2], rightRamp[1], rightRamp[2], rightRamp[3]];
146
- } }
147
- ];
148
-
149
- // Render cards scaffolding
150
- const cardSel = d3.select(grid).selectAll('.palette-card').data(cards, d => d.key);
151
- const enter = cardSel.enter().append('div').attr('class', 'palette-card')
152
- .style('border', '1px solid var(--border-color)')
153
- .style('border-radius', '10px')
154
- .style('background', 'var(--surface-bg)')
155
- .style('padding', '16px 14px 12px')
156
- .style('display', 'flex')
157
- .style('flex-direction', 'column')
158
- .style('gap', '10px')
159
- .style('min-height', '240px');
160
-
161
- enter.append('div').attr('class', 'palette-card__header');
162
-
163
- enter.append('div').attr('class', 'palette-card__badge');
164
-
165
- enter.append('div').attr('class', 'palette-card__title')
166
- .style('text-align', 'center')
167
- .style('font-weight', '800')
168
- .style('font-size', '28px')
169
- .text(d => d.title);
170
-
171
- enter.append('div').attr('class', 'palette-card__desc')
172
- .style('text-align', 'center')
173
- .style('color', 'var(--muted-color)')
174
- .style('line-height', '1.6')
175
- .style('font-size', '15px')
176
- .text(d => d.desc);
177
-
178
- const footer = enter.append('div').attr('class', 'palette-card__footer');
179
- footer.append('div').attr('class', 'palette-card__swatches');
180
- footer.append('button').attr('class', 'copy-btn').text('Copy palette');
181
-
182
- // Rendering
183
- const renderPalettes = (h) => {
184
- console.log('[palettes] renderPalettes', h);
185
- const base = chroma.hsl(h, 0.67, 0.72);
186
- // Update CSS variables for live theming only during interaction
187
- try {
188
- if (isAdjusting) {
189
- const baseHex = base.hex();
190
- const hoverHex = chroma(base).darken(0.6).hex();
191
- const rootEl = document.documentElement;
192
- rootEl.style.setProperty('--primary', baseHex);
193
- rootEl.style.setProperty('--primary-hover', hoverHex);
194
- }
195
- } catch (e) {
196
- console.warn('[palettes] failed updating CSS vars', e);
197
- }
198
- const uniformText = (bg) => (chroma(bg).luminance() > 0.45 ? '#111' : '#fff');
199
-
200
- // Update current swatch + name
201
- if (currentSwatch) currentSwatch.style.background = base.hex();
202
- const getName = (hex) => {
203
- // Prefer embedded reduced list
204
- const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : COLOR_NAMES;
205
- if (list && window.chroma) {
206
- let bestName = null; let best = Infinity;
207
- for (let i = 0; i < list.length; i++) {
208
- const item = list[i];
209
- const d = (chroma.deltaE ? chroma.deltaE(hex, item.hex) : chroma.distance(hex, item.hex, 'lab'));
210
- if (d < best) { best = d; bestName = item.name; }
211
- }
212
- if (bestName) return bestName;
213
- }
214
- // Hue-based coarse fallback
215
- const hh = chroma(hex).get('hsl.h') || 0;
216
- const labels = ['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
217
- const idx = Math.round(((hh % 360) / 360) * (labels.length - 1));
218
- return labels[idx];
219
- };
220
- const hexStr = base.hex().toUpperCase();
221
- if (currentName) currentName.textContent = getName(hexStr);
222
- if (currentHex) currentHex.textContent = hexStr;
223
- d3.select(root).selectAll('.palette-card').each(function(d) {
224
- const palette = d.generator(base);
225
- console.log('[palettes] palette', d.key, palette.length, palette);
226
- const sw = d3.select(this).select('.palette-card__swatches');
227
- const data = palette.slice(0, 6);
228
- const s = sw.selectAll('.sw').data(data, (c, i) => i);
229
- const sEnter = s.enter().append('div').attr('class', 'sw');
230
- sEnter.merge(s)
231
- .style('background', c => c)
232
- .text('');
233
- s.exit().remove();
234
-
235
- // Hook up copy button (keeps palette visible)
236
- const btn = d3.select(this).select('.copy-btn');
237
- btn.on('click', async () => {
238
- const json = JSON.stringify(data, null, 2);
239
- try {
240
- await navigator.clipboard.writeText(json);
241
- const old = btn.text(); btn.text('Copied!'); setTimeout(() => btn.text(old), 900);
242
- } catch {
243
- window.prompt('Copy palette', json);
244
- }
245
- });
246
-
247
- // Update header gradient to reflect palette
248
- const header = d3.select(this).select('.palette-card__header');
249
- const grad = `linear-gradient(90deg, ${data.join(',')})`;
250
- header.style('background', grad);
251
-
252
- // Update circular badge
253
- const badge = d3.select(this).select('.palette-card__badge');
254
- if (d.key === 'categorical') {
255
- // Donut full hue circle for categorical
256
- const hueCircle = 'conic-gradient(#f00 0%, #ff0 16.6%, #0f0 33.3%, #0ff 50%, #00f 66.6%, #f0f 83.3%, #f00 100%)';
257
- badge.style('background', hueCircle);
258
- // Clear previous markers
259
- badge.selectAll('.badge-marker').remove();
260
- // Place markers at palette hues
261
- const radius = 21; // outer circle radius
262
- const center = 22; // half of badge size (44)
263
- data.forEach((hex) => {
264
- const h = chroma(hex).get('hsl.h') || 0;
265
- const angle = (h * Math.PI) / 180; // radians
266
- const r = radius - 3; // adjust for 6px marker size
267
- const x = center + r * Math.cos(angle - Math.PI / 2);
268
- const y = center + r * Math.sin(angle - Math.PI / 2);
269
- const mk = document.createElement('div');
270
- mk.className = 'badge-marker';
271
- mk.style.left = `${Math.round(x - 3)}px`;
272
- mk.style.top = `${Math.round(y - 3)}px`;
273
- mk.style.background = "rgba(255,255,255,0.9)";
274
- badge.node().appendChild(mk);
275
- });
276
- // Inner hole to make it a donut
277
- if (!badge.select('.badge-hole').node()) badge.append('div').attr('class', 'badge-hole');
278
- } else {
279
- // Linear gradient left->right for sequential/diverging
280
- const linear = `linear-gradient(90deg, ${data.join(',')})`;
281
- badge.style('background', linear);
282
- badge.selectAll('.badge-marker').remove();
283
- badge.selectAll('.badge-hole').remove();
284
- }
285
  });
286
- };
287
-
288
- // Hue slider behavior
289
- let hue = 337; // initial
290
- let isAdjusting = false; // only update theme while interacting
291
- const setHue = (h) => { hue = (h + 360) % 360; const pct = hue / 360 * 100; if (knob) knob.style.left = pct + '%'; if (hueValue) hueValue.textContent = `H ${Math.round(hue)}°`; console.log('[palettes] setHue', hue, pct); renderPalettes(hue); };
292
- const getHueFromEvent = (ev) => { const rect = slider.getBoundingClientRect(); const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX; const x = clientX - rect.left; const t = Math.max(0, Math.min(1, x / rect.width)); const h = t * 360; console.log('[palettes] getHueFromEvent', { clientX, left: rect.left, width: rect.width, t, h }); return h; };
293
- const onDown = (ev) => { console.log('[palettes] onDown', ev.type); ev.preventDefault(); isAdjusting = true; const h0 = getHueFromEvent(ev); setHue(h0); bus.publish(instanceId, h0, true); const move = (e) => { e.preventDefault && e.preventDefault(); const hh = getHueFromEvent(e); setHue(hh); bus.publish(instanceId, hh, true); }; const up = () => { console.log('[palettes] onUp'); isAdjusting = false; bus.publish(instanceId, hue, false); window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); }; window.addEventListener('mousemove', move, { passive: false }); window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('mouseup', up, { once: true }); window.addEventListener('touchend', up, { once: true }); };
294
- if (slider) { slider.addEventListener('mousedown', onDown); slider.addEventListener('touchstart', onDown, { passive: false }); console.log('[palettes] listeners attached'); } else { console.warn('[palettes] slider not found'); }
295
 
296
- // Subscribe to bus to receive updates from other pickers
297
- const unsubscribe = bus.subscribe(({ sourceId, hue: H, adjusting }) => { if (sourceId === instanceId) return; isAdjusting = adjusting; setHue(H); });
298
-
299
- // Color-vision simulation filters (SVG)
300
- const injectFilters = () => {
301
- if (document.getElementById('cvd-filters')) return;
302
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
303
- svg.setAttribute('id', 'cvd-filters');
304
- svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.position = 'absolute';
305
- svg.innerHTML = `
306
- <filter id="cvd-protanopia"><feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/></filter>
307
- <filter id="cvd-deuteranopia"><feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/></filter>
308
- <filter id="cvd-tritanopia"><feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/></filter>
309
- <filter id="cvd-achromatopsia"><feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/></filter>
310
- `;
311
- document.body.appendChild(svg);
312
- };
313
- injectFilters();
314
-
315
- const applySimulation = (mode) => {
316
- if (!grid) return;
317
- if (!mode || mode === 'none') { grid.style.filter = 'none'; return; }
318
- grid.style.filter = `url(#cvd-${mode})`;
319
  };
320
- if (simSelect) simSelect.addEventListener('change', () => applySimulation(simSelect.value));
321
-
322
- console.log('[palettes] initial render');
323
- // Initialize from shared hue without updating CSS variables
324
- const shared = bus.get();
325
- isAdjusting = false;
326
- setHue(shared && typeof shared.hue === 'number' ? shared.hue : hue);
327
- applySimulation('none');
328
 
329
- // Fixed 3 columns layout
330
- grid.style.gridTemplateColumns = '1fr 1fr 1fr';
 
 
331
  };
332
 
333
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureLibs(bootstrap), { once: true });
334
- else ensureLibs(bootstrap);
335
  })();
336
  </script>
337
 
 
1
  <div class="palettes" style="width:100%; margin: 10px 0;">
2
  <style>
3
+ .palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
4
+ .palettes .palette-card { position: relative; display: grid; grid-template-columns: 260px 1fr auto; align-items: center; gap: 14px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; }
5
+ /* removed circular badge */
6
+ .palettes .palette-card__swatches { display: flex; gap: 0; margin: 0; height: 40px; overflow: hidden; border-radius: 8px; }
7
+ .palettes .palette-card__swatches .sw { flex: 1 1 0; height: 100%; min-width: 0; border: none; }
8
+ .palettes .palette-card__content { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; justify-content: center; min-width: 0; padding-left: 12px; border-left: 1px solid var(--border-color); }
9
+ .palettes .palette-card__actions { display: flex; align-items: center; justify-content: flex-end; justify-self: end; }
10
+ .palettes .copy-btn { margin: 0; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color)!important; font-size: 12px; cursor: pointer; transition: background .18s ease, color .18s ease, border-color .18s ease; }
11
+ .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; }
12
+ .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
13
+ @media (max-width: 640px) {
14
+ .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
15
+ .palettes .palette-card__swatches { height: 36px; border-radius: 8px; }
16
+ .palettes .palette-card__content { border-left: none; padding-left: 0; }
17
+ .palettes .palette-card__actions { justify-self: start; }
18
+ }
 
 
 
 
19
  </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  <div class="palettes__grid"></div>
21
  </div>
22
  <script>
23
  (() => {
 
24
  const loadScript = (id, src, onload, onerror) => {
25
  let s = document.getElementById(id);
26
  if (s) { return onload && onload(); }
27
+ s = document.createElement('script'); s.id = id; s.src = src; s.async = true;
 
 
 
28
  if (onload) s.addEventListener('load', onload, { once: true });
29
  if (onerror) s.addEventListener('error', onerror, { once: true });
30
  document.head.appendChild(s);
31
  };
 
 
 
 
 
 
32
  const ensureChroma = (next) => {
33
  if (window.chroma) return next();
34
+ const tryReady = () => { if (window.chroma) next(); else setTimeout(tryReady, 25); };
35
+ const existing = document.getElementById('chroma-cdn') || document.getElementById('chroma-cdn-fallback');
36
+ if (existing) { tryReady(); return; }
37
+ loadScript('chroma-cdn', 'https://unpkg.com/[email protected]/dist/chroma.min.js', tryReady, () => {
38
+ loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', tryReady);
39
  });
40
  };
 
 
 
41
 
42
+ const cards = [
43
+ { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. <strong>Up to 6</strong>.', generator: (baseHex) => {
44
+ const base = chroma(baseHex);
45
+ const lc = base.lch();
46
+ const baseH = base.get('hsl.h') || 0;
47
+ const L = Math.max(40, Math.min(85, lc[0] || 70));
48
+ const C = Math.max(40, Math.min(90, lc[1] || 80));
49
+ const offsets = [180, 120, -120, 60, -60];
50
+ const rest = offsets.map(off => chroma.lch(L, C, (baseH + off + 360) % 360).hex());
51
+ return [base.hex(), ...rest];
52
+ }},
53
+ { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>light to dark</strong>; ideal for <strong>heatmaps</strong>.', generator: (baseHex) => {
54
+ const c = chroma(baseHex).saturate(0.3);
55
+ return chroma.scale([c.brighten(2), c, c.darken(2)]).mode('lab').correctLightness(true).colors(6);
56
+ }},
57
+ { key: 'diverging', title: 'Diverging', desc: 'For <strong>centered ranges</strong> with <strong>two extremes</strong> (e.g., negatives/positives) around a <strong>baseline</strong>.', generator: (baseHex) => {
58
+ const baseH = chroma(baseHex).get('hsl.h');
59
+ const compH = (baseH + 180) % 360;
60
+ const left = chroma.hsl(compH, 0.75, 0.55);
61
+ const right = chroma.hsl(baseH, 0.75, 0.55);
62
+ const center = '#ffffff';
63
+ const leftRamp = chroma.scale([left, center]).mode('lch').correctLightness(true).colors(4);
64
+ const rightRamp = chroma.scale([center, right]).mode('lch').correctLightness(true).colors(4);
65
+ return [leftRamp[0], leftRamp[1], leftRamp[2], rightRamp[1], rightRamp[2], rightRamp[3]];
66
+ }}
67
+ ];
68
+
69
+ const getCssPrimary = () => {
70
+ try { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); } catch { return ''; }
71
+ };
72
 
73
+ const render = () => {
 
74
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
75
  const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
76
+ if (!root) return;
 
77
  const grid = root.querySelector('.palettes__grid');
78
+ if (!grid) return;
79
+ grid.innerHTML = '';
80
+ const css = getCssPrimary();
81
+ const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
82
+
83
+ cards.forEach((c) => {
84
+ const card = document.createElement('div'); card.className = 'palette-card';
85
+ const sw = document.createElement('div'); sw.className = 'palette-card__swatches';
86
+ const colors = c.generator(baseHex).slice(0, 6);
87
+ colors.forEach(col => { const d = document.createElement('div'); d.className = 'sw'; d.style.background = col; sw.appendChild(d); });
88
+
89
+ const content = document.createElement('div'); content.className = 'palette-card__content';
90
+ const title = document.createElement('div'); title.className = 'palette-card__title'; title.style.textAlign = 'left'; title.style.fontWeight = '800'; title.style.fontSize = '15px'; title.textContent = c.title;
91
+ const desc = document.createElement('div'); desc.className = 'palette-card__desc'; desc.style.textAlign = 'left'; desc.style.color = 'var(--muted-color)'; desc.style.lineHeight = '1.5'; desc.style.fontSize = '12px'; desc.innerHTML = c.desc;
92
+ const actions = document.createElement('div'); actions.className = 'palette-card__actions';
93
+ const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'Copy';
94
+ btn.addEventListener('click', async () => {
95
+ const json = JSON.stringify(colors, null, 2);
96
+ try { await navigator.clipboard.writeText(json); const old = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = old, 900); } catch { window.prompt('Copy palette', json); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  });
 
 
 
 
 
 
 
 
 
98
 
99
+ content.appendChild(title); content.appendChild(desc);
100
+ actions.appendChild(btn);
101
+ card.appendChild(sw); card.appendChild(content); card.appendChild(actions);
102
+ grid.appendChild(card);
103
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  };
 
 
 
 
 
 
 
 
105
 
106
+ const bootstrap = () => {
107
+ render();
108
+ const mo = new MutationObserver(() => render());
109
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
110
  };
111
 
112
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureChroma(bootstrap), { once: true });
113
+ else ensureChroma(bootstrap);
114
  })();
115
  </script>
116
 
app/src/pages/index.astro CHANGED
@@ -82,11 +82,11 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
82
  <section class="content-grid">
83
  <aside class="toc">
84
  <div class="title">Table of Contents</div>
85
- <div id="toc-placeholder"></div>
86
  </aside>
87
  <details class="toc-mobile">
88
  <summary>Table of Contents</summary>
89
- <div id="toc-mobile-placeholder"></div>
90
  </details>
91
  <main>
92
  <Article />
@@ -193,24 +193,64 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
193
  <script>
194
  // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
195
  const buildTOC = () => {
196
- const holder = document.getElementById('toc-placeholder');
197
- const holderMobile = document.getElementById('toc-mobile-placeholder');
198
- if ((holder && holder.children.length) && (holderMobile && holderMobile.children.length)) return;
 
 
199
  const articleRoot = document.querySelector('section.content-grid main');
200
  if (!articleRoot) return;
201
  const headings = articleRoot.querySelectorAll('h2, h3, h4');
202
  if (!headings.length) return;
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  const nav = document.createElement('nav');
205
  let ulStack = [document.createElement('ul')];
206
  nav.appendChild(ulStack[0]);
207
 
208
  const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
209
  let prev = 2;
210
- headings.forEach((h) => {
211
  const lvl = levelOf(h.tagName);
212
- // ensure id exists
213
- if (!h.id) h.id = h.textContent.toLowerCase().replace(/\s+/g, '_');
214
  // adjust depth
215
  while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
216
  while (lvl < prev) { ulStack.pop(); prev--; }
@@ -230,11 +270,11 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
230
  ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
231
  ];
232
  const onScroll = () => {
233
- for (let i = headings.length - 1; i >= 0; i--) {
234
- const top = headings[i].getBoundingClientRect().top;
235
  if (top - 60 <= 0) {
236
  links.forEach(l => l.classList.remove('active'));
237
- const id = '#' + headings[i].id;
238
  const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
239
  actives.forEach(a => a.classList.add('active'));
240
  break;
 
82
  <section class="content-grid">
83
  <aside class="toc">
84
  <div class="title">Table of Contents</div>
85
+ <div id="article-toc-placeholder"></div>
86
  </aside>
87
  <details class="toc-mobile">
88
  <summary>Table of Contents</summary>
89
+ <div id="article-toc-mobile-placeholder"></div>
90
  </details>
91
  <main>
92
  <Article />
 
193
  <script>
194
  // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
195
  const buildTOC = () => {
196
+ const holder = document.getElementById('article-toc-placeholder');
197
+ const holderMobile = document.getElementById('article-toc-mobile-placeholder');
198
+ // Always rebuild TOC to avoid stale entries
199
+ if (holder) holder.innerHTML = '';
200
+ if (holderMobile) holderMobile.innerHTML = '';
201
  const articleRoot = document.querySelector('section.content-grid main');
202
  if (!articleRoot) return;
203
  const headings = articleRoot.querySelectorAll('h2, h3, h4');
204
  if (!headings.length) return;
205
 
206
+ // Filter out headings that should not appear in TOC
207
+ const normalize = (s) => String(s || '')
208
+ .toLowerCase()
209
+ .replace(/[^a-z0-9]+/g, ' ')
210
+ .trim();
211
+ const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
212
+ const shouldSkip = (h) => {
213
+ const t = h.textContent || '';
214
+ const id = String(h.id || '');
215
+ const slug = normalize(t).replace(/\s+/g, '_');
216
+ if (isTocLabel(t)) return true;
217
+ if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
218
+ if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
219
+ return false;
220
+ };
221
+ const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
222
+ if (!headingsArr.length) return;
223
+
224
+ // Ensure unique ids for headings (deduplicate duplicates)
225
+ const usedIds = new Set<string>();
226
+ const slugify = (s: string) => String(s || '')
227
+ .toLowerCase()
228
+ .trim()
229
+ .replace(/\s+/g, '_')
230
+ .replace(/[^a-z0-9_\-]/g, '');
231
+ headingsArr.forEach((h) => {
232
+ let id = (h.id || '').trim();
233
+ if (!id) {
234
+ const base = slugify(h.textContent || '');
235
+ id = base || 'section';
236
+ }
237
+ let candidate = id;
238
+ let n = 2;
239
+ while (usedIds.has(candidate)) {
240
+ candidate = `${id}-${n++}`;
241
+ }
242
+ if (h.id !== candidate) h.id = candidate;
243
+ usedIds.add(candidate);
244
+ });
245
+
246
  const nav = document.createElement('nav');
247
  let ulStack = [document.createElement('ul')];
248
  nav.appendChild(ulStack[0]);
249
 
250
  const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
251
  let prev = 2;
252
+ headingsArr.forEach((h) => {
253
  const lvl = levelOf(h.tagName);
 
 
254
  // adjust depth
255
  while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
256
  while (lvl < prev) { ulStack.pop(); prev--; }
 
270
  ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
271
  ];
272
  const onScroll = () => {
273
+ for (let i = headingsArr.length - 1; i >= 0; i--) {
274
+ const top = headingsArr[i].getBoundingClientRect().top;
275
  if (top - 60 <= 0) {
276
  links.forEach(l => l.classList.remove('active'));
277
+ const id = '#' + headingsArr[i].id;
278
  const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
279
  actives.forEach(a => a.classList.add('active'));
280
  break;
app/src/styles/_base.css CHANGED
@@ -42,8 +42,8 @@ html { font-size: 14px; line-height: 1.6; }
42
  margin: var(--spacing-8) 0 var(--spacing-4);
43
  }
44
 
45
- .content-grid main a { color: var(--primary); text-decoration: none; border-bottom: 1px solid var(--link-underline); }
46
- .content-grid main a:hover { color: var(--primary-hover); border-bottom: 1px solid var(--link-underline-hover); }
47
 
48
  /* Do not underline heading links inside the article (not the TOC) */
49
  .content-grid main h2 a,
@@ -178,7 +178,7 @@ figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); mar
178
  /* ============================================================================ */
179
  button, .button {
180
  appearance: none;
181
- background: linear-gradient(15deg, var(--primary) 0%, var(--primary-hover) 35%);
182
  color: white!important;
183
  border: 1px solid transparent;
184
  border-radius: 6px;
@@ -239,6 +239,30 @@ button:disabled, .button:disabled {
239
  --link-underline: rgba(0,0,0,.3);
240
  --link-underline-hover: rgba(0,0,0,.4);
241
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
 
244
  .muted {
 
42
  margin: var(--spacing-8) 0 var(--spacing-4);
43
  }
44
 
45
+ .content-grid main a { color: var(--primary-color); text-decoration: none; border-bottom: 1px solid var(--link-underline); }
46
+ .content-grid main a:hover { color: var(--primary-color-hover); border-bottom: 1px solid var(--link-underline-hover); }
47
 
48
  /* Do not underline heading links inside the article (not the TOC) */
49
  .content-grid main h2 a,
 
178
  /* ============================================================================ */
179
  button, .button {
180
  appearance: none;
181
+ background: linear-gradient(15deg, var(--primary-color) 0%, var(--primary-color-hover) 35%);
182
  color: white!important;
183
  border: 1px solid transparent;
184
  border-radius: 6px;
 
239
  --link-underline: rgba(0,0,0,.3);
240
  --link-underline-hover: rgba(0,0,0,.4);
241
  }
242
+
243
+ /* Force single column to reduce widows/orphans and awkward breaks */
244
+ .content-grid { grid-template-columns: 1fr !important; }
245
+ .toc, .right-aside, .toc-mobile { display: none !important; }
246
+ main > nav:first-of-type { display: none !important; }
247
+
248
+ /* Avoid page breaks inside complex visual blocks */
249
+ .hero,
250
+ .hero-banner,
251
+ .d3-galaxy,
252
+ .d3-galaxy svg,
253
+ .plot-card,
254
+ .js-plotly-plot,
255
+ figure,
256
+ pre,
257
+ table,
258
+ blockquote,
259
+ .wide,
260
+ .full-bleed {
261
+ break-inside: avoid;
262
+ page-break-inside: avoid;
263
+ }
264
+ /* Prefer keeping header+lead together */
265
+ .hero { page-break-after: avoid; }
266
  }
267
 
268
  .muted {
app/src/styles/_variables.css CHANGED
@@ -9,8 +9,8 @@
9
  --neutral-200: rgb(245, 245, 245);
10
 
11
  /* Primary brand color */
12
- --primary: rgb(232, 137, 171);
13
- --primary-hover: rgb(212, 126, 156);
14
  --on-primary: #ffffff;
15
 
16
  --text-color: rgba(0,0,0,.85);
@@ -20,8 +20,8 @@
20
  /* Light surfaces & links */
21
  --surface-bg: #fafafa;
22
  --code-bg: #f6f8fa;
23
- --link-underline: var(--primary); /* based on --primary */
24
- --link-underline-hover: var(--primary-hover);
25
 
26
  --spacing-1: 8px;
27
  --spacing-2: 12px;
@@ -42,8 +42,8 @@
42
  --surface-bg: #12151b;
43
  --code-bg: #12151b;
44
  /* Primary in dark mode */
45
- --primary: rgb(232, 137, 171);
46
- --primary-hover: rgb(212, 126, 156);
47
  --on-primary: #0f1115;
48
 
49
  color-scheme: dark;
 
9
  --neutral-200: rgb(245, 245, 245);
10
 
11
  /* Primary brand color */
12
+ --primary-color: rgb(232, 137, 171);
13
+ --primary-color-hover: rgb(212, 126, 156);
14
  --on-primary: #ffffff;
15
 
16
  --text-color: rgba(0,0,0,.85);
 
20
  /* Light surfaces & links */
21
  --surface-bg: #fafafa;
22
  --code-bg: #f6f8fa;
23
+ --link-underline: var(--primary-color); /* based on --primary-color */
24
+ --link-underline-hover: var(--primary-color-hover);
25
 
26
  --spacing-1: 8px;
27
  --spacing-2: 12px;
 
42
  --surface-bg: #12151b;
43
  --code-bg: #12151b;
44
  /* Primary in dark mode */
45
+ --primary-color: rgb(232, 137, 171);
46
+ --primary-color-hover: rgb(212, 126, 156);
47
  --on-primary: #0f1115;
48
 
49
  color-scheme: dark;
app/src/styles/components/_poltly.css CHANGED
@@ -13,8 +13,8 @@
13
  .plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
14
  .plotly_slider { display: flex; align-items: center; gap: 10px; }
15
  .plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
16
- .plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); cursor: pointer; }
17
- .plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary); cursor: pointer; }
18
  .plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
19
 
20
  /* ---------------------------------------------------------------------------- */
 
13
  .plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
14
  .plotly_slider { display: flex; align-items: center; gap: 10px; }
15
  .plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
16
+ .plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
17
+ .plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
18
  .plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
19
 
20
  /* ---------------------------------------------------------------------------- */