@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,153 @@
1
+ /**
2
+ * Lightbox
3
+ *
4
+ * Opens the links in a [data-lightbox] gallery in a full-screen native
5
+ * <dialog>: previous / next buttons, arrow keys, Escape to close (native),
6
+ * swipe left / right, a "3 of 12" counter and the caption. Focus moves into
7
+ * the dialog and returns to the thumbnail that opened it.
8
+ *
9
+ * Progressive enhancement: without this script every thumbnail is a plain
10
+ * link to the full-size image, so nothing is lost.
11
+ *
12
+ * Usage (markup and options in src/components/gallery.css):
13
+ * <ul class="gallery" role="list" data-lightbox> … <a class="gallery__link" href="full.jpg"><img …></a> … </ul>
14
+ * <script src="assets/js/lightbox.js" defer></script>
15
+ *
16
+ * The caption comes from the figure's .gallery__caption, else the link's
17
+ * data-caption, else the image's alt text. Dynamic galleries: call
18
+ * window.fluidLightbox() after adding them (idempotent).
19
+ */
20
+
21
+ (function () {
22
+ 'use strict';
23
+
24
+ var dialog, img, caption, counter, prevBtn, nextBtn, items = [], index = 0, opener = null, startX = null, swiped = false;
25
+
26
+ function button(cls, label, text) {
27
+ var b = document.createElement('button');
28
+ b.type = 'button';
29
+ b.className = 'lightbox__button ' + cls;
30
+ b.setAttribute('aria-label', label);
31
+ // A variant keeps the presets' default button skins off these controls.
32
+ b.setAttribute('data-variant', 'ghost');
33
+ b.textContent = text;
34
+ return b;
35
+ }
36
+
37
+ function build() {
38
+ dialog = document.createElement('dialog');
39
+ dialog.className = 'lightbox';
40
+ dialog.setAttribute('aria-label', 'Photo viewer');
41
+
42
+ var bar = document.createElement('div');
43
+ bar.className = 'lightbox__bar';
44
+ counter = document.createElement('p');
45
+ counter.className = 'lightbox__counter';
46
+ counter.setAttribute('aria-live', 'polite');
47
+ var close = button('lightbox__close', 'Close photo viewer', '×');
48
+ close.addEventListener('click', function () { dialog.close(); });
49
+ bar.append(counter, close);
50
+
51
+ var stage = document.createElement('div');
52
+ stage.className = 'lightbox__stage';
53
+ img = document.createElement('img');
54
+ img.className = 'lightbox__image';
55
+ img.decoding = 'async';
56
+ img.draggable = false; // a mouse drag must swipe, not start the browser's image drag
57
+ prevBtn = button('lightbox__prev', 'Previous photo', '‹');
58
+ nextBtn = button('lightbox__next', 'Next photo', '›');
59
+ prevBtn.addEventListener('click', function () { show(index - 1); });
60
+ nextBtn.addEventListener('click', function () { show(index + 1); });
61
+ stage.append(prevBtn, img, nextBtn);
62
+
63
+ caption = document.createElement('p');
64
+ caption.className = 'lightbox__caption';
65
+
66
+ dialog.append(bar, stage, caption);
67
+ document.body.appendChild(dialog);
68
+
69
+ dialog.addEventListener('keydown', function (e) {
70
+ if (e.key === 'ArrowLeft') { e.preventDefault(); show(index - 1); }
71
+ else if (e.key === 'ArrowRight') { e.preventDefault(); show(index + 1); }
72
+ });
73
+ // A click on the dark area around the photo closes, as people expect.
74
+ dialog.addEventListener('click', function (e) {
75
+ if (swiped) { swiped = false; return; } // the click that ends a swipe is not a close
76
+ if (e.target === dialog || e.target === stage) dialog.close();
77
+ });
78
+ stage.addEventListener('pointerdown', function (e) { startX = e.clientX; swiped = false; });
79
+ stage.addEventListener('pointercancel', function () { startX = null; });
80
+ stage.addEventListener('pointerup', function (e) {
81
+ if (startX === null) return;
82
+ var dx = e.clientX - startX;
83
+ startX = null;
84
+ if (Math.abs(dx) > 40) { swiped = true; show(index + (dx < 0 ? 1 : -1)); }
85
+ });
86
+ dialog.addEventListener('close', function () {
87
+ img.removeAttribute('src');
88
+ if (opener) opener.focus();
89
+ opener = null;
90
+ });
91
+ }
92
+
93
+ function captionFor(link) {
94
+ var fig = link.closest('figure');
95
+ var cap = fig && fig.querySelector('.gallery__caption');
96
+ if (cap && cap.textContent.trim()) return cap.textContent.trim();
97
+ if (link.dataset.caption) return link.dataset.caption;
98
+ var thumb = link.querySelector('img');
99
+ return thumb ? thumb.alt : '';
100
+ }
101
+
102
+ function preload(i) {
103
+ if (i < 0 || i >= items.length) return;
104
+ var p = new Image();
105
+ p.src = items[i].href;
106
+ }
107
+
108
+ function show(i) {
109
+ if (i < 0 || i >= items.length) return;
110
+ index = i;
111
+ var link = items[i];
112
+ var thumb = link.querySelector('img');
113
+ img.src = link.href;
114
+ img.alt = thumb ? thumb.alt : '';
115
+ var text = captionFor(link);
116
+ caption.textContent = text;
117
+ caption.hidden = !text || text === img.alt;
118
+ counter.textContent = items.length > 1 ? (i + 1) + ' of ' + items.length : '';
119
+ prevBtn.hidden = i === 0;
120
+ nextBtn.hidden = i === items.length - 1;
121
+ preload(i + 1);
122
+ preload(i - 1);
123
+ }
124
+
125
+ function open(gallery, link) {
126
+ if (!dialog) build();
127
+ items = Array.prototype.slice.call(gallery.querySelectorAll('a[href]'));
128
+ opener = link;
129
+ show(items.indexOf(link));
130
+ dialog.showModal();
131
+ // Start on the next button so arrow keys and Enter both move forward.
132
+ (nextBtn.hidden ? dialog.querySelector('.lightbox__close') : nextBtn).focus();
133
+ }
134
+
135
+ function init() {
136
+ if (typeof HTMLDialogElement !== 'function') return; // very old browser: links stay links
137
+ document.querySelectorAll('[data-lightbox]').forEach(function (gallery) {
138
+ if (gallery.dataset.lightboxReady) return;
139
+ gallery.dataset.lightboxReady = '1';
140
+ gallery.addEventListener('click', function (e) {
141
+ var link = e.target.closest('a[href]');
142
+ if (!link || !gallery.contains(link)) return;
143
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; // new tab etc.
144
+ e.preventDefault();
145
+ open(gallery, link);
146
+ });
147
+ });
148
+ }
149
+
150
+ window.fluidLightbox = init;
151
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
152
+ else init();
153
+ })();
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Scroll Reveal
3
+ *
4
+ * Lightweight scroll-triggered reveal animations using Intersection Observer.
5
+ * Add [data-reveal] attribute to elements you want to animate.
6
+ *
7
+ * Usage:
8
+ * <div data-reveal="fade-up">Content</div>
9
+ * <div data-reveal="fade-up" data-reveal-delay="200">Delayed content</div>
10
+ *
11
+ * The script adds the 'revealed' class when elements enter the viewport.
12
+ * Nothing is hidden until it sets data-reveal-ready on <html>
13
+ * (scroll-reveal.css), so a page without this script keeps its content.
14
+ */
15
+
16
+ (function() {
17
+ 'use strict';
18
+
19
+ // Configuration
20
+ const config = {
21
+ root: null, // viewport
22
+ rootMargin: '0px', // no margin
23
+ threshold: 0.1 // trigger when 10% visible
24
+ };
25
+
26
+ // Initialize when DOM is ready
27
+ function init() {
28
+ // Only elements that opt in with [data-reveal] animate (scroll-reveal.css).
29
+ const elements = document.querySelectorAll('[data-reveal]');
30
+
31
+ if (!elements.length) return;
32
+
33
+ // Check for IntersectionObserver support
34
+ if (!('IntersectionObserver' in window)) {
35
+ // Fallback: reveal all immediately
36
+ elements.forEach(el => el.classList.add('revealed'));
37
+ return;
38
+ }
39
+
40
+ // Content already on screen stays as it is (no blank flash while the
41
+ // observer starts); only what is below the fold waits to animate in.
42
+ const viewH = window.innerHeight || document.documentElement.clientHeight;
43
+ elements.forEach(el => {
44
+ const r = el.getBoundingClientRect();
45
+ if (r.top < viewH && r.bottom > 0) el.classList.add('revealed');
46
+ });
47
+ const root = document.documentElement;
48
+ root.setAttribute('data-reveal-init', '');
49
+ root.setAttribute('data-reveal-ready', '');
50
+ void root.offsetHeight; // apply the hidden start state without animating it
51
+ root.removeAttribute('data-reveal-init');
52
+
53
+ const observer = new IntersectionObserver((entries, observer) => {
54
+ entries.forEach(entry => {
55
+ if (entry.isIntersecting) {
56
+ entry.target.classList.add('revealed');
57
+ // Stop observing once revealed (one-time animation)
58
+ observer.unobserve(entry.target);
59
+ }
60
+ });
61
+ }, config);
62
+
63
+ elements.forEach(el => { if (!el.classList.contains('revealed')) observer.observe(el); });
64
+ }
65
+
66
+ // Run on DOMContentLoaded
67
+ if (document.readyState === 'loading') {
68
+ document.addEventListener('DOMContentLoaded', init);
69
+ } else {
70
+ init();
71
+ }
72
+ })();
@@ -0,0 +1,86 @@
1
+ /**
2
+ * TOC builder: the server-side half of the TOC module (spec
3
+ * _docs/plans/base-v2.md §13). Writes the plain nested list that crawlers,
4
+ * AI fetchers and no-JS readers get; assets/js/toc.js adds the spy and the
5
+ * dock on top, and src/components/toc.css (dist/fluid-toc.min.css) styles it.
6
+ *
7
+ * import { tocHtml } from "@fluid-theme/core/toc-build";
8
+ * const { headings } = await render(entry); // Astro: { depth, slug, text }[]
9
+ * const toc = tocHtml(headings.map((h) => ({ ...h, id: h.slug })), { modes: "rail dock" });
10
+ * // <Fragment set:html={toc} /> after the lede, before the body
11
+ *
12
+ * headings: { depth, id, text, label?, skip? }[] in document order.
13
+ * - H2s are the top level; each H3 nests under the H2 before it. An H3
14
+ * before the first H2 is skipped, and so are the H3s of a skipped H2.
15
+ * - label: a shorter TOC entry (data-toc-label on the heading);
16
+ * skip: leave it out (data-toc-skip).
17
+ * - An existing id is never renamed, so links keep working when content
18
+ * moves. A missing id is made with github-slugger's rule (Astro's): the
19
+ * caller must put the returned ids on its headings (see tocHeadings).
20
+ * options:
21
+ * modes data-toc words, default "rail dock" (inline | rail | dock, + end)
22
+ * depth 2 or 3 (default 3)
23
+ * min fewer H2s than this renders nothing (default 3)
24
+ * label the fold's label, default "On this page"
25
+ * labelId the label's id, default "toc-label"
26
+ *
27
+ * Returns "" below min. Keep tocBody identical to the copy in toc.js:
28
+ * scripts/toc-check.mjs fails if the two disagree.
29
+ */
30
+
31
+ function esc(s) {
32
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33
+ }
34
+
35
+ /** github-slugger's rule: lower case, drop punctuation and symbols, spaces to hyphens; a repeat gets -1, -2… */
36
+ export function slug(text, used = {}) {
37
+ const base = String(text).toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, '').replace(/ /g, '-');
38
+ let id = base;
39
+ let n = 0;
40
+ while (used[id]) id = `${base}-${++n}`;
41
+ used[id] = true;
42
+ return id;
43
+ }
44
+
45
+ /** The headings with every missing id filled in (existing ids are kept). */
46
+ export function tocHeadings(headings) {
47
+ const used = {};
48
+ for (const h of headings) if (h.id) used[h.id] = true;
49
+ return headings.map((h) => (h.id ? h : { ...h, id: slug(h.text, used) }));
50
+ }
51
+
52
+ /** The fold and list: what toc.js writes into an empty nav[data-toc-build]. */
53
+ export function tocBody(headings, opts = {}) {
54
+ const depth = opts.depth || 3;
55
+ const min = opts.min == null ? 3 : opts.min;
56
+ const labelId = opts.labelId || 'toc-label';
57
+ const label = opts.label || 'On this page';
58
+ const tree = [];
59
+ let last = null;
60
+ let skipping = false;
61
+ for (const h of tocHeadings(headings)) {
62
+ if (h.depth === 2) {
63
+ skipping = Boolean(h.skip);
64
+ if (skipping) continue;
65
+ last = { h, kids: [] };
66
+ tree.push(last);
67
+ } else if (h.depth === 3 && depth >= 3 && last && !skipping && !h.skip) {
68
+ last.kids.push({ h });
69
+ }
70
+ }
71
+ if (tree.length < min) return '';
72
+ const item = (n) =>
73
+ `<li><a href="#${esc(encodeURIComponent(n.h.id))}">${esc(n.h.label || n.h.text)}</a>` +
74
+ (n.kids && n.kids.length ? `<ol>${n.kids.map(item).join('')}</ol>` : '') + '</li>';
75
+ return `<details class="toc__fold" open><summary class="toc__label" id="${esc(labelId)}">${esc(label)}</summary>` +
76
+ `<ol>${tree.map(item).join('')}</ol></details>`;
77
+ }
78
+
79
+ /** The whole nav, ready to place after the lede and before the article body. */
80
+ export function tocHtml(headings, opts = {}) {
81
+ const body = tocBody(headings, opts);
82
+ if (!body) return '';
83
+ const modes = opts.modes || 'rail dock';
84
+ const depthAttr = opts.depth === 2 ? ' data-toc-depth="2"' : '';
85
+ return `<nav class="toc" data-toc="${esc(modes)}"${depthAttr} aria-labelledby="${esc(opts.labelId || 'toc-label')}">${body}</nav>`;
86
+ }