Merge branch 'main' into refactor/task-15b-copy-prompt
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# Context: full-guide language switching
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The Astro full guide keeps the current client-side language switch on its
|
||||||
|
existing `/full-guide/` URL. It server-renders both locale variants and the
|
||||||
|
language-toggle island shows the selected variant after `client:idle` hydration.
|
||||||
|
|
||||||
|
This deliberately preserves the current no-URL-change contract, including links
|
||||||
|
shared without a locale segment, and avoids a route/redirect and publishing
|
||||||
|
change. The cost is duplicated localized HTML and both locales in the response.
|
||||||
|
That is acceptable for this small guide and avoids sending duplicated string
|
||||||
|
data through every interactive island.
|
||||||
|
|
||||||
|
## Markup and island contract for task 15d
|
||||||
|
|
||||||
|
- Render each static localized fragment twice. Put `data-language-content="en"`
|
||||||
|
or `data-language-content="pt"` on its outer element. English is visible in
|
||||||
|
server HTML; the toggle uses the native `hidden` attribute for the inactive
|
||||||
|
locale.
|
||||||
|
- Add `<LanguageToggle />` to the guide top bar. Astro's `client:idle` directive
|
||||||
|
is only valid for framework components; this `.astro` island defers its
|
||||||
|
browser setup with `requestIdleCallback` (and a timeout fallback) instead. Do
|
||||||
|
not hydrate the page or use `client:load`; the control is deliberately
|
||||||
|
idle-priority.
|
||||||
|
- The island owns the `ai-for-dummies-language` localStorage key. Every read and
|
||||||
|
write remains inside `try`/`catch`, because previews may disable storage.
|
||||||
|
- On each selection the island sets `<html lang>` to `en` or `pt-BR`, updates
|
||||||
|
its `[data-lang]` buttons' `.active` class and `aria-pressed` state, updates
|
||||||
|
`[data-language-content]`, then dispatches `ai-for-dummies:languagechange` on
|
||||||
|
`window`. The event detail is `{ language: 'en' | 'pt' }`.
|
||||||
|
- The guide selector island (15a) must read `document.documentElement.lang` when
|
||||||
|
it hydrates and listen for that event. On receipt it must re-render the
|
||||||
|
currently active phase and all active selector panels from their collection
|
||||||
|
data. This preserves today’s `applyLanguage` behaviour without coupling the
|
||||||
|
toggle to page selectors.
|
||||||
|
|
||||||
|
This is a page-local contract: the existing `/rules/` toggle continues using its
|
||||||
|
own `rules-language` key and must not be changed as part of full-guide
|
||||||
|
migration.
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user