@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.
- package/AGENTS.md +6 -29
- package/CHANGELOG.md +543 -2
- package/CLAUDE.md +193 -14
- package/GEMINI.md +6 -45
- package/README.md +17 -8
- package/assets/js/compare.js +77 -0
- package/assets/js/fit-text.js +103 -0
- package/assets/js/lightbox.js +153 -0
- package/assets/js/scroll-reveal.js +72 -0
- package/assets/js/toc-build.mjs +86 -0
- package/assets/js/toc.js +384 -0
- package/dist/fluid-1x.min.css +4 -0
- package/dist/fluid-fonts.min.css +2 -0
- package/dist/fluid-legacy-1x.css +3 -0
- package/dist/fluid-nav.min.css +3 -0
- package/dist/fluid-toc.min.css +3 -0
- package/dist/fluid-zero.min.css +3 -0
- package/dist/fluid.min.css +3 -2
- package/dist/fonts/OFL-newsreader.txt +93 -0
- package/dist/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/dist/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
- package/dist/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
- package/dist/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
- package/dist/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
- package/fluid-agent-kit/README.md +1 -0
- package/fluid-agent-kit/skills/accessibility.md +3 -1
- package/fluid-agent-kit/skills/css-conventions.md +22 -5
- package/fluid-agent-kit/skills/image-optimization.md +2 -0
- package/fluid-agent-kit/skills/preset-composition.md +108 -0
- package/package.json +29 -3
- package/src/animations/scroll-reveal.css +75 -193
- package/src/components/accordion.css +6 -47
- package/src/components/alert.css +47 -88
- package/src/components/avatar.css +4 -20
- package/src/components/badge.css +25 -48
- package/src/components/button.css +69 -175
- package/src/components/card.css +146 -481
- package/src/components/chip.css +46 -12
- package/src/components/compare.css +234 -0
- package/src/components/dropdown.css +10 -67
- package/src/components/empty-state.css +4 -0
- package/src/components/form.css +113 -81
- package/src/components/gallery.css +252 -0
- package/src/components/hero.css +203 -88
- package/src/components/indicator.css +23 -117
- package/src/components/loading.css +7 -29
- package/src/components/modal.css +12 -65
- package/src/components/nav-modes.css +261 -0
- package/src/components/nav.css +324 -392
- package/src/components/search-input.css +4 -2
- package/src/components/step-indicator.css +26 -27
- package/src/components/table.css +64 -171
- package/src/components/toc.css +254 -0
- package/src/components/tooltip.css +5 -46
- package/src/core/base.css +393 -547
- package/src/core/reset.css +17 -5
- package/src/core/tokens.css +426 -2276
- package/src/fluid.css +258 -17
- package/src/fonts/OFL-newsreader.txt +93 -0
- package/src/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/src/fonts/fluid-fonts.css +55 -0
- package/src/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
- package/src/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
- package/src/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
- package/src/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
- package/src/layouts/positioning.css +6 -3
- package/src/legacy-1x.css +61 -0
- package/src/sections/cta.css +33 -101
- package/src/sections/features.css +42 -219
- package/src/sections/footer.css +53 -134
- package/src/sections/stats.css +40 -153
- package/src/sections/team.css +24 -113
- package/src/sections/testimonials.css +34 -148
- package/src/zero/base.css +166 -0
- package/src/zero/tokens.css +64 -0
- package/src/zero.css +20 -0
- package/src/components/scroll-progress.css +0 -104
- package/src/components/settings-panel.css +0 -603
- package/src/components/themed-images.css +0 -229
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|