Files
ai-for-dummies/src/components/islands/GuideSelector.astro
T
Marcos Paulo 9ca3f48def style(motion): add strict animations and fix layout properties
- Change ReadingProgress and RouteTable to use transform (scaleX/scaleY) instead of width/height
- Convert existing easing functions to 200ms cubic-bezier(.2,0,0,1)
- Document animation purpose with CSS comments
- Add guide panel swap, review desk detail swap, and tally pop animations
- Implement prefers-reduced-motion for all new states
2026-09-05 23:13:59 +00:00

319 lines
12 KiB
TypeScript

---
// 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) return;
panel.innerHTML = markup;
panel.classList.remove('is-swapping');
void panel.offsetWidth;
panel.classList.add('is-swapping');
}
// 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;
}
:global(.is-swapping > *) {
/* purpose: tab panel change in the guide (state change) */
animation: guide-swap 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes guide-swap {
from {
opacity: 0.15;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:global(.is-swapping > *) {
animation: none;
}
}
</style>