Merge branch 'main' into refactor/task-13-page-chapters
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
---
|
||||
// CopyPrompt — copy-to-clipboard island for full-guide. One per button.
|
||||
//
|
||||
// Click reads `#${target}`'s textContent, copies it via
|
||||
// `navigator.clipboard.writeText`, and falls back to a `document.execCommand`
|
||||
// textarea when the clipboard API is unavailable (the workshop serves the
|
||||
// site over plain HTTP in some venues, where `navigator.clipboard` is
|
||||
// undefined — deleting the fallback silently breaks the lab for attendees).
|
||||
// Both paths are kept on purpose; this is the contract the legacy app.js
|
||||
// copyPrompt function uses for the three full-guide prompts.
|
||||
//
|
||||
// On success, writes a bilingual result string to the page-owned
|
||||
// `#copy-status` (live region, `role="status"` `aria-live="polite"`) and
|
||||
// swaps the button's <span> to COPIED / COPIADO for 1800 ms before
|
||||
// restoring it.
|
||||
//
|
||||
// Language comes from `document.documentElement.lang` — set today by
|
||||
// app.js's `applyLanguage` and tomorrow by task 15c's language-toggle
|
||||
// island. A `MutationObserver` on `<html lang>` keeps the button label
|
||||
// in step with whatever flips it; this island does not own the toggle
|
||||
// and does not invent a second mechanism. Task 15c and 15d should align
|
||||
// on this — see the task report.
|
||||
|
||||
interface Props {
|
||||
// The id (without `#`) of the <pre><code> element whose textContent
|
||||
// is copied. Matches `data-copy-target="…"` on the rendered button,
|
||||
// which is the token `scripts/verify.mjs` asserts in the legacy
|
||||
// `full-guide/index.html`.
|
||||
target: 'prompt-install-skills' | 'prompt-basic' | 'prompt-skills' | string;
|
||||
}
|
||||
|
||||
const { target } = Astro.props;
|
||||
---
|
||||
|
||||
<button class="copy-prompt-button" data-copy-target={target} data-copy-prompt-button type="button">
|
||||
<span data-copy-prompt-label>COPY</span>
|
||||
<i aria-hidden="true">↗</i>
|
||||
</button>
|
||||
|
||||
<script is:inline>
|
||||
// CopyPrompt island — click handler.
|
||||
// Wire-once guard: a page that uses <CopyPrompt> three times renders
|
||||
// three inline script tags. Each one would otherwise attach three
|
||||
// click listeners per button; this flag makes the second and third
|
||||
// calls a no-op.
|
||||
(function () {
|
||||
if (window.__copyPromptWired) return;
|
||||
window.__copyPromptWired = true;
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
button: 'COPY',
|
||||
copied: 'COPIED',
|
||||
success: 'Prompt copied. Paste it into a fresh agent session.',
|
||||
failure: 'Copy unavailable. Select the text manually.',
|
||||
},
|
||||
pt: {
|
||||
button: 'COPIAR',
|
||||
copied: 'COPIADO',
|
||||
success: 'Prompt copiado. Cole em uma nova sessão de agente.',
|
||||
failure: 'Não foi possível copiar. Selecione o texto manualmente.',
|
||||
},
|
||||
};
|
||||
|
||||
function currentLang() {
|
||||
return document.documentElement.lang === 'pt-BR' ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
function labels() {
|
||||
return STRINGS[currentLang()];
|
||||
}
|
||||
|
||||
function setStatus(copied) {
|
||||
const status = document.querySelector('#copy-status');
|
||||
if (!status) return;
|
||||
const l = labels();
|
||||
status.textContent = copied ? l.success : l.failure;
|
||||
}
|
||||
|
||||
function fallbackCopy(value) {
|
||||
// The textarea dance is straight from the legacy app.js: a fixed,
|
||||
// invisible, readonly <textarea> selected synchronously, so
|
||||
// document.execCommand('copy') sees a real selection. Removing
|
||||
// any of those three bits makes the fallback fail on at least
|
||||
// one browser the workshop covers.
|
||||
const helper = document.createElement('textarea');
|
||||
helper.value = value;
|
||||
helper.setAttribute('readonly', '');
|
||||
helper.style.position = 'fixed';
|
||||
helper.style.opacity = '0';
|
||||
document.body.appendChild(helper);
|
||||
helper.select();
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
helper.remove();
|
||||
return copied;
|
||||
}
|
||||
|
||||
function onResult(button, copied) {
|
||||
setStatus(copied);
|
||||
if (!copied) return;
|
||||
const l = labels();
|
||||
const span = button.querySelector('span');
|
||||
if (span) span.textContent = l.copied;
|
||||
button.classList.add('copied');
|
||||
// 1800ms matches the legacy copyPrompt. Long enough for a
|
||||
// sighted user to read COPIED, short enough not to feel sticky
|
||||
// if the next click follows fast.
|
||||
window.setTimeout(function () {
|
||||
button.classList.remove('copied');
|
||||
if (span) span.textContent = labels().button;
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function copyPrompt(button) {
|
||||
const id = button.dataset.copyTarget;
|
||||
if (!id) return;
|
||||
const target = document.querySelector('#' + id);
|
||||
if (!target) return;
|
||||
const value = target.textContent || '';
|
||||
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.clipboard &&
|
||||
typeof navigator.clipboard.writeText === 'function'
|
||||
) {
|
||||
// navigator.clipboard.writeText is async; resolve to true on
|
||||
// success, fall back to the textarea dance on rejection
|
||||
// (insecure context, missing permission, etc).
|
||||
navigator.clipboard.writeText(value).then(
|
||||
function () {
|
||||
onResult(button, true);
|
||||
},
|
||||
function () {
|
||||
onResult(button, fallbackCopy(value));
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
onResult(button, fallbackCopy(value));
|
||||
}
|
||||
|
||||
function syncLabels() {
|
||||
const l = labels();
|
||||
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
const btn = buttons[i];
|
||||
// Mid-feedback buttons (1800ms COPIED state) keep their
|
||||
// swapped label until the timer fires; otherwise the toggle
|
||||
// would visibly flicker the COPIED text back to the default.
|
||||
if (btn.classList.contains('copied')) continue;
|
||||
const span = btn.querySelector('span');
|
||||
if (span) span.textContent = l.button;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].addEventListener(
|
||||
'click',
|
||||
(function (button) {
|
||||
return function () {
|
||||
copyPrompt(button);
|
||||
};
|
||||
})(buttons[i]),
|
||||
);
|
||||
}
|
||||
|
||||
// Pick up external language changes (task 15c's toggle) without
|
||||
// owning the toggle itself. attributeFilter keeps the observer
|
||||
// from firing on unrelated <html> mutations.
|
||||
if (typeof MutationObserver === 'function') {
|
||||
new MutationObserver(syncLabels).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['lang'],
|
||||
});
|
||||
}
|
||||
// Sync on first hydrate so a page rendered server-side as English
|
||||
// picks up the user's stored PT preference without waiting for a
|
||||
// click. app.js's applyLanguage has already set <html lang> by the
|
||||
// time this script runs in the current page wiring, so this is a
|
||||
// one-shot correction, not a race.
|
||||
syncLabels();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Component-scoped. The legacy full-guide/styles.css carries the
|
||||
full visual treatment (button border, hover, `.copied` state,
|
||||
etc.); 15d ports that wholesale when it migrates the page. This
|
||||
block only ensures the button is keyboard-reachable and announces
|
||||
the COPIED state — the rest lives in the page stylesheet so the
|
||||
legacy file and the new page stay visually identical. */
|
||||
.copy-prompt-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.copy-prompt-button:focus-visible {
|
||||
outline: 3px solid var(--gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
// GuideSelector — one client:visible controller for the nine independent
|
||||
// selectors on /full-guide/. The page owns the server-rendered shells; this
|
||||
// leaf only swaps their already-present detail panels after they are visible.
|
||||
|
||||
interface Localized {
|
||||
en: string;
|
||||
pt: string;
|
||||
}
|
||||
interface GuideSelectorData {
|
||||
phases: Record<string, { model: Localized; title: Localized; copy: Localized; code: Localized }>;
|
||||
workers: Record<string, { en: string[]; pt: string[] }>;
|
||||
trees: Record<string, { owner: Localized; path: string; note: Localized; command: string }>;
|
||||
routes: Record<string, { score: number; label: Localized; why: Localized }>;
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
source: string;
|
||||
title: Localized;
|
||||
copy: Localized;
|
||||
tiers: [string, string, Localized][];
|
||||
config: string;
|
||||
}
|
||||
>;
|
||||
efforts: Record<string, { en: string[]; pt: string[] }>;
|
||||
skillFiles: Record<string, { icon: string; title: string; en: string; pt: string }>;
|
||||
skillWorkflow: Record<
|
||||
string,
|
||||
{
|
||||
number: string;
|
||||
title: Localized;
|
||||
question: Localized;
|
||||
action: Localized;
|
||||
output: Localized;
|
||||
proof: Localized;
|
||||
}
|
||||
>;
|
||||
commonSkills: Record<
|
||||
string,
|
||||
{
|
||||
number: string;
|
||||
kind: Localized;
|
||||
title: string;
|
||||
rule: Localized;
|
||||
use: Localized;
|
||||
example: Localized;
|
||||
caution: Localized;
|
||||
source: string;
|
||||
}
|
||||
>;
|
||||
labels: {
|
||||
context: Localized;
|
||||
owner: Localized;
|
||||
reasoningLoad: Localized;
|
||||
officialSource: Localized;
|
||||
skillFileHint: Localized;
|
||||
workflow: { question: Localized; action: Localized; artifact: Localized; proof: Localized };
|
||||
commonSkill: {
|
||||
whenToUse: Localized;
|
||||
example: Localized;
|
||||
watchOut: Localized;
|
||||
source: Localized;
|
||||
};
|
||||
};
|
||||
}
|
||||
interface Props {
|
||||
/** The page root that contains the nine static selector shells. */
|
||||
rootSelector: string;
|
||||
/** All bilingual selector data, supplied by the page from content collections. */
|
||||
data: GuideSelectorData;
|
||||
}
|
||||
const { rootSelector, data } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- client:visible equivalent: bind only after the page-owned root is visible. -->
|
||||
<span aria-hidden="true" data-guide-selector-hydration="visible"></span>
|
||||
|
||||
<script define:vars={{ rootSelector, data }}>
|
||||
(() => {
|
||||
const root = document.querySelector(rootSelector);
|
||||
if (!root) return;
|
||||
|
||||
const language = () => (document.documentElement.lang === 'pt-BR' ? 'pt' : 'en');
|
||||
const item = (group, id) => data[group][id];
|
||||
const activeId = (selector, key, fallback) =>
|
||||
root.querySelector(`${selector}.active`)?.dataset[key] || fallback;
|
||||
|
||||
function selectButtons(selector, activeValue, key) {
|
||||
root.querySelectorAll(selector).forEach((button) => {
|
||||
const active = button.dataset[key] === activeValue;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute(
|
||||
button.hasAttribute('aria-selected') ? 'aria-selected' : 'aria-pressed',
|
||||
String(active),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function replace(target, markup) {
|
||||
const panel = root.querySelector(target);
|
||||
if (panel) panel.innerHTML = markup;
|
||||
}
|
||||
|
||||
// Names deliberately match legacy functions until task 19 updates its checks.
|
||||
function render(id) {
|
||||
const phase = item('phases', id);
|
||||
if (!phase) return;
|
||||
const locale = language();
|
||||
replace(
|
||||
'#phase-panel',
|
||||
`<div class="phase-meta"><span>${phase.model[locale]}</span><small>${data.labels.context[locale]}</small></div><h3>${phase.title[locale]}</h3><p>${phase.copy[locale]}</p><code>${phase.code[locale]}</code>`,
|
||||
);
|
||||
selectButtons('[data-phase]', id, 'phase');
|
||||
}
|
||||
function renderWorker(id) {
|
||||
const worker = item('workers', id);
|
||||
if (!worker) return;
|
||||
const [label, detail, result] = worker[language()];
|
||||
replace(
|
||||
'#worker-detail',
|
||||
`<span>${label}</span><strong>${detail}</strong><small>${result}</small>`,
|
||||
);
|
||||
selectButtons('[data-worker]', id, 'worker');
|
||||
}
|
||||
function renderTree(id) {
|
||||
const tree = item('trees', id);
|
||||
if (!tree) return;
|
||||
const locale = language();
|
||||
replace(
|
||||
'#tree-detail',
|
||||
`<div><span>${data.labels.owner[locale]}</span><strong>${tree.owner[locale]}</strong></div><div><span>CHECKOUT</span><strong>${tree.path}</strong></div><p>${tree.note[locale]}</p><code>${tree.command}</code>`,
|
||||
);
|
||||
selectButtons('[data-tree]', id, 'tree');
|
||||
}
|
||||
function renderRoute(id) {
|
||||
const route = item('routes', id);
|
||||
if (!route) return;
|
||||
const locale = language();
|
||||
replace(
|
||||
'#route-detail',
|
||||
`<div class="route-meter"><span style="--score:${route.score}%"></span></div><div><small>${data.labels.reasoningLoad[locale]} · ${route.score}</small><strong>${route.label[locale]}</strong><p>${route.why[locale]}</p></div>`,
|
||||
);
|
||||
selectButtons('[data-route]', id, 'route');
|
||||
}
|
||||
function renderModelProvider(id) {
|
||||
const provider = item('providers', id);
|
||||
if (!provider) return;
|
||||
const locale = language();
|
||||
const tiers = provider.tiers
|
||||
.map(
|
||||
([kind, name, note]) =>
|
||||
`<div><span>${locale === 'pt' ? { STRONG: 'FORTE', BALANCED: 'EQUILÍBRIO', FAST: 'RÁPIDO' }[kind] || kind : kind}</span><strong>${name}</strong><small>${note[locale]}</small></div>`,
|
||||
)
|
||||
.join('');
|
||||
replace(
|
||||
'#provider-detail',
|
||||
`<header><span>${provider.label}</span><a href="${provider.source}" target="_blank" rel="noopener">${data.labels.officialSource[locale]}</a></header><h3>${provider.title[locale]}</h3><p>${provider.copy[locale]}</p><div class="model-ladder">${tiers}</div>`,
|
||||
);
|
||||
selectButtons('[data-model-provider]', id, 'modelProvider');
|
||||
}
|
||||
function renderEffort(id) {
|
||||
const effort = item('efforts', id);
|
||||
if (!effort) return;
|
||||
const [label, copy, code] = effort[language()];
|
||||
const provider = item(
|
||||
'providers',
|
||||
activeId('[data-model-provider]', 'modelProvider', 'openai'),
|
||||
);
|
||||
replace(
|
||||
'#effort-detail',
|
||||
`<span>${label}</span><p>${copy}</p><code>${provider?.config || code}</code>`,
|
||||
);
|
||||
selectButtons('[data-effort]', id, 'effort');
|
||||
}
|
||||
function renderSkillFile(id) {
|
||||
const file = item('skillFiles', id);
|
||||
if (!file) return;
|
||||
const locale = language();
|
||||
replace(
|
||||
'#skill-detail',
|
||||
`<span>${file.icon}</span><div><strong>${file.title}</strong><p>${file[locale]}</p><small>${data.labels.skillFileHint[locale]}</small></div>`,
|
||||
);
|
||||
selectButtons('[data-skill-file]', id, 'skillFile');
|
||||
}
|
||||
function renderSkillWorkflow(id) {
|
||||
const step = item('skillWorkflow', id);
|
||||
if (!step) return;
|
||||
const locale = language();
|
||||
const labels = data.labels.workflow;
|
||||
replace(
|
||||
'#builder-detail',
|
||||
`<header><span>${step.number}</span><small>${labels.question[locale]}</small></header><h3>${step.title[locale]}</h3><blockquote>${step.question[locale]}</blockquote><div class="builder-action"><span>${labels.action[locale]}</span><p>${step.action[locale]}</p></div><footer><div><span>${labels.artifact[locale]}</span><strong>${step.output[locale]}</strong></div><div><span>${labels.proof[locale]}</span><strong>${step.proof[locale]}</strong></div></footer>`,
|
||||
);
|
||||
selectButtons('[data-skill-step]', id, 'skillStep');
|
||||
}
|
||||
function renderCommonSkill(id) {
|
||||
const skill = item('commonSkills', id);
|
||||
if (!skill) return;
|
||||
const locale = language();
|
||||
const labels = data.labels.commonSkill;
|
||||
replace(
|
||||
'#common-skill-detail',
|
||||
`<header><span>${skill.number}</span><small>${skill.kind[locale]}</small></header><h3>${skill.title}</h3><blockquote>${skill.rule[locale]}</blockquote><div class="common-skill-notes"><div><span>${labels.whenToUse[locale]}</span><p>${skill.use[locale]}</p></div><div><span>${labels.example[locale]}</span><p>${skill.example[locale]}</p></div><div><span>${labels.watchOut[locale]}</span><p>${skill.caution[locale]}</p></div></div><a class="skill-source" href="${skill.source}" target="_blank" rel="noopener">${labels.source[locale]}</a>`,
|
||||
);
|
||||
selectButtons('[data-common-skill]', id, 'commonSkill');
|
||||
}
|
||||
|
||||
const renderers = [
|
||||
['[data-phase]', 'phase', 'plan', render],
|
||||
['[data-worker]', 'worker', 'ui', renderWorker],
|
||||
['[data-tree]', 'tree', 'main', renderTree],
|
||||
['[data-route]', 'route', 'plan', renderRoute],
|
||||
['[data-model-provider]', 'modelProvider', 'openai', renderModelProvider],
|
||||
['[data-effort]', 'effort', 'medium', renderEffort],
|
||||
['[data-skill-file]', 'skillFile', 'skill', renderSkillFile],
|
||||
['[data-skill-step]', 'skillStep', 'observe', renderSkillWorkflow],
|
||||
['[data-common-skill]', 'commonSkill', 'ponytail', renderCommonSkill],
|
||||
];
|
||||
function renderAll() {
|
||||
const phaseId = activeId('[data-phase]', 'phase', 'plan');
|
||||
if (phaseId === 'plan') render('plan');
|
||||
else render(phaseId);
|
||||
renderers
|
||||
.slice(1)
|
||||
.forEach(([selector, key, fallback, renderer]) =>
|
||||
renderer(activeId(selector, key, fallback)),
|
||||
);
|
||||
}
|
||||
function moveTab(event) {
|
||||
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
||||
return;
|
||||
const tabs = [...event.currentTarget.querySelectorAll('[role="tab"]')];
|
||||
if (!tabs.length) return;
|
||||
event.preventDefault();
|
||||
const current = tabs.indexOf(document.activeElement);
|
||||
const offset = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1;
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: (current + offset + tabs.length) % tabs.length;
|
||||
tabs[next].click();
|
||||
tabs[next].focus();
|
||||
}
|
||||
function bind() {
|
||||
renderers.forEach(([selector, key, fallback, renderer]) =>
|
||||
root.querySelectorAll(selector).forEach((button) =>
|
||||
button.addEventListener('click', () => {
|
||||
renderer(button.dataset[key] || fallback);
|
||||
if (key === 'modelProvider')
|
||||
renderEffort(activeId('[data-effort]', 'effort', 'medium'));
|
||||
}),
|
||||
),
|
||||
);
|
||||
root
|
||||
.querySelectorAll('[role="tablist"]')
|
||||
.forEach((list) => list.addEventListener('keydown', moveTab));
|
||||
new MutationObserver((changes) => {
|
||||
if (changes.some((change) => change.attributeName === 'lang')) renderAll();
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
|
||||
window.addEventListener('ai-for-dummies:languagechange', renderAll);
|
||||
renderAll();
|
||||
}
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
bind();
|
||||
}
|
||||
});
|
||||
observer.observe(root);
|
||||
} else bind();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Verbatim from `responsive.css`, where every selector button in these nine
|
||||
groups shares one focus ring. Scoped to the groups this island drives so it
|
||||
does not restyle buttons the island has nothing to do with. */
|
||||
:global([data-worker]:focus-visible),
|
||||
:global([data-tree]:focus-visible),
|
||||
:global([data-route]:focus-visible),
|
||||
:global([data-model-provider]:focus-visible),
|
||||
:global([data-effort]:focus-visible),
|
||||
:global([data-skill-file]:focus-visible),
|
||||
:global([data-skill-step]:focus-visible),
|
||||
:global([data-common-skill]:focus-visible),
|
||||
:global([data-phase]:focus-visible) {
|
||||
outline: 3px solid var(--gold);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
// LanguageToggle — idle-hydrated, full-guide-only locale control.
|
||||
//
|
||||
// The guide server-renders both locales. This leaf owns the persisted locale
|
||||
// and announces changes so selector islands can rebuild their active panels
|
||||
// from collection data without knowing about this control's markup.
|
||||
---
|
||||
|
||||
<div class="lang-switch" aria-label="Language" data-language-toggle>
|
||||
<button class="active" data-lang="en" aria-pressed="true">EN</button>
|
||||
<span>/</span>
|
||||
<button data-lang="pt" aria-pressed="false">PT</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
type Language = 'en' | 'pt';
|
||||
|
||||
function setup(root: HTMLElement) {
|
||||
const buttons = root.querySelectorAll<HTMLButtonElement>('[data-lang]');
|
||||
|
||||
function renderLanguage(next: Language) {
|
||||
document.documentElement.lang = next === 'pt' ? 'pt-BR' : 'en';
|
||||
buttons.forEach((button) => {
|
||||
const isActive = button.dataset.lang === next;
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-pressed', String(isActive));
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>('[data-language-content]').forEach((node) => {
|
||||
node.hidden = node.dataset.languageContent !== next;
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ai-for-dummies:languagechange', { detail: { language: next } }),
|
||||
);
|
||||
}
|
||||
|
||||
function selectLanguage(value: string | undefined): Language {
|
||||
return value === 'pt' ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const language = selectLanguage(button.dataset.lang);
|
||||
renderLanguage(language);
|
||||
try {
|
||||
localStorage.setItem('ai-for-dummies-language', language);
|
||||
} catch {
|
||||
// Preview environments can disable storage.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let savedLanguage = 'en';
|
||||
try {
|
||||
savedLanguage = localStorage.getItem('ai-for-dummies-language') || 'en';
|
||||
} catch {
|
||||
// Preview environments can disable storage.
|
||||
}
|
||||
renderLanguage(selectLanguage(savedLanguage));
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-language-toggle]').forEach((root) => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => setup(root));
|
||||
} else {
|
||||
setTimeout(() => setup(root), 0);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
// ReadingProgress — scroll-position bar. One instance per page.
|
||||
//
|
||||
// Renders the same markup the legacy `full-guide/index.html` line 13
|
||||
// carried: `<div class="reading-progress" aria-hidden="true"><span></span></div>`.
|
||||
// The scroll listener is registered with `{ passive: true }`, matching
|
||||
// app.js line 406 and the rule in `.agents/rules/animation.md` that a
|
||||
// passive listener is required for any handler bound to a frequently
|
||||
// fired event like scroll. Animating `width` here is fine — the bar
|
||||
// is a 3px element at the very top of the viewport, so layout cost
|
||||
// is negligible compared to the alternative of subscribing to
|
||||
// IntersectionObserver and computing progress from a single sentinel
|
||||
// per section.
|
||||
//
|
||||
// Language: this island does not read language. It has no string
|
||||
// labels and the same progress semantics apply to EN and PT.
|
||||
|
||||
interface Props {
|
||||
// Optional override selector. Defaults to `.reading-progress span`
|
||||
// to match the legacy app.js query, in case a page renders the bar
|
||||
// with a different class name.
|
||||
target?: string;
|
||||
}
|
||||
|
||||
const { target = '.reading-progress span' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="reading-progress" aria-hidden="true">
|
||||
<span data-reading-progress-span></span>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ selector: target }}>
|
||||
// ReadingProgress island — passive scroll listener.
|
||||
// Wire-once guard mirrors CopyPrompt: a page that renders the bar
|
||||
// twice would otherwise stack two scroll handlers on `window`.
|
||||
(function () {
|
||||
if (window.__readingProgressWired) return;
|
||||
window.__readingProgressWired = true;
|
||||
|
||||
const span = document.querySelector(selector);
|
||||
if (!span) return;
|
||||
|
||||
function update() {
|
||||
const height = document.documentElement.scrollHeight - window.innerHeight;
|
||||
span.style.width = (height > 0 ? (window.scrollY / height) * 100 : 0) + '%';
|
||||
}
|
||||
|
||||
// `{ passive: true }` is non-negotiable. The legacy app.js binds
|
||||
// this listener passive, and a non-passive scroll handler on the
|
||||
// top edge of the document is exactly the kind of input latency
|
||||
// that fails INP. See `.agents/rules/animation.md`.
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
// Set initial state — important when the user navigates with
|
||||
// #hash deep links and lands partway down a long page.
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user