Merge branch 'main' into refactor/task-15b-copy-prompt

This commit is contained in:
Marcos Paulo
2026-09-05 17:51:30 +00:00
3 changed files with 402 additions and 0 deletions
+40
View File
@@ -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 todays `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.
+294
View File
@@ -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>