Checked the section against MOBILE.md at its three reference widths instead of assuming the breakpoints were enough. Two real failures at 360px. The arrows sat in the heading's flex row, taking 96px from a 328px card and crushing the lede into a four-line column — for a control that swiping already provides. Below 640px they are now gone, the header is a single column, one card shows at 86% so the next one peeks, and the affordance is the right-edge scroll-fade MOBILE.md already documents for horizontal scrollers. The fade lifts at the end of the travel, so the at-end state is computed whether or not an arrow exists to consume it. The arrow and add-to-compare buttons were 40px against a 44px floor. Both are 44 now. A card title's own box is shorter, but its hit area is the whole card through the ::after overlay, so it passes on the target that actually receives the tap. 360, 390 and 430 now all report zero overflow, no failing tap targets and no text under 11px. MOBILE.md wanted a Playwright width check and recorded that Playwright was not in the project; it is, so the journey now carries one for this page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
374 lines
20 KiB
HTML
374 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Similar Schools Nearby</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* Tokens copied verbatim from nextjs-app/app/(frontend)/globals.css so this
|
||
mockup cannot drift from the shipped palette. Light values first, dark
|
||
under prefers-color-scheme, both overridable by the theme switch. */
|
||
:root {
|
||
color-scheme: light;
|
||
--bg-primary:#FAFAF8; --bg-secondary:#F5EFE6; --bg-card:#FFFFFF;
|
||
--text-primary:#1C2731; --text-secondary:#4A5560; --text-muted:#5F6A75;
|
||
--border:#E5E7EB; --border-strong:#D3D7DD;
|
||
--brand:#0F766E; --brand-strong:#0C5F58; --brand-bg:rgba(15,118,110,.10); --brand-on:#FFFFFF;
|
||
--action:#BE3C27; --action-strong:#A33320; --action-on:#FFFFFF;
|
||
--sand:#F5EFE6;
|
||
--font-display:Manrope,-apple-system,BlinkMacSystemFont,sans-serif;
|
||
--font-ui:Inter,-apple-system,BlinkMacSystemFont,sans-serif;
|
||
--radius-md:8px; --radius-lg:16px;
|
||
--shadow:0 1px 2px rgba(28,39,49,.06),0 1px 3px rgba(28,39,49,.05);
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
color-scheme: dark;
|
||
--bg-primary:#111A20; --bg-secondary:#16222A; --bg-card:#18242C;
|
||
--text-primary:#E9EEF0; --text-secondary:#B4C2C7; --text-muted:#8B9AA1;
|
||
--border:#26343D; --border-strong:#35454F;
|
||
--brand:#5FC7BB; --brand-strong:#7BD6CC; --brand-bg:rgba(95,199,187,.14); --brand-on:#0A1418;
|
||
--action:#F08A72; --action-strong:#F5A492; --action-on:#241009;
|
||
--sand:#1B2730;
|
||
--shadow:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.25);
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
--bg-primary:#111A20; --bg-secondary:#16222A; --bg-card:#18242C;
|
||
--text-primary:#E9EEF0; --text-secondary:#B4C2C7; --text-muted:#8B9AA1;
|
||
--border:#26343D; --border-strong:#35454F;
|
||
--brand:#5FC7BB; --brand-strong:#7BD6CC; --brand-bg:rgba(95,199,187,.14); --brand-on:#0A1418;
|
||
--action:#F08A72; --action-strong:#F5A492; --action-on:#241009;
|
||
--sand:#1B2730;
|
||
--shadow:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.25);
|
||
}
|
||
* { box-sizing:border-box; }
|
||
body {
|
||
margin:0; padding:32px 16px 80px; background:var(--bg-primary);
|
||
color:var(--text-primary); font:15px/1.55 var(--font-ui);
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
.page { max-width:960px; margin:0 auto; }
|
||
.page > header { margin-bottom:28px; display:flex; flex-wrap:wrap; gap:16px; align-items:flex-start; justify-content:space-between; }
|
||
.page > header h1 { font:700 25px/1.25 var(--font-display); letter-spacing:-.6px; margin:0 0 6px; }
|
||
.page > header p { margin:0; color:var(--text-muted); font-size:14px; max-width:60ch; }
|
||
.theme-switch { border:1px solid var(--border-strong); background:var(--bg-card); color:var(--text-secondary); border-radius:999px; padding:8px 14px; font:500 13px var(--font-ui); cursor:pointer; min-height:44px; }
|
||
.theme-switch:hover { border-color:var(--brand); color:var(--brand); }
|
||
|
||
/* ── The page context each variant is shown inside ─────────────────── */
|
||
.variant { margin-bottom:40px; }
|
||
.variant > .context { padding:0 4px 12px; }
|
||
.variant .eyebrow { margin:0 0 4px; font-size:12px; letter-spacing:.04em; text-transform:uppercase; color:var(--text-muted); }
|
||
.variant .context h2 { font:600 18px/1.35 var(--font-display); margin:0; color:var(--text-secondary); }
|
||
.variant .note { margin:10px 4px 0; font-size:12.5px; color:var(--text-muted); }
|
||
.variant .note b { color:var(--text-secondary); font-weight:600; }
|
||
|
||
/* ── The section itself — mirrors components/school/Section ────────── */
|
||
.card {
|
||
background:var(--bg-card); border:1px solid var(--border);
|
||
border-radius:var(--radius-lg); padding:28px; box-shadow:var(--shadow);
|
||
}
|
||
.top { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||
.top h2 { font:700 22px/1.25 var(--font-display); letter-spacing:-.4px; margin:0; }
|
||
.lede { margin:8px 0 20px; color:var(--text-secondary); font-size:14.5px; max-width:64ch; }
|
||
|
||
/* ── Carousel ───────────────────────────────────────────────────────
|
||
Every card is in the DOM and in the initial HTML — the arrows scroll a
|
||
list, they do not swap a view. That keeps all six links crawlable and
|
||
keeps the section usable with no JavaScript, where it degrades to a
|
||
plain horizontally scrollable row. */
|
||
.arrows { display:flex; gap:8px; flex:none; }
|
||
.arrow {
|
||
width:44px; height:44px; display:grid; place-items:center; cursor:pointer;
|
||
border:1px solid var(--border-strong); border-radius:999px;
|
||
background:var(--bg-card); color:var(--brand);
|
||
}
|
||
.arrow:hover:not(:disabled) { border-color:var(--brand); background:var(--brand-bg); }
|
||
.arrow:disabled { opacity:.35; cursor:default; }
|
||
.arrow:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
|
||
.arrow svg { width:17px; height:17px; }
|
||
|
||
.scroller {
|
||
display:grid; grid-auto-flow:column;
|
||
grid-auto-columns:calc((100% - 28px) / 3);
|
||
gap:14px; overflow-x:auto; scroll-snap-type:x mandatory;
|
||
padding:2px; margin:-2px; /* room for focus rings */
|
||
scrollbar-width:none; -ms-overflow-style:none;
|
||
list-style:none;
|
||
}
|
||
.scroller::-webkit-scrollbar { display:none; }
|
||
.scroller:focus-visible { outline:2px solid var(--brand); outline-offset:4px; border-radius:var(--radius-md); }
|
||
@media (max-width:820px) { .scroller { grid-auto-columns:calc((100% - 14px) / 2); } }
|
||
/* Touch widths: the arrows would squeeze the lede into a four-line column for a
|
||
control that swiping already provides, so they go and the documented
|
||
right-edge fade carries the affordance instead (MOBILE.md). The fade lifts at
|
||
the end of the travel, where there is nothing more to hint at. */
|
||
@media (max-width:640px) {
|
||
.top { display:block; }
|
||
.arrows { display:none; }
|
||
.scroller { grid-auto-columns:86%; mask-image:linear-gradient(to right, #000 calc(100% - 28px), transparent); }
|
||
.scroller[data-at-end=true] { mask-image:none; }
|
||
.card { padding:20px; }
|
||
}
|
||
|
||
.school {
|
||
position:relative; display:flex; flex-direction:column; scroll-snap-align:start;
|
||
border:1px solid var(--border); border-radius:var(--radius-md);
|
||
padding:16px; background:var(--bg-card);
|
||
}
|
||
.school:has(.add[aria-pressed=true]) { border-color:var(--brand); background:var(--brand-bg); }
|
||
.distance { display:flex; align-items:center; gap:5px; font-size:12px; color:var(--text-muted); margin:0 0 10px; }
|
||
.distance svg { width:13px; height:13px; flex:none; }
|
||
.school h3 { font:600 16px/1.35 var(--font-display); margin:0 0 6px; }
|
||
/* The whole card is the link target; the button sits above it on z-index so
|
||
it stays independently clickable. */
|
||
.school h3 a { color:var(--text-primary); text-decoration:none; }
|
||
.school h3 a::after { content:""; position:absolute; inset:0; border-radius:var(--radius-md); }
|
||
.school:hover { border-color:var(--border-strong); }
|
||
.school h3 a:hover { color:var(--brand); text-decoration:underline; }
|
||
.school h3 a:focus-visible { outline:none; }
|
||
.school:has(h3 a:focus-visible) { outline:2px solid var(--brand); outline-offset:2px; }
|
||
.meta { margin:0 0 12px; font-size:12.5px; color:var(--text-muted); }
|
||
.shared { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 14px; padding:0; list-style:none; }
|
||
.shared li { font-size:11.5px; line-height:1.4; padding:4px 8px; border-radius:999px; background:var(--brand-bg); color:var(--brand); border:1px solid transparent; }
|
||
.shared li.loose { background:transparent; color:var(--text-muted); border-color:var(--border); }
|
||
.metric { margin-top:auto; padding-top:13px; border-top:1px solid var(--border); }
|
||
.value { font:700 26px/1.1 var(--font-display); letter-spacing:-.6px; margin:0; }
|
||
.value.absent { font-size:15px; font-weight:600; color:var(--text-muted); letter-spacing:0; }
|
||
.metric .label { margin:4px 0 0; font-size:12px; color:var(--text-secondary); }
|
||
.metric .ref { margin:2px 0 0; font-size:12px; color:var(--text-muted); }
|
||
.add {
|
||
position:relative; z-index:1; margin-top:14px; width:100%; min-height:44px;
|
||
font:500 13px var(--font-ui); cursor:pointer; border-radius:var(--radius-md);
|
||
border:1px solid var(--border-strong); background:var(--bg-card); color:var(--brand);
|
||
}
|
||
.add:hover { border-color:var(--brand); background:var(--brand-bg); }
|
||
.add[aria-pressed=true] { border-color:var(--brand); background:var(--brand-bg); font-weight:600; }
|
||
.add:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
|
||
|
||
.footer {
|
||
display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between;
|
||
gap:14px; margin-top:20px; padding-top:18px; border-top:1px solid var(--border);
|
||
}
|
||
.footer p { margin:0; font-size:12.5px; color:var(--text-muted); }
|
||
.footer strong { display:block; font:600 14px var(--font-ui); color:var(--text-primary); }
|
||
/* Coral: the one decisive action in this section, and there is only one. */
|
||
.compare {
|
||
min-height:44px; padding:0 20px; border-radius:var(--radius-md); cursor:pointer;
|
||
font:600 14px var(--font-ui); background:var(--action); color:var(--action-on);
|
||
border:1px solid var(--action); text-decoration:none; display:inline-flex; align-items:center; gap:8px;
|
||
}
|
||
.compare:hover { background:var(--action-strong); border-color:var(--action-strong); }
|
||
.compare[aria-disabled=true] { opacity:.45; pointer-events:none; }
|
||
.caption { margin:16px 0 0; font-size:11.5px; color:var(--text-muted); }
|
||
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header>
|
||
<div>
|
||
<h1>Similar schools nearby</h1>
|
||
<p>A new section on the school detail page. Fictional schools and figures; shipped
|
||
colour, type and section shell taken from <code>globals.css</code>.</p>
|
||
</div>
|
||
<button class="theme-switch" type="button" id="theme">Dark theme</button>
|
||
</header>
|
||
<div id="variants"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>';
|
||
const CHEV = (dir) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${dir === 'prev' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'}"/></svg>`;
|
||
|
||
const variants = [
|
||
{
|
||
id: 'dense',
|
||
eyebrow: 'Variant 1 · Dense urban primary — six matches, carousel active',
|
||
context: 'Meadowbrook Primary School — Ages 4–11 · Mixed · No religious character · Community school',
|
||
lede: 'Other primary schools near Meadowbrook Primary School, with a similar intake.',
|
||
metric: 'Reading, writing & maths',
|
||
caption: 'Meeting the expected standard at key stage 2, 2025.',
|
||
thisValue: '72%',
|
||
note: 'Fourteen schools cleared <b>tier 1</b> within three miles, so the section takes the six nearest and stops there. The arrows scroll a list that is entirely in the HTML — all six links are crawlable, and with JavaScript off the row still scrolls.',
|
||
schools: [
|
||
{ name:'Willow Lane Primary School', distance:'0.4', meta:'Community school · Ages 4–11', shared:['Mixed','No religious character'], value:'74%' },
|
||
{ name:'Oakfield Primary School', distance:'0.6', meta:'Academy converter · Ages 3–11', shared:['Mixed','No religious character'], value:'69%' },
|
||
{ name:'Brookside Primary School', distance:'0.9', meta:'Community school · Ages 4–11', shared:['Mixed','No religious character'], value:'Not published' },
|
||
{ name:'Hollytree Primary School', distance:'1.3', meta:'Academy converter · Ages 4–11', shared:['Mixed','No religious character'], value:'81%' },
|
||
{ name:'Marsh Green Primary School', distance:'1.8', meta:'Community school · Ages 3–11', shared:['Mixed','No religious character'], value:'64%' },
|
||
{ name:'Kingsway Primary School', distance:'2.2', meta:'Foundation school · Ages 4–11', shared:['Mixed','No religious character'], value:'77%' },
|
||
],
|
||
},
|
||
{
|
||
id: 'secondary',
|
||
eyebrow: 'Variant 2 · Secondary — four matches, mixed tiers',
|
||
context: 'Meadowbrook High School — Ages 11–18 · Mixed · Non-selective · Academy',
|
||
lede: 'Other secondary schools near Meadowbrook High School, with a similar intake.',
|
||
metric: 'Attainment 8',
|
||
caption: 'Average GCSE attainment score across eight qualifications, 2025.',
|
||
thisValue: '51.2',
|
||
note: 'Only two schools cleared tier 1, so the search widened to <b>tier 2</b> and found two more. It stops there rather than widening again to reach six — tiers relax to reach a usable set, never to fill the last slots. Selectivity never relaxes, so no grammar school can appear here.',
|
||
schools: [
|
||
{ name:'Rivermead High School', distance:'0.9', meta:'Academy converter · Ages 11–18', shared:['Mixed','Non-selective','No religious character'], value:'52.8' },
|
||
{ name:'Oakfield Academy', distance:'1.7', meta:'Academy sponsor led · Ages 11–16', shared:['Mixed','Non-selective','No religious character'], value:'49.6' },
|
||
{ name:'St Aidan’s Catholic High School', distance:'2.4', meta:'Voluntary aided · Ages 11–18', shared:['Mixed','Non-selective'], value:'53.4' },
|
||
{ name:'Parkside Community School', distance:'3.8', meta:'Community school · Ages 11–16', shared:['Mixed','Non-selective'], value:'50.9' },
|
||
],
|
||
},
|
||
{
|
||
id: 'sparse',
|
||
eyebrow: 'Variant 3 · Rural — two matches, no arrows',
|
||
context: 'Little Ashby Church of England Primary School — Ages 4–11 · Mixed · Church of England · Voluntary controlled',
|
||
lede: 'Other primary schools near Little Ashby Church of England Primary School.',
|
||
metric: 'Reading, writing & maths',
|
||
caption: 'Meeting the expected standard at key stage 2, 2025.',
|
||
thisValue: '66%',
|
||
note: 'Nothing matched on religious character within range. At <b>tier 3</b> the lede drops the phrase “with a similar intake” and the chips fall back to the plain phase. Two cards fit the row, so the arrows are not rendered at all. One school fewer and the section would not render either.',
|
||
schools: [
|
||
{ name:'Great Marden Primary School', distance:'4.2', meta:'Community school · Ages 4–11', shared:['Primary school'], loose:true, value:'71%' },
|
||
{ name:'Ashby Vale Academy', distance:'7.8', meta:'Academy converter · Ages 4–11', shared:['Primary school'], loose:true, value:'58%' },
|
||
],
|
||
},
|
||
];
|
||
|
||
const selected = Object.fromEntries(variants.map((v) => [v.id, new Set()]));
|
||
|
||
function card(v, s, i) {
|
||
const on = selected[v.id].has(i);
|
||
const absent = s.value === 'Not published';
|
||
return `
|
||
<li class="school">
|
||
<p class="distance">${PIN}${s.distance} miles away</p>
|
||
<h3><a href="#">${s.name}</a></h3>
|
||
<p class="meta">${s.meta}</p>
|
||
<ul class="shared">${s.shared.map((c) => `<li class="${s.loose ? 'loose' : ''}">${c}</li>`).join('')}</ul>
|
||
<div class="metric">
|
||
<p class="value ${absent ? 'absent' : ''}">${s.value}</p>
|
||
<p class="label">${v.metric}</p>
|
||
<p class="ref">${v.thisValue} at this school</p>
|
||
</div>
|
||
<button class="add" type="button" data-variant="${v.id}" data-index="${i}" aria-pressed="${on}">
|
||
${on ? '✓ Added to compare' : '+ Add to compare'}<span class="sr"> — ${s.name}</span>
|
||
</button>
|
||
</li>`;
|
||
}
|
||
|
||
function render() {
|
||
document.getElementById('variants').innerHTML = variants.map((v) => {
|
||
const count = selected[v.id].size;
|
||
// Three fit the row, so anything more is what the arrows are for.
|
||
const scrollable = v.schools.length > 3;
|
||
return `
|
||
<section class="variant">
|
||
<div class="context">
|
||
<p class="eyebrow">${v.eyebrow}</p>
|
||
<h2>${v.context}</h2>
|
||
</div>
|
||
<div class="card">
|
||
<div class="top">
|
||
<div>
|
||
<h2 id="h-${v.id}">Similar schools nearby</h2>
|
||
<p class="lede">${v.lede}</p>
|
||
</div>
|
||
${scrollable ? `<div class="arrows">
|
||
<button class="arrow" type="button" data-scroll="prev" data-variant="${v.id}" aria-label="Previous schools" aria-controls="sc-${v.id}">${CHEV('prev')}</button>
|
||
<button class="arrow" type="button" data-scroll="next" data-variant="${v.id}" aria-label="More schools" aria-controls="sc-${v.id}">${CHEV('next')}</button>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<ul class="scroller" id="sc-${v.id}" ${scrollable ? `tabindex="0" role="group" aria-labelledby="h-${v.id}"` : ''}>
|
||
${v.schools.map((s, i) => card(v, s, i)).join('')}
|
||
</ul>
|
||
|
||
<div class="footer">
|
||
<p aria-live="polite">
|
||
<strong>${count ? `${count} school${count === 1 ? '' : 's'} selected` : 'Compare side by side'}</strong>
|
||
${count ? 'This school is included automatically.' : 'Add a school to compare it with this one.'}
|
||
</p>
|
||
<a class="compare" href="#" aria-disabled="${count ? 'false' : 'true'}">
|
||
${count ? `Compare ${count + 1} schools` : 'Compare'} →
|
||
</a>
|
||
</div>
|
||
<p class="caption">Distances are straight-line from this school, not road distance.
|
||
${v.caption} Fictional schools and figures for this mockup.</p>
|
||
</div>
|
||
<p class="note">${v.note}</p>
|
||
</section>`;
|
||
}).join('');
|
||
|
||
variants.forEach((v) => {
|
||
const scroller = document.getElementById(`sc-${v.id}`);
|
||
if (scroller) syncArrows(v.id, scroller);
|
||
});
|
||
}
|
||
|
||
/** An arrow that scrolls nowhere is a dead control, so each end disables its own.
|
||
*
|
||
* EDGE is not paranoia. The scroller carries 2px of padding so focus rings are
|
||
* not clipped, and scroll-snap treats that padding as the first card's snap
|
||
* position — so a scroller sitting at its start reports scrollLeft 2, not 0.
|
||
* Sub-pixel rounding at other zoom levels moves it again. Testing against an
|
||
* exact 0 leaves the back arrow live at the start, pointing nowhere. */
|
||
const EDGE = 8;
|
||
|
||
function syncArrows(id, scroller) {
|
||
const max = scroller.scrollWidth - scroller.clientWidth;
|
||
const atStart = scroller.scrollLeft <= EDGE;
|
||
const atEnd = scroller.scrollLeft >= max - EDGE;
|
||
|
||
// Drives the mobile scroll-fade, so it is computed even where no arrow is
|
||
// rendered to consume it.
|
||
scroller.dataset.atEnd = String(atEnd);
|
||
|
||
const prev = document.querySelector(`.arrow[data-scroll="prev"][data-variant="${id}"]`);
|
||
const next = document.querySelector(`.arrow[data-scroll="next"][data-variant="${id}"]`);
|
||
if (!prev || !next) return;
|
||
prev.disabled = atStart;
|
||
next.disabled = atEnd;
|
||
}
|
||
|
||
document.getElementById('variants').addEventListener('click', (event) => {
|
||
const arrow = event.target.closest('.arrow');
|
||
if (arrow) {
|
||
const scroller = document.getElementById(`sc-${arrow.dataset.variant}`);
|
||
// A page is what the reader can see, so the viewport is the step.
|
||
scroller.scrollBy({ left: (arrow.dataset.scroll === 'next' ? 1 : -1) * scroller.clientWidth, behavior: 'smooth' });
|
||
return;
|
||
}
|
||
const button = event.target.closest('.add');
|
||
if (!button) return;
|
||
const { variant, index } = button.dataset;
|
||
const set = selected[variant];
|
||
const i = Number(index);
|
||
// Scroll position is DOM state, not React state; keep it across the re-render.
|
||
const offset = document.getElementById(`sc-${variant}`).scrollLeft;
|
||
set.has(i) ? set.delete(i) : set.add(i);
|
||
render();
|
||
const scroller = document.getElementById(`sc-${variant}`);
|
||
scroller.scrollLeft = offset;
|
||
syncArrows(variant, scroller);
|
||
document.querySelector(`.add[data-variant="${variant}"][data-index="${index}"]`).focus();
|
||
}, true);
|
||
|
||
document.getElementById('variants').addEventListener('scroll', (event) => {
|
||
const scroller = event.target.closest('.scroller');
|
||
if (scroller) syncArrows(scroller.id.replace('sc-', ''), scroller);
|
||
}, true);
|
||
|
||
const themeButton = document.getElementById('theme');
|
||
themeButton.addEventListener('click', () => {
|
||
const dark = document.documentElement.dataset.theme === 'dark';
|
||
document.documentElement.dataset.theme = dark ? 'light' : 'dark';
|
||
themeButton.textContent = dark ? 'Dark theme' : 'Light theme';
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|