thibaud frere commited on
Commit
b6281fd
·
1 Parent(s): 0e04b08
app/src/components/HtmlEmbed.astro CHANGED
@@ -64,7 +64,7 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
64
  </script>
65
 
66
  <style>
67
- .html-embed { margin: 12px 0; }
68
  .html-embed__title {
69
  text-align: left;
70
  font-weight: 600;
@@ -81,6 +81,7 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
81
  .html-embed__card.is-frameless {
82
  background: transparent;
83
  border-color: transparent;
 
84
  }
85
  .html-embed__desc {
86
  text-align: left;
 
64
  </script>
65
 
66
  <style>
67
+ .html-embed { margin: 0; }
68
  .html-embed__title {
69
  text-align: left;
70
  font-weight: 600;
 
81
  .html-embed__card.is-frameless {
82
  background: transparent;
83
  border-color: transparent;
84
+ padding: 0;
85
  }
86
  .html-embed__desc {
87
  text-align: left;
app/src/components/Seo.astro CHANGED
@@ -29,6 +29,7 @@ const jsonLd = {
29
  image: ogImage ? [ogImage] : undefined,
30
  };
31
  ---
 
32
  <meta name="description" content={description} />
33
  <link rel="canonical" href={url} />
34
 
 
29
  image: ogImage ? [ogImage] : undefined,
30
  };
31
  ---
32
+ <title>{title}</title>
33
  <meta name="description" content={description} />
34
  <link rel="canonical" href={url} />
35
 
app/src/content/chapters/available-blocks.mdx CHANGED
@@ -358,26 +358,27 @@ import HtmlEmbed from '../components/HtmlEmbed.astro'
358
 
359
  You can embed external content in your article using **iframes**. For example, **TrackIO or github code embeds** can be used this way.
360
 
 
361
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
362
 
363
- <iframe className="html-embed__card" src="https://trackio-documentation.hf.space/?project=fake-training-750735&metrics=train_loss,train_accuracy&sidebar=hidden&lang=en" width="100%" height="660" frameborder="0"></iframe>
 
 
 
364
 
 
 
 
 
 
 
 
 
 
365
 
366
  <small className="muted">Example</small>
367
  ```mdx
368
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
369
  <iframe src="https://trackio-documentation.hf.space/?project=fake-training-750735&metrics=train_loss,train_accuracy&sidebar=hidden&lang=en" width="100%" height="600" frameborder="0"></iframe>
370
- ```
371
-
372
- ### Gradio
373
-
374
- You can also embed **gradio** apps.
375
-
376
- <gradio-app theme_mode="light" space="gradio/hello_world"></gradio-app>
377
-
378
-
379
-
380
- <small className="muted">Example</small>
381
- ```mdx
382
- <gradio-app theme_mode="light" space="gradio/hello_world"></gradio-app>
383
  ```
 
358
 
359
  You can embed external content in your article using **iframes**. For example, **TrackIO or github code embeds** can be used this way.
360
 
361
+ <small className="muted">Github code embed</small>
362
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
363
 
364
+ <small className="muted">TrackIO embed</small>
365
+ <div className="">
366
+ <iframe src="https://trackio-documentation.hf.space/?project=fake-training-750735&metrics=train_loss,train_accuracy&sidebar=hidden&lang=en" width="100%" height="660" frameborder="0"></iframe>
367
+ </div>
368
 
369
+ <small className="muted">Gradio embed</small>
370
+ <div className="">
371
+ <iframe
372
+ src="https://gradio-hello-world.hf.space"
373
+ width="100%"
374
+ height="380"
375
+ frameborder="0"
376
+ ></iframe>
377
+ </div>
378
 
379
  <small className="muted">Example</small>
380
  ```mdx
381
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
382
  <iframe src="https://trackio-documentation.hf.space/?project=fake-training-750735&metrics=train_loss,train_accuracy&sidebar=hidden&lang=en" width="100%" height="600" frameborder="0"></iframe>
383
+ <iframe src="https://gradio-hello-world.hf.space" width="100%" height="380" frameborder="0"></iframe>
 
 
 
 
 
 
 
 
 
 
 
 
384
  ```
app/src/content/embeds/d3-line.html CHANGED
@@ -191,6 +191,8 @@
191
  let runList = [];
192
  let runOrder = [];
193
  const dataByMetric = new Map(); // metric => { run => [{step,value}] }
 
 
194
 
195
  // Scales and layout
196
  let width = 800, height = 360;
@@ -231,14 +233,19 @@
231
  yScale.range([innerHeight, 0]);
232
 
233
  // Compute integer ticks for Y
234
- const yDomain = yScale.domain();
235
- const yMin = Math.min(yDomain[0], yDomain[1]);
236
- const yMax = Math.max(yDomain[0], yDomain[1]);
237
- let yStep = Math.max(1, Math.round((yMax - yMin) / 6));
238
- if (!isFinite(yStep) || yStep <= 0) yStep = 1;
239
  let yIntTicks = [];
240
- for (let v = Math.ceil(yMin); v <= Math.floor(yMax); v += yStep) { yIntTicks.push(v); }
241
- if (yIntTicks.length === 0) { yIntTicks = [Math.round(yMin), Math.round(yMax)]; }
 
 
 
 
 
 
 
 
 
 
242
 
243
  // Grid (horizontal)
244
  gGrid.selectAll('*').remove();
@@ -255,7 +262,18 @@
255
 
256
  // Axes
257
  gAxes.selectAll('*').remove();
258
- const xAxis = d3.axisBottom(xScale).ticks(8).tickSizeOuter(0);
 
 
 
 
 
 
 
 
 
 
 
259
  const yAxis = d3.axisLeft(yScale).tickValues(yIntTicks).tickSizeOuter(0).tickFormat(d3.format('d'));
260
  gAxes.append('g')
261
  .attr('transform', `translate(0,${innerHeight})`)
@@ -333,7 +351,13 @@
333
  });
334
  if (!isFinite(minStep) || !isFinite(maxStep)) { return; }
335
  xScale.domain([minStep, maxStep]);
336
- yScale.domain(isRank ? [Math.max(maxVal, 1), Math.min(minVal, 0)] : [0, Math.max(1, maxVal)]).nice();
 
 
 
 
 
 
337
 
338
  const { innerWidth, innerHeight } = updateScales();
339
 
@@ -345,6 +369,7 @@
345
  .slice()
346
  .sort((a,b)=>a.step-b.step)
347
  .map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value) } : pt)
 
348
  }));
349
  const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
350
  const gen = isRank ? lineGenStep : lineGenSmooth;
 
191
  let runList = [];
192
  let runOrder = [];
193
  const dataByMetric = new Map(); // metric => { run => [{step,value}] }
194
+ let isRankStrictFlag = false;
195
+ let rankTickMax = 1;
196
 
197
  // Scales and layout
198
  let width = 800, height = 360;
 
233
  yScale.range([innerHeight, 0]);
234
 
235
  // Compute integer ticks for Y
 
 
 
 
 
236
  let yIntTicks = [];
237
+ if (isRankStrictFlag) {
238
+ const maxR = Math.max(1, Math.round(rankTickMax));
239
+ for (let v = 1; v <= maxR; v += 1) yIntTicks.push(v);
240
+ } else {
241
+ const yDomain = yScale.domain();
242
+ const yMin = Math.min(yDomain[0], yDomain[1]);
243
+ const yMax = Math.max(yDomain[0], yDomain[1]);
244
+ let yStep = Math.max(1, Math.round((yMax - yMin) / 6));
245
+ if (!isFinite(yStep) || yStep <= 0) yStep = 1;
246
+ for (let v = Math.ceil(yMin); v <= Math.floor(yMax); v += yStep) { yIntTicks.push(v); }
247
+ if (yIntTicks.length === 0) { yIntTicks = [Math.round(yMin), Math.round(yMax)]; }
248
+ }
249
 
250
  // Grid (horizontal)
251
  gGrid.selectAll('*').remove();
 
262
 
263
  // Axes
264
  gAxes.selectAll('*').remove();
265
+ let xAxis = d3.axisBottom(xScale).tickSizeOuter(0);
266
+ if (isRankStrictFlag) {
267
+ const [dx0, dx1] = xScale.domain();
268
+ const start = Math.ceil(dx0 / 1000) * 1000;
269
+ const end = Math.floor(dx1 / 1000) * 1000;
270
+ const xTicks = [];
271
+ for (let v = start; v <= end; v += 1000) xTicks.push(v);
272
+ if (xTicks.length === 0) xTicks.push(Math.round(dx0));
273
+ xAxis = xAxis.tickValues(xTicks).tickFormat(d3.format('d'));
274
+ } else {
275
+ xAxis = xAxis.ticks(8);
276
+ }
277
  const yAxis = d3.axisLeft(yScale).tickValues(yIntTicks).tickSizeOuter(0).tickFormat(d3.format('d'));
278
  gAxes.append('g')
279
  .attr('transform', `translate(0,${innerHeight})`)
 
351
  });
352
  if (!isFinite(minStep) || !isFinite(maxStep)) { return; }
353
  xScale.domain([minStep, maxStep]);
354
+ if (isRank) {
355
+ rankTickMax = Math.max(1, Math.round(maxVal));
356
+ yScale.domain([rankTickMax, 1]);
357
+ } else {
358
+ yScale.domain([0, Math.max(1, maxVal)]).nice();
359
+ }
360
+ isRankStrictFlag = isRankStrict;
361
 
362
  const { innerWidth, innerHeight } = updateScales();
363
 
 
369
  .slice()
370
  .sort((a,b)=>a.step-b.step)
371
  .map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value) } : pt)
372
+ .filter(pt => !isRankStrict || (pt.step % 1000 === 0))
373
  }));
374
  const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
375
  const gen = isRank ? lineGenStep : lineGenSmooth;
app/src/content/embeds/palettes.html CHANGED
@@ -5,13 +5,24 @@
5
  /* removed circular badge */
6
  .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 8px; margin: 0; }
7
  .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 8px; border: 1px solid var(--border-color); }
8
- .palettes .palette-card__content { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; justify-content: center; min-width: 0; padding-right: 12px; border-right: 1px solid var(--border-color); }
9
- .palettes .palette-card__actions { display: flex; align-items: center; justify-content: flex-start; justify-self: start; }
10
- .palettes .palette-card__actions { align-self: stretch; }
 
 
11
  /* .palettes .copy-btn { margin: 0; padding: 0 10px; height: 100%; border-radius: 8px; } */
12
  /* .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; }
13
  .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
14
  .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
 
 
 
 
 
 
 
 
 
15
  @media (max-width: 640px) {
16
  .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
17
  .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
@@ -20,6 +31,37 @@
20
  }
21
  </style>
22
  <div class="palettes__grid"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  </div>
24
  <script>
25
  (() => {
@@ -154,39 +196,81 @@
154
  const css = getCssPrimary();
155
  const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
156
 
157
- cards.forEach((c) => {
158
- const card = document.createElement('div'); card.className = 'palette-card';
159
- const sw = document.createElement('div'); sw.className = 'palette-card__swatches';
160
  const colors = c.generator(baseHex).slice(0, 6);
161
- colors.forEach(col => { const d = document.createElement('div'); d.className = 'sw'; d.style.background = col; sw.appendChild(d); });
162
-
163
- const content = document.createElement('div'); content.className = 'palette-card__content';
164
- 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;
165
- 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;
166
- const actions = document.createElement('div'); actions.className = 'palette-card__actions';
167
- const btn = document.createElement('button'); btn.className = 'copy-btn button--ghost';
168
- btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
169
- btn.addEventListener('click', async () => {
170
- const json = JSON.stringify(colors, null, 2);
171
- try {
172
- await navigator.clipboard.writeText(json);
173
- const old = btn.innerHTML;
174
- btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
175
- setTimeout(() => btn.innerHTML = old, 900);
176
- } catch {
177
- window.prompt('Copy palette', json);
178
- }
179
- });
180
-
181
- content.appendChild(title); content.appendChild(desc);
182
- actions.appendChild(btn);
183
- card.appendChild(actions); card.appendChild(content); card.appendChild(sw);
184
- grid.appendChild(card);
185
- });
186
  };
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  const bootstrap = () => {
189
  render();
 
 
190
  const mo = new MutationObserver(() => render());
191
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
192
  };
 
5
  /* removed circular badge */
6
  .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 8px; margin: 0; }
7
  .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 8px; border: 1px solid var(--border-color); }
8
+ .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 24px; border-right: 1px solid var(--border-color); }
9
+ .palettes .palette-card__content__info { display: flex; flex-direction: column; }
10
+ .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
11
+ .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
12
+ .palettes .palette-card__actions { display: flex; align-items: center; justify-content: flex-start; justify-self: start; align-self: stretch; }
13
  /* .palettes .copy-btn { margin: 0; padding: 0 10px; height: 100%; border-radius: 8px; } */
14
  /* .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; }
15
  .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
16
  .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
17
+ /* Simulation UI */
18
+ .palettes .palettes__select { width: 100%; max-width: 260px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
19
+ .palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
20
+ /* Page-wide color vision simulation classes */
21
+ html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
22
+ html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
23
+ html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
24
+ html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
25
+ html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
26
  @media (max-width: 640px) {
27
  .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
28
  .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
 
31
  }
32
  </style>
33
  <div class="palettes__grid"></div>
34
+ <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
35
+ <br/>
36
+ <p ><strong>Use color with care.</strong> Color should rarely be the only channel of meaning. Always pair it with text, icons, shape or position. The simulation below helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies. Toggle modes while checking charts, legends and interactions to ensure sufficient contrast and redundant cues.</p>
37
+ <label for="cb-select">Color vision simulation</label>
38
+ <select id="cb-select" class="palettes__select">
39
+ <option value="none">None — full color</option>
40
+ <option value="grayscale">Grayscale — no hue (luminance only)</option>
41
+ <option value="protanopia">Protanopia — reduced/absent reds</option>
42
+ <option value="deuteranopia">Deuteranopia — reduced/absent greens</option>
43
+ <option value="tritanopia">Tritanopia — reduced/absent blues</option>
44
+ <option value="achromatopsia">Achromatopsia — no color at all</option>
45
+ </select>
46
+ <!-- Hidden SVG filters used by the page-wide simulation classes -->
47
+ <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
48
+ <defs>
49
+ <!-- Matrices from common color vision deficiency simulations -->
50
+ <filter id="cb-protanopia">
51
+ <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"/>
52
+ </filter>
53
+ <filter id="cb-deuteranopia">
54
+ <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"/>
55
+ </filter>
56
+ <filter id="cb-tritanopia">
57
+ <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"/>
58
+ </filter>
59
+ <filter id="cb-achromatopsia">
60
+ <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"/>
61
+ </filter>
62
+ </defs>
63
+ </svg>
64
+ </div>
65
  </div>
66
  <script>
67
  (() => {
 
196
  const css = getCssPrimary();
197
  const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
198
 
199
+ const html = cards.map((c) => {
 
 
200
  const colors = c.generator(baseHex).slice(0, 6);
201
+ const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
202
+ return `
203
+ <div class="palette-card" data-colors="${colors.join(',')}">
204
+ <div class="palette-card__content">
205
+ <div class="palette-card__content__info">
206
+ <div class="palette-card__title">${c.title}</div>
207
+ <div class="palette-card__desc">${c.desc}</div>
208
+ </div>
209
+ <button class="copy-btn button--ghost" type="button" aria-label="Copy palette">
210
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
211
+ </button>
212
+ </div>
213
+ <div class="palette-card__actions"></div>
214
+ <div class="palette-card__swatches">${swatches}</div>
215
+ </div>
216
+ `;
217
+ }).join('');
218
+ grid.innerHTML = html;
 
 
 
 
 
 
 
219
  };
220
 
221
+ const MODE_TO_CLASS = { grayscale: 'cb-grayscale', protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
222
+ const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
223
+ const clearCbClasses = () => {
224
+ const rootEl = document.documentElement;
225
+ CLEAR_CLASSES.forEach(cls => rootEl.classList.remove(cls));
226
+ };
227
+ const applyCbClass = (mode) => {
228
+ clearCbClasses();
229
+ const cls = MODE_TO_CLASS[mode];
230
+ if (cls) document.documentElement.classList.add(cls);
231
+ };
232
+ const currentCbMode = () => {
233
+ const rootEl = document.documentElement;
234
+ for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; }
235
+ return 'none';
236
+ };
237
+ const setupCbSim = () => {
238
+ const select = document.getElementById('cb-select');
239
+ if (!select) return;
240
+ try { select.value = currentCbMode(); } catch {}
241
+ select.addEventListener('change', () => applyCbClass(select.value));
242
+ };
243
+
244
+ let copyDelegationSetup = false;
245
+ const setupCopyDelegation = () => {
246
+ if (copyDelegationSetup) return;
247
+ const root = document.querySelector('.palettes');
248
+ if (!root) return;
249
+ const grid = root.querySelector('.palettes__grid');
250
+ if (!grid) return;
251
+ grid.addEventListener('click', async (e) => {
252
+ const target = e.target.closest ? e.target.closest('.copy-btn') : null;
253
+ if (!target) return;
254
+ const card = target.closest('.palette-card');
255
+ if (!card) return;
256
+ const colors = (card.dataset.colors || '').split(',').filter(Boolean);
257
+ const json = JSON.stringify(colors, null, 2);
258
+ try {
259
+ await navigator.clipboard.writeText(json);
260
+ const old = target.innerHTML;
261
+ target.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
262
+ setTimeout(() => target.innerHTML = old, 900);
263
+ } catch {
264
+ window.prompt('Copy palette', json);
265
+ }
266
+ });
267
+ copyDelegationSetup = true;
268
+ };
269
+
270
  const bootstrap = () => {
271
  render();
272
+ setupCbSim();
273
+ setupCopyDelegation();
274
  const mo = new MutationObserver(() => render());
275
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
276
  };
app/src/layouts/ArticleLayout.astro DELETED
@@ -1,70 +0,0 @@
1
- ---
2
- import type { APIContext } from 'astro';
3
-
4
- interface Props {
5
- title: string;
6
- description?: string;
7
- authors?: string[];
8
- published?: string; // ISO or human-readable
9
- tags?: string[];
10
- image?: string; // Recommended absolute URL
11
- }
12
-
13
- const {
14
- title,
15
- description = '',
16
- authors = [],
17
- published,
18
- tags = [],
19
- image,
20
- } = Astro.props as Props;
21
-
22
- const url = Astro.url?.toString?.() ?? '';
23
- const site = (Astro.site ? String(Astro.site) : '') as string;
24
- const ogImage = image && image.length > 0
25
- ? (image.startsWith('http') ? image : (site ? new URL(image, site).toString() : image))
26
- : undefined;
27
-
28
- const jsonLd = {
29
- '@context': 'https://schema.org',
30
- '@type': 'Article',
31
- headline: title,
32
- description: description || undefined,
33
- datePublished: published || undefined,
34
- author: authors.map((name) => ({ '@type': 'Person', name })),
35
- keywords: tags.length ? tags.join(', ') : undefined,
36
- mainEntityOfPage: url || undefined,
37
- image: ogImage ? [ogImage] : undefined,
38
- };
39
- ---
40
- <html lang="en">
41
- <head>
42
- <meta charset="utf-8" />
43
- <meta name="viewport" content="width=device-width, initial-scale=1" />
44
-
45
- <title>{title}</title>
46
- {description && <meta name="description" content={description} />}
47
-
48
- <link rel="canonical" href={url} />
49
-
50
- <meta property="og:type" content="article" />
51
- <meta property="og:title" content={title} />
52
- {description && <meta property="og:description" content={description} />}
53
- <meta property="og:url" content={url} />
54
- {ogImage && <meta property="og:image" content={ogImage} />}
55
- {published && <meta property="article:published_time" content={published} />}
56
- {authors.map(a => <meta property="article:author" content={a} />)}
57
-
58
- <meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
59
- <meta name="twitter:title" content={title} />
60
- {description && <meta name="twitter:description" content={description} />}
61
- {ogImage && <meta name="twitter:image" content={ogImage} />}
62
-
63
- <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
64
- </head>
65
- <body>
66
- <slot />
67
- </body>
68
- </html>
69
-
70
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/pages/index.astro CHANGED
@@ -55,7 +55,6 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
55
  <head>
56
  <meta charset="utf-8" />
57
  <meta name="viewport" content="width=device-width, initial-scale=1" />
58
- <title>{docTitle}</title>
59
  <Seo title={docTitle} description={description} authors={authors} published={published} tags={tags} image={imageAbs} />
60
  <script is:inline>
61
  (() => {
 
55
  <head>
56
  <meta charset="utf-8" />
57
  <meta name="viewport" content="width=device-width, initial-scale=1" />
 
58
  <Seo title={docTitle} description={description} authors={authors} published={published} tags={tags} image={imageAbs} />
59
  <script is:inline>
60
  (() => {