@fluid-theme/core 0.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +6 -29
  2. package/CHANGELOG.md +543 -2
  3. package/CLAUDE.md +193 -14
  4. package/GEMINI.md +6 -45
  5. package/README.md +17 -8
  6. package/assets/js/compare.js +77 -0
  7. package/assets/js/fit-text.js +103 -0
  8. package/assets/js/lightbox.js +153 -0
  9. package/assets/js/scroll-reveal.js +72 -0
  10. package/assets/js/toc-build.mjs +86 -0
  11. package/assets/js/toc.js +384 -0
  12. package/dist/fluid-1x.min.css +4 -0
  13. package/dist/fluid-fonts.min.css +2 -0
  14. package/dist/fluid-legacy-1x.css +3 -0
  15. package/dist/fluid-nav.min.css +3 -0
  16. package/dist/fluid-toc.min.css +3 -0
  17. package/dist/fluid-zero.min.css +3 -0
  18. package/dist/fluid.min.css +3 -2
  19. package/dist/fonts/OFL-newsreader.txt +93 -0
  20. package/dist/fonts/OFL-schibsted-grotesk.txt +93 -0
  21. package/dist/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  22. package/dist/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  23. package/dist/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
  24. package/dist/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
  25. package/fluid-agent-kit/README.md +1 -0
  26. package/fluid-agent-kit/skills/accessibility.md +3 -1
  27. package/fluid-agent-kit/skills/css-conventions.md +22 -5
  28. package/fluid-agent-kit/skills/image-optimization.md +2 -0
  29. package/fluid-agent-kit/skills/preset-composition.md +108 -0
  30. package/package.json +29 -3
  31. package/src/animations/scroll-reveal.css +75 -193
  32. package/src/components/accordion.css +6 -47
  33. package/src/components/alert.css +47 -88
  34. package/src/components/avatar.css +4 -20
  35. package/src/components/badge.css +25 -48
  36. package/src/components/button.css +69 -175
  37. package/src/components/card.css +146 -481
  38. package/src/components/chip.css +46 -12
  39. package/src/components/compare.css +234 -0
  40. package/src/components/dropdown.css +10 -67
  41. package/src/components/empty-state.css +4 -0
  42. package/src/components/form.css +113 -81
  43. package/src/components/gallery.css +252 -0
  44. package/src/components/hero.css +203 -88
  45. package/src/components/indicator.css +23 -117
  46. package/src/components/loading.css +7 -29
  47. package/src/components/modal.css +12 -65
  48. package/src/components/nav-modes.css +261 -0
  49. package/src/components/nav.css +324 -392
  50. package/src/components/search-input.css +4 -2
  51. package/src/components/step-indicator.css +26 -27
  52. package/src/components/table.css +64 -171
  53. package/src/components/toc.css +254 -0
  54. package/src/components/tooltip.css +5 -46
  55. package/src/core/base.css +393 -547
  56. package/src/core/reset.css +17 -5
  57. package/src/core/tokens.css +426 -2276
  58. package/src/fluid.css +258 -17
  59. package/src/fonts/OFL-newsreader.txt +93 -0
  60. package/src/fonts/OFL-schibsted-grotesk.txt +93 -0
  61. package/src/fonts/fluid-fonts.css +55 -0
  62. package/src/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  63. package/src/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  64. package/src/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
  65. package/src/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
  66. package/src/layouts/positioning.css +6 -3
  67. package/src/legacy-1x.css +61 -0
  68. package/src/sections/cta.css +33 -101
  69. package/src/sections/features.css +42 -219
  70. package/src/sections/footer.css +53 -134
  71. package/src/sections/stats.css +40 -153
  72. package/src/sections/team.css +24 -113
  73. package/src/sections/testimonials.css +34 -148
  74. package/src/zero/base.css +166 -0
  75. package/src/zero/tokens.css +64 -0
  76. package/src/zero.css +20 -0
  77. package/src/components/scroll-progress.css +0 -104
  78. package/src/components/settings-panel.css +0 -603
  79. package/src/components/themed-images.css +0 -229
  80. package/src/components/version-badge.css +0 -70
@@ -0,0 +1,384 @@
1
+ /**
2
+ * TOC: scroll spy and the dock for .toc (spec _docs/plans/base-v2.md §13)
3
+ *
4
+ * Progressive enhancement. The list is plain HTML written by the server
5
+ * (assets/js/toc-build.mjs), so it works and reads well with no JS. This
6
+ * script adds:
7
+ * - the spy: aria-current="location" on the link of the section being read:
8
+ * the last heading at or above the activation line. The line sits at 20%
9
+ * of the viewport and moves down to 95% over the last screen of scroll,
10
+ * so short sections at the end of a page each get their turn. Above the
11
+ * first heading, none. After a TOC click or a hash change the target is
12
+ * the current one until the reader next scrolls: a wheel, touch or
13
+ * scrolling key at once, and any other scroll (scrollbar, Tab, find in
14
+ * page, a screen reader, script) once the page has moved a quarter of a
15
+ * screen from where the jump settled;
16
+ * - the mode: data-toc-mode="dock" when data-toc has "dock", the viewport
17
+ * is at least 32rem tall (a zoomed or short screen gets the inline list)
18
+ * and either no "rail" or a viewport under 70rem; "rail" or "inline"
19
+ * otherwise. The fold is opened in rail and dock modes; a rail that falls
20
+ * back to inline on a narrow screen is folded closed;
21
+ * - the dock: shown (data-toc-visible) while .article__body is on screen,
22
+ * and whenever a link in it has keyboard focus (CSS). Row one centres
23
+ * the current section, row two its current subsection. Centring scrolls
24
+ * only the rows, never the page; it jumps under reduced motion (OS
25
+ * setting or data-motion="reduced"). On a phone each neighbour of the
26
+ * centred section is capped to the room beside it (--_toc-side), so it
27
+ * ends in an ellipsis on screen. A link focused by Tab is centred; a
28
+ * link pressed with a pointer is not (moving it under the pointer would
29
+ * lose the click). The dock's height is written to --_toc-dock-size on
30
+ * <html> for the page's scroll padding;
31
+ * - the rail: when its list is taller than the rail, the active entry is
32
+ * kept in view (the rail scrolls, never the page).
33
+ * - data-toc-build on an empty nav builds the list from the h2 and h3
34
+ * inside data-toc-source (a selector; default: the nav's <article>,
35
+ * else <main>), with the rules of
36
+ * toc-build.mjs: data-toc-skip leaves a heading out, data-toc-label
37
+ * gives a shorter entry, a missing id is made from the text,
38
+ * data-toc-depth="2" drops H3s, fewer than data-toc-min H2s (default 3)
39
+ * hides the nav, data-toc-label-text renames "On this page".
40
+ * Server-built lists are preferred.
41
+ *
42
+ * Clicks are native hash links: the URL, history and focus behave as usual.
43
+ *
44
+ * Usage:
45
+ * <link rel="stylesheet" href="fluid-toc.min.css">
46
+ * <script src="assets/js/toc.js" defer></script>
47
+ *
48
+ * Dynamic content: call window.fluidToc() after injecting a TOC or headings
49
+ * (idempotent; safe to call repeatedly).
50
+ */
51
+
52
+ (function () {
53
+ 'use strict';
54
+
55
+ var WIDE = '(min-width: 70rem)';
56
+ var TALL = '(min-height: 32rem)';
57
+ var navs = [];
58
+ var queued = false;
59
+ // Where a TOC jump settled (scrollY), or null while it is still moving.
60
+ var pinY = null, settling = 0;
61
+
62
+ /* ---------- Builder (keep identical to toc-build.mjs) ---------- */
63
+
64
+ function esc(s) {
65
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
+ }
67
+
68
+ // github-slugger's rule (what Astro uses): lower case, drop punctuation and
69
+ // symbols, spaces to hyphens; a repeat gets -1, -2…
70
+ function slug(text, used) {
71
+ var base = String(text).toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, '').replace(/ /g, '-');
72
+ var id = base, n = 0;
73
+ while (used[id]) id = base + '-' + (++n);
74
+ used[id] = true;
75
+ return id;
76
+ }
77
+
78
+ function tocBody(headings, opts) {
79
+ opts = opts || {};
80
+ var depth = opts.depth || 3, min = opts.min == null ? 3 : opts.min;
81
+ var labelId = opts.labelId || 'toc-label', label = opts.label || 'On this page';
82
+ var tree = [], last = null, skipping = false;
83
+ headings.forEach(function (h) {
84
+ if (h.depth === 2) {
85
+ skipping = Boolean(h.skip);
86
+ if (skipping) return;
87
+ last = { h: h, kids: [] };
88
+ tree.push(last);
89
+ } else if (h.depth === 3 && depth >= 3 && last && !skipping && !h.skip) {
90
+ last.kids.push({ h: h });
91
+ }
92
+ });
93
+ if (tree.length < min) return '';
94
+ function item(n) {
95
+ var a = '<a href="#' + esc(encodeURIComponent(n.h.id)) + '">' + esc(n.h.label || n.h.text) + '</a>';
96
+ return '<li>' + a + (n.kids && n.kids.length ? '<ol>' + n.kids.map(item).join('') + '</ol>' : '') + '</li>';
97
+ }
98
+ return '<details class="toc__fold" open><summary class="toc__label" id="' + esc(labelId) + '">' +
99
+ esc(label) + '</summary><ol>' + tree.map(item).join('') + '</ol></details>';
100
+ }
101
+
102
+ // Headings as toc-build.mjs takes them, read from the page.
103
+ function readHeadings(root) {
104
+ var used = {};
105
+ Array.prototype.forEach.call(document.querySelectorAll('[id]'), function (el) { used[el.id] = true; });
106
+ return Array.prototype.map.call(root.querySelectorAll('h2, h3'), function (el) {
107
+ var text = el.textContent.replace(/\s+/g, ' ').trim();
108
+ if (!el.id) el.id = slug(text, used);
109
+ return {
110
+ depth: el.tagName === 'H2' ? 2 : 3,
111
+ id: el.id,
112
+ text: text,
113
+ label: el.getAttribute('data-toc-label') || undefined,
114
+ skip: el.hasAttribute('data-toc-skip') || undefined
115
+ };
116
+ });
117
+ }
118
+
119
+ function build(nav) {
120
+ var src = nav.getAttribute('data-toc-source');
121
+ var root = (src ? document.querySelector(src) : nav.closest('article') || document.querySelector('main')) || document.body;
122
+ var labelId = nav.getAttribute('aria-labelledby') || 'toc-label';
123
+ var label = nav.getAttribute('data-toc-label-text');
124
+ var html = tocBody(readHeadings(root), {
125
+ depth: Number(nav.getAttribute('data-toc-depth')) || 3,
126
+ min: nav.hasAttribute('data-toc-min') ? Number(nav.getAttribute('data-toc-min')) : 3,
127
+ labelId: labelId,
128
+ label: label
129
+ });
130
+ nav.innerHTML = html;
131
+ nav.hidden = !html;
132
+ if (html) nav.setAttribute('aria-labelledby', labelId);
133
+ }
134
+
135
+ /* ---------- Spy and dock ---------- */
136
+
137
+ function reduced(nav) {
138
+ return matchMedia('(prefers-reduced-motion: reduce)').matches || Boolean(nav.closest('[data-motion="reduced"]'));
139
+ }
140
+
141
+ // Scroll a row (never the page) so el's centre meets the row's centre.
142
+ function centre(row, el, nav, instant) {
143
+ if (!row || !el || !row.clientWidth) return;
144
+ var r = row.getBoundingClientRect(), e = el.getBoundingClientRect();
145
+ var dx = (e.left + e.width / 2) - (r.left + r.width / 2);
146
+ if (Math.abs(dx) < 1) return;
147
+ row.scrollBy({ left: dx, behavior: instant || reduced(nav) ? 'instant' : 'smooth' });
148
+ }
149
+
150
+ function centreRows(t, instant) {
151
+ if (t.mode !== 'dock') return;
152
+ var cur = t.active >= 0 ? t.links[t.active] : null;
153
+ var rowOne = t.nav.querySelector('.toc__fold > ol');
154
+ var top = cur ? cur.closest('.toc__fold > ol > li') : rowOne && rowOne.firstElementChild;
155
+ if (!top) return;
156
+ // The room either side of the centred entry, less the 1.5rem fade: a
157
+ // neighbour capped to it (CSS, phones) ends in its ellipsis on screen.
158
+ var fade = parseFloat(getComputedStyle(document.documentElement).fontSize) * 1.5;
159
+ t.nav.style.setProperty('--_toc-side', Math.max(0, (rowOne.clientWidth - top.firstElementChild.offsetWidth) / 2 - fade) + 'px');
160
+ centre(rowOne, top.firstElementChild, t.nav, instant);
161
+ var rowTwo = top.querySelector(':scope > ol');
162
+ if (rowTwo && cur) centre(rowTwo, cur.parentElement.parentElement === rowTwo ? cur : rowTwo.firstElementChild.firstElementChild, t.nav, instant);
163
+ }
164
+
165
+ // Where the spy says the reader is: an index into t.heads, or -1. Reads only.
166
+ function activeIndex(t) {
167
+ if (t.pin != null) return t.pin;
168
+ var h = innerHeight, left = document.documentElement.scrollHeight - (scrollY + h);
169
+ // Measured in the space the dock leaves, so the line never sits under it.
170
+ var dock = t.mode === 'dock' && t.nav.hasAttribute('data-toc-visible') ? t.nav.offsetHeight : 0;
171
+ var line = (h - dock) * (0.2 + 0.75 * Math.max(0, 1 - left / h));
172
+ var active = -1;
173
+ for (var i = 0; i < t.heads.length; i++) {
174
+ if (t.heads[i].getBoundingClientRect().top <= line) active = i; else break;
175
+ }
176
+ return active;
177
+ }
178
+
179
+ // Writes only; true when the active link changed.
180
+ function apply(t, active) {
181
+ if (!t.links[active]) active = -1; // a stale pin after the headings changed
182
+ if (active === t.active) return false;
183
+ if (t.active >= 0 && t.links[t.active]) t.links[t.active].removeAttribute('aria-current');
184
+ if (active >= 0) t.links[active].setAttribute('aria-current', 'location');
185
+ t.active = active;
186
+ return true;
187
+ }
188
+
189
+ // The rail keeps its active entry in view when the list is taller than it.
190
+ function railKeep(t) {
191
+ if (t.mode !== 'rail' || t.active < 0 || t.nav.scrollHeight <= t.nav.clientHeight) return;
192
+ var n = t.nav.getBoundingClientRect(), a = t.links[t.active].getBoundingClientRect();
193
+ var d = a.top < n.top ? a.top - n.top - 8 : a.bottom > n.bottom ? a.bottom - n.bottom + 8 : 0;
194
+ if (d) t.nav.scrollBy({ top: d, behavior: 'instant' });
195
+ }
196
+
197
+ function dockSize(t) {
198
+ var root = document.documentElement.style;
199
+ if (t.mode === 'dock') root.setProperty('--_toc-dock-size', t.nav.offsetHeight + 'px');
200
+ else if (!navs.some(function (o) { return o.mode === 'dock'; })) root.removeProperty('--_toc-dock-size');
201
+ }
202
+
203
+ // Every nav is read first, then every nav is written; the centring reads
204
+ // again in the next frame. No layout is forced between a write and a read.
205
+ function spyAll() {
206
+ var next = navs.map(activeIndex);
207
+ var changed = navs.filter(function (t, k) { return apply(t, next[k]); });
208
+ if (changed.length) {
209
+ requestAnimationFrame(function () {
210
+ changed.forEach(function (t) { centreRows(t, false); railKeep(t); });
211
+ });
212
+ }
213
+ }
214
+
215
+ function setMode(t) {
216
+ var words = (t.nav.getAttribute('data-toc') || '').split(/\s+/);
217
+ var rail = words.indexOf('rail') > -1, wide = matchMedia(WIDE).matches;
218
+ var dock = words.indexOf('dock') > -1 && matchMedia(TALL).matches;
219
+ var mode = dock && (!rail || !wide) ? 'dock' : rail && wide ? 'rail' : 'inline';
220
+ if (mode === t.mode) return;
221
+ t.mode = mode;
222
+ // Switch without the dock's slide, so a page never loads with the bar
223
+ // gliding away; data-toc-instant turns the transition off for two frames.
224
+ t.nav.setAttribute('data-toc-instant', '');
225
+ t.nav.setAttribute('data-toc-mode', mode);
226
+ var fold = t.nav.querySelector('.toc__fold');
227
+ if (fold) {
228
+ if (mode !== 'inline') fold.open = true;
229
+ else if (rail) fold.open = false;
230
+ }
231
+ requestAnimationFrame(function () {
232
+ centreRows(t, true);
233
+ dockSize(t);
234
+ requestAnimationFrame(function () { t.nav.removeAttribute('data-toc-instant'); });
235
+ });
236
+ }
237
+
238
+ function onScroll() {
239
+ // Any scroll that moves a quarter of a screen from where a jump settled
240
+ // releases the pin, however it was made (scrollbar, Tab, find, script).
241
+ if (pinY != null && Math.abs(scrollY - pinY) > innerHeight / 4) unpin({ type: 'scroll' });
242
+ if (queued) return;
243
+ queued = true;
244
+ requestAnimationFrame(function () {
245
+ queued = false;
246
+ spyAll();
247
+ });
248
+ }
249
+
250
+ function onResize() {
251
+ navs.forEach(setMode);
252
+ onScroll();
253
+ }
254
+
255
+ function idOf(hash) {
256
+ var raw = String(hash).replace(/^#/, '');
257
+ try { return decodeURIComponent(raw); } catch (e) { return raw; }
258
+ }
259
+
260
+ // A TOC click or a hash change: the target is current until the reader scrolls.
261
+ function pin(id) {
262
+ navs.forEach(function (t) {
263
+ var i = -1;
264
+ for (var k = 0; k < t.heads.length; k++) if (t.heads[k].id === id) { i = k; break; }
265
+ t.pin = i >= 0 ? i : null;
266
+ });
267
+ settle();
268
+ onScroll();
269
+ }
270
+
271
+ // Record where the jump lands: the first scrollY that holds still for
272
+ // three frames (a smooth scroll moves every frame), at most ~5s.
273
+ function settle() {
274
+ pinY = null;
275
+ var run = ++settling, last = scrollY, quiet = 0, frames = 0;
276
+ (function tick() {
277
+ requestAnimationFrame(function () {
278
+ if (run !== settling) return;
279
+ if (Math.abs(scrollY - last) < 1) quiet++; else { quiet = 0; last = scrollY; }
280
+ if (quiet >= 3 || ++frames > 300) pinY = scrollY; else tick();
281
+ });
282
+ })();
283
+ }
284
+
285
+ function unpin(e) {
286
+ if (e.type === 'keydown' && !/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(e.key)) return;
287
+ var any = false;
288
+ navs.forEach(function (t) { if (t.pin != null) { t.pin = null; any = true; } });
289
+ pinY = null;
290
+ settling++;
291
+ if (any) onScroll();
292
+ }
293
+
294
+ function init(nav) {
295
+ if (nav.hasAttribute('data-toc-build') && !nav.querySelector('a')) build(nav);
296
+ var t = nav._fluidToc;
297
+ if (!t) {
298
+ t = nav._fluidToc = { nav: nav, active: -1, mode: null, pin: null };
299
+ navs.push(t);
300
+ // A link reached by Tab in the dock is centred, so it is never under
301
+ // the fade; a row-one link also brings its row two to the start. A
302
+ // pointer press focuses a link too (Chrome), but is skipped.
303
+ nav.addEventListener('focusin', function (e) {
304
+ if (t.mode !== 'dock' || !e.target.matches('a')) return;
305
+ var keyboard = true;
306
+ try { keyboard = e.target.matches(':focus-visible'); } catch (err) { /* old browser: assume keyboard */ }
307
+ if (!keyboard) return;
308
+ centre(e.target.closest('ol'), e.target, nav, true);
309
+ var sub = e.target.nextElementSibling;
310
+ if (sub && sub.tagName === 'OL' && sub.firstElementChild) centre(sub, sub.firstElementChild.firstElementChild, nav, true);
311
+ });
312
+ nav.addEventListener('click', function (e) {
313
+ var a = e.target.closest && e.target.closest('a[href^="#"]');
314
+ if (a) pin(idOf(a.hash));
315
+ });
316
+ }
317
+ // The dock shows while the article body is on screen. Re-observe if the
318
+ // body changed (the page swapped it out).
319
+ var body = nav.closest('.article__body') || nav.parentElement;
320
+ if (t.body !== body) {
321
+ if (t.io) t.io.disconnect();
322
+ t.body = body;
323
+ if ('IntersectionObserver' in window) {
324
+ t.io = new IntersectionObserver(function (entries) {
325
+ var on = entries[entries.length - 1].isIntersecting;
326
+ nav.toggleAttribute('data-toc-visible', on);
327
+ if (on) centreRows(t, true);
328
+ });
329
+ t.io.observe(body);
330
+ } else {
331
+ nav.setAttribute('data-toc-visible', '');
332
+ }
333
+ }
334
+ // The nav's own links decide the headings it follows.
335
+ t.links = [];
336
+ t.heads = [];
337
+ Array.prototype.forEach.call(nav.querySelectorAll('a[href^="#"]'), function (a) {
338
+ var h = document.getElementById(idOf(a.hash));
339
+ if (h) { t.links.push(a); t.heads.push(h); }
340
+ });
341
+ t.active = -1;
342
+ t.pin = null; // fluidToc() pins location.hash again after init
343
+ Array.prototype.forEach.call(nav.querySelectorAll('[aria-current]'), function (a) { a.removeAttribute('aria-current'); });
344
+ t.mode = null;
345
+ setMode(t);
346
+ apply(t, activeIndex(t));
347
+ }
348
+
349
+ var listening = false;
350
+
351
+ function fluidToc() {
352
+ // Drop navs that left the page (an SPA or view-transition swap), with
353
+ // their observers, so detached headings are never read again.
354
+ navs = navs.filter(function (t) {
355
+ if (t.nav.isConnected) return true;
356
+ if (t.io) t.io.disconnect();
357
+ delete t.nav._fluidToc;
358
+ return false;
359
+ });
360
+ Array.prototype.forEach.call(document.querySelectorAll('nav.toc'), init);
361
+ if (!listening) {
362
+ listening = true;
363
+ addEventListener('scroll', onScroll, { passive: true });
364
+ addEventListener('resize', onResize, { passive: true });
365
+ addEventListener('hashchange', function () { pin(idOf(location.hash)); });
366
+ ['wheel', 'touchstart', 'keydown'].forEach(function (ev) { addEventListener(ev, unpin, { passive: true }); });
367
+ [WIDE, TALL].forEach(function (q) {
368
+ var mq = matchMedia(q);
369
+ if (mq.addEventListener) mq.addEventListener('change', onResize);
370
+ });
371
+ }
372
+ if (location.hash) pin(idOf(location.hash));
373
+ }
374
+
375
+ window.fluidToc = fluidToc;
376
+ // Exposed for toc-check.mjs, which proves this builder and toc-build.mjs agree.
377
+ window.fluidToc.html = tocBody;
378
+
379
+ if (document.readyState === 'loading') {
380
+ document.addEventListener('DOMContentLoaded', fluidToc);
381
+ } else {
382
+ fluidToc();
383
+ }
384
+ })();