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
+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>