feat(guide): add shared selector island

Add one client-visible controller for the nine full-guide selectors. Keep static shells and page migration out of scope.
This commit is contained in:
Marcos Paulo
2026-09-05 17:44:20 +00:00
parent aa2653340a
commit 6ec1e31ec5
+283
View File
@@ -0,0 +1,283 @@
---
// 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'] });
document.addEventListener('ai-for-dummies:language-change', 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>
:global(button:focus-visible) {
outline: 3px solid var(--red);
outline-offset: 2px;
}
</style>