From c2cfa488b0a82ccb2ddbf1656cdaa93eff72ff30 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 6 Sep 2026 20:17:14 +0200 Subject: [PATCH] feat(map): group the appellation filter by country MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter listed 177 region groups from 21 countries in one flat list ordered by size, so regions interleaved (FR Bourgogne, IT Toscana, GR Ελλάδα, FR Val de Loire, …) with no way to scan by country. Nest them under a country level: 21 collapsed rows, each with its flag, name and appellation + complementary-designation counts. Nesting also fixes an ambiguity the flat list had: regions were keyed by name alone, but Tokaj is both a Hungarian borrégió and a Slovak vinohradnícka oblasť, and the "no region" bucket spanned four countries. Both levels now key on (country, region); the omnisearch keeps its name-keyed pick (its index is the global region facet) via setRegionNameSelection. A cross-border appellation — a record carrying country_aliases, today only Maasvallei Limburg on the BE/NL border — is listed under every country it spans. It stays one record with one polygon and one slug, so ticking it on either side toggles the same filter entry and mirrors onto its twin row. country_aliases is promoted into STARTUP_AOCS_FIELDS because the tree needs it at first paint. Also: - a fully-selected subtree collapses to one chip (country first, else each whole region), with a flag prefix since the chip row has no nesting to disambiguate with; a one-record group is left uncollapsed so its appellation keeps its name. - bulk checkboxes are named after their row ("Select all — Burgundy") instead of ~200 controls all announcing a bare "select all". - both tree levels render mixed case. The region rows had been uppercased by the sidebar's global summary rule, which had been hiding that the FR bassin labels are INAO's own ALL-CAPS join key; the fr catalog now carries their proper names, as en/es/nl already did. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/_lib/assets/app.js | 377 ++++++++++++++++++++++++----------- scripts/_lib/map_template.py | 40 +++- 2 files changed, 288 insertions(+), 129 deletions(-) diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index 7594967..8c121f2 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -350,6 +350,10 @@ return REGION_LABELS[region] || region; } + function countryLabel(cc) { + return COUNTRY_LABELS[cc] || cc || ''; + } + function oneCountryChip(countryCode) { const flag = COUNTRY_FLAG_EMOJI[countryCode] || ''; const name = COUNTRY_LABELS[countryCode] || ''; @@ -662,7 +666,10 @@ else if (kind === 'principal') filters.principal.delete(key); else if (kind === 'accessory') filters.accessory.delete(key); else if (kind === 'appellation') filters.appellations.delete(key); - else if (kind === 'region') setRegionSelection(key, false); + // Region chips are country-scoped (the name alone is ambiguous — Tokaj is + // both Hungarian and Slovak), so the country rides along on the chip. + else if (kind === 'region') setSlugSelection(visibleSlugsInRegion(chip.dataset.country || '', key), false); + else if (kind === 'country') setSlugSelection(visibleSlugsInCountry(key), false); else if (kind === 'mainGrapeOnly') { filters.mainGrapeOnly = false; const m = document.getElementById('main-grape-only'); if (m) m.checked = false; @@ -677,7 +684,7 @@ } }); refreshSidebarCheckedState(); - refreshRegionTriStates(); + refreshTreeTriStates(); applyFilter(); }); @@ -837,41 +844,93 @@ buildGrapeChipFilter(container, role, set); }); - // Map of region → list of slugs, computed once. The appellation tree - // re-renders on spirits-toggle (entries appear/disappear), but the - // per-region grouping itself is stable across rebuilds. - const REGION_SLUGS = (() => { - const m = new Map(); - const order = FACET_REGIONS.map(([r]) => r); - for (const r of order) m.set(r, []); - m.set('', []); - for (const slug in AOCS) { - const r = AOCS[slug].region || ''; - if (!m.has(r)) m.set(r, []); - m.get(r).push(slug); + // Country → region → list of slugs, computed once. The tree re-renders on + // spirits-toggle (entries appear/disappear), but the grouping itself is + // stable across rebuilds. + // + // Two levels, not one: 21 countries contribute 177 regions, and a single + // flat list ordered by size interleaves them (FR Bourgogne, IT Toscana, + // GR Ελλάδα, FR Val de Loire, …) with no way to scan by country. Nesting + // also disambiguates the region names that repeat across a border — Tokaj + // is both a Hungarian borrégió and a Slovak vinohradnícka oblasť, so a + // region name alone is not a key. + // + // A cross-border appellation (a record carrying `country_aliases`, e.g. + // Maasvallei Limburg on the BE/NL border) is listed under EVERY country it + // spans. It stays one record with one polygon and one slug, so ticking it + // on either side toggles the same entry in `filters.appellations`. + const COUNTRY_TREE = (() => { + const byCountry = new Map(); + function push(cc, region, slug) { + let regions = byCountry.get(cc); + if (!regions) { regions = new Map(); byCountry.set(cc, regions); } + const arr = regions.get(region); + if (arr) arr.push(slug); else regions.set(region, [slug]); } - for (const arr of m.values()) { - arr.sort((a, b) => AOCS[a].name.localeCompare(AOCS[b].name, 'fr')); + for (const slug in AOCS) { + const rec = AOCS[slug]; + const region = rec.region || ''; + push(rec.country || '', region, slug); + for (const alias of rec.country_aliases || []) push(alias, region, slug); + } + function total(regions) { + let n = 0; + for (const arr of regions.values()) n += arr.length; + return n; + } + // Countries by corpus weight (the ordering every other facet uses), then + // by localized name. Regions the same within their country, except the + // "no region" residue bucket, pinned last however big it is. + const ordered = new Map(); + const countries = [...byCountry.entries()].sort((a, b) => + total(b[1]) - total(a[1]) || countryLabel(a[0]).localeCompare(countryLabel(b[0]), 'fr')); + for (const [cc, regions] of countries) { + const sorted = new Map([...regions.entries()].sort((a, b) => + (a[0] === '' ? 1 : 0) - (b[0] === '' ? 1 : 0) || + b[1].length - a[1].length || + regionLabel(a[0]).localeCompare(regionLabel(b[0]), 'fr'))); + for (const arr of sorted.values()) { + arr.sort((a, b) => AOCS[a].name.localeCompare(AOCS[b].name, 'fr')); + } + ordered.set(cc, sorted); } - return m; + return ordered; })(); - function visibleSlugsInRegion(region) { - const all = REGION_SLUGS.get(region) || []; + function visibleSlugsInRegion(country, region) { + const regions = COUNTRY_TREE.get(country); + const all = (regions && regions.get(region)) || []; if (spiritsVisible()) return all; return all.filter(s => AOCS[s].is_wine !== false); } - function setRegionSelection(region, on) { - const slugs = visibleSlugsInRegion(region); + function visibleSlugsInCountry(country) { + const regions = COUNTRY_TREE.get(country); + if (!regions) return []; + const out = []; + for (const region of regions.keys()) { + for (const s of visibleSlugsInRegion(country, region)) out.push(s); + } + return out; + } + + function setSlugSelection(slugs, on) { for (const s of slugs) { if (on) filters.appellations.add(s); else filters.appellations.delete(s); } } - function regionTriState(region) { - const slugs = visibleSlugsInRegion(region); + // The omnisearch indexes regions by NAME (its source is the global region + // facet), so picking one there selects that region in every country using + // the name — the one place Tokaj is treated as a single entry. + function setRegionNameSelection(region, on) { + for (const [cc, regions] of COUNTRY_TREE) { + if (regions.has(region)) setSlugSelection(visibleSlugsInRegion(cc, region), on); + } + } + + function triState(slugs) { if (!slugs.length) return 'empty'; let n = 0; for (const s of slugs) if (filters.appellations.has(s)) n++; @@ -885,8 +944,9 @@ // subzona, IT sottozona, DE Einzellage). One combined number implies a legal // precision it doesn't have — Burgundy reads 869 where the regulator // recognises 84 appellations carrying 785 complementary designations. - // Written by both the initial paint and the filter-driven refresh, so it - // lives here rather than being duplicated at either call site. + // Written by the initial paint and the filter-driven refresh, at both the + // country and the region row, so it lives here rather than being duplicated + // at each call site. function renderRegionCount(el, parents, subs) { if (!el) return; el.innerHTML = subs @@ -902,37 +962,49 @@ const el = document.getElementById('facet-appellations'); if (!el) return; // defensive null-guard const html = []; - for (const [region, allSlugs] of REGION_SLUGS) { - const slugs = spiritsVisible() ? allSlugs : allSlugs.filter(s => AOCS[s].is_wine !== false); - if (!slugs.length) continue; - const label = region ? regionLabel(region) : LABELS.meta_no_region; - const items = slugs.map(slug => { - const safeSlug = escapeAttr(slug); - const rec = AOCS[slug]; - const nameHtml = nameWithLatin(rec); - const checked = filters.appellations.has(slug) ? ' checked' : ''; - const openLbl = escapeAttr(fmt(LABELS.open_appellation_aria, { name: rec.name || slug })); - return ``; - }).join(''); - let parentCount = 0; - for (const s of slugs) if (!AOCS[s].is_sub_denomination) parentCount++; - const subCount = slugs.length - parentCount; - const safeRegion = escapeAttr(region); - // Checkbox lives outside `` (sibling of `
`, - // not a descendant) so the nested-interactive-in-summary - // accessibility warning doesn't fire. Visual layout is restored - // via `.region-group-wrap`'s flex rule — checkbox + disclosure - // sit in the same row. - html.push(`
${escapeHtml(label)}
${items}
`); + const selectAllAria = name => escapeAttr(`${LABELS.select_all_aria} — ${name}`); + for (const [cc, regions] of COUNTRY_TREE) { + const safeCountry = escapeAttr(cc); + const regionHtml = []; + let countryParents = 0; + let countryTotal = 0; + for (const region of regions.keys()) { + const slugs = visibleSlugsInRegion(cc, region); + if (!slugs.length) continue; + const items = slugs.map(slug => { + const safeSlug = escapeAttr(slug); + const rec = AOCS[slug]; + const nameHtml = nameWithLatin(rec); + const checked = filters.appellations.has(slug) ? ' checked' : ''; + const openLbl = escapeAttr(fmt(LABELS.open_appellation_aria, { name: rec.name || slug })); + return ``; + }).join(''); + let parentCount = 0; + for (const s of slugs) if (!AOCS[s].is_sub_denomination) parentCount++; + countryParents += parentCount; + countryTotal += slugs.length; + const safeRegion = escapeAttr(region); + // Checkbox lives outside `` (sibling of `
`, + // not a descendant) so the nested-interactive-in-summary + // accessibility warning doesn't fire. Visual layout is restored + // via `.region-group-wrap`'s flex rule — checkbox + disclosure + // sit in the same row. Both levels carry `data-country`: a region + // name is only unique within its country. + regionHtml.push(`
${escapeHtml(regionLabel(region))}
${items}
`); + } + if (!regionHtml.length) continue; + const flag = COUNTRY_FLAG_EMOJI[cc] || ''; + const flagHtml = flag ? `` : ''; + html.push(`
${flagHtml}${escapeHtml(countryLabel(cc))}
${regionHtml.join('')}
`); } el.innerHTML = html.join(''); - el.querySelectorAll('.region-group > summary > .count').forEach(c => { + el.querySelectorAll('.region-group > summary > .count, .country-group > summary > .count').forEach(c => { renderRegionCount(c, +c.dataset.parents, +c.dataset.subs); }); // Reapply current search visibility (so a tree rebuild during a typed // query keeps the filtered view). refreshFacetVisibility('facet-appellations', filters.q); - refreshRegionTriStates(); + refreshTreeTriStates(); } // Single delegated listener — buildAppellationFacet may run multiple @@ -941,25 +1013,40 @@ document.getElementById('facet-appellations')?.addEventListener('change', e => { const el = document.getElementById('facet-appellations'); if (e.target.tagName !== 'INPUT') return; - if (e.target.classList.contains('region-select')) { - const region = e.target.dataset.region; - setRegionSelection(region, e.target.checked); - for (const inp of el.querySelectorAll( - `.region-group[data-region="${CSS.escape(region)}"] .region-items input[type=checkbox]` - )) { + // A bulk row (country or region) may share slugs with another country's + // subtree — a cross-border appellation is listed on both sides — so the + // checkbox re-sync sweeps the whole tree rather than the toggled subtree. + const syncItemCheckboxes = () => { + for (const inp of el.querySelectorAll('.region-items input[type=checkbox]')) { inp.checked = filters.appellations.has(inp.dataset.key); } + }; + if (e.target.classList.contains('country-select')) { + const cc = e.target.dataset.country; + setSlugSelection(visibleSlugsInCountry(cc), e.target.checked); + syncItemCheckboxes(); + if (e.target.checked) { + track('Filter Applied', { facet: 'country', value: cc || '(none)', locale: LANG }); + } + } else if (e.target.classList.contains('region-select')) { + const region = e.target.dataset.region; + setSlugSelection(visibleSlugsInRegion(e.target.dataset.country, region), e.target.checked); + syncItemCheckboxes(); if (e.target.checked) { track('Filter Applied', { facet: 'region', value: region || '(none)', locale: LANG }); } } else { const k = e.target.dataset.key; if (e.target.checked) filters.appellations.add(k); else filters.appellations.delete(k); + // Mirror onto the twin row when the slug is listed under two countries. + for (const inp of el.querySelectorAll(`input[data-key="${CSS.escape(k)}"]`)) { + inp.checked = e.target.checked; + } if (e.target.checked) { track('Filter Applied', { facet: 'appellation', value: k, locale: LANG }); } } - refreshRegionTriStates(); + refreshTreeTriStates(); applyFilter({ fit: true }); }); @@ -996,51 +1083,66 @@ } }); - function refreshRegionTriStates() { + function refreshTreeTriStates() { const el = document.getElementById('facet-appellations'); if (!el) return; - el.querySelectorAll('.region-group').forEach(group => { - const region = group.dataset.region; - // `.region-select` is a sibling of `.region-group` inside the - // `.region-group-wrap`, not a descendant. Reach via the parent. - const cb = (group.parentElement || group).querySelector('.region-select'); - if (!cb) return; - const state = regionTriState(region); - cb.checked = state === 'checked'; - cb.indeterminate = state === 'indeterminate'; - }); + // The bulk checkbox is a sibling of its `
` inside the wrap, not + // a descendant (see buildAppellationFacet) — reach it via the parent. + const paint = (selector, cls, slugsFor) => { + el.querySelectorAll(selector).forEach(group => { + const cb = (group.parentElement || group).querySelector(cls); + if (!cb) return; + const state = triState(slugsFor(group)); + cb.checked = state === 'checked'; + cb.indeterminate = state === 'indeterminate'; + }); + }; + paint('.region-group', '.region-select', + g => visibleSlugsInRegion(g.dataset.country, g.dataset.region)); + paint('.country-group', '.country-select', + g => visibleSlugsInCountry(g.dataset.country)); } function refreshFacetVisibility(containerId, q) { const el = document.getElementById(containerId); if (!el) return; const nq = searchNormalize(q); - // Appellation tree: groups + labels with data-name dataset. - const groups = el.querySelectorAll('.region-group'); - if (groups.length) { - groups.forEach(group => { - let visible = 0; - group.querySelectorAll('label').forEach(lbl => { - const match = !nq || lbl.dataset.name.includes(nq); - lbl.style.display = match ? '' : 'none'; - if (match) visible++; + // Appellation tree: country groups > region groups > labels with a + // data-name dataset. Auto-expand groups that hold a match while + // searching, but re-collapse them when the query is cleared — tracking + // which groups WE opened so one the user expanded by hand stays open. + // (Was: open-on-search with no matching collapse, leaving every group + // expanded after clear.) + const setOpenState = (group, hasMatch) => { + if (nq) { + if (hasMatch && !group.open) { group.open = true; group.dataset.autoOpened = '1'; } + } else if (group.dataset.autoOpened) { + group.open = false; + delete group.dataset.autoOpened; + } + }; + const showWrap = (group, wrapClass, visible) => { + const wrap = group.parentElement; + if (wrap && wrap.classList.contains(wrapClass)) wrap.style.display = visible ? '' : 'none'; + else group.style.display = visible ? '' : 'none'; + }; + const countryGroups = el.querySelectorAll('.country-group'); + if (countryGroups.length) { + countryGroups.forEach(countryGroup => { + let countryVisible = 0; + countryGroup.querySelectorAll('.region-group').forEach(group => { + let visible = 0; + group.querySelectorAll('label').forEach(lbl => { + const match = !nq || lbl.dataset.name.includes(nq); + lbl.style.display = match ? '' : 'none'; + if (match) visible++; + }); + showWrap(group, 'region-group-wrap', visible); + setOpenState(group, visible); + countryVisible += visible; }); - const wrap = group.parentElement; - if (wrap && wrap.classList.contains('region-group-wrap')) { - wrap.style.display = visible ? '' : 'none'; - } else { - group.style.display = visible ? '' : 'none'; - } - // Auto-expand matching groups while searching, but re-collapse them - // when the query is cleared — tracking which groups WE opened so a - // group the user expanded by hand stays open. (Was: open-on-search - // with no matching collapse, leaving every group expanded after clear.) - if (nq) { - if (visible && !group.open) { group.open = true; group.dataset.autoOpened = '1'; } - } else if (group.dataset.autoOpened) { - group.open = false; - delete group.dataset.autoOpened; - } + showWrap(countryGroup, 'country-group-wrap', countryVisible); + setOpenState(countryGroup, countryVisible); }); return; } @@ -1277,13 +1379,31 @@ for (const k of filters.classifications) chips.push({ kind: 'classification', key: k, label: CLASS_LABELS[k] || k }); for (const k of filters.grapesAll) chips.push({ kind: 'grapeAll', key: k, label: grapeName(k) }); if (filters.mainGrapeOnly) chips.push({ kind: 'mainGrapeOnly', key: '1', label: LABELS.main_grape_only_label }); + // Collapse a fully-selected subtree into one chip — a whole country first, + // else each of its whole regions. The flag prefix disambiguates region + // names shared across a border, which the tree does by nesting. const collapsed = new Set(); - for (const [region] of REGION_SLUGS) { - const slugs = visibleSlugsInRegion(region); - if (!slugs.length) continue; - if (slugs.every(s => filters.appellations.has(s))) { - chips.push({ kind: 'region', key: region, label: region ? regionLabel(region) : LABELS.meta_no_region }); - for (const s of slugs) collapsed.add(s); + for (const [cc, regions] of COUNTRY_TREE) { + const flag = COUNTRY_FLAG_EMOJI[cc] || ''; + const inCountry = visibleSlugsInCountry(cc); + if (inCountry.length > 1 && inCountry.every(s => filters.appellations.has(s))) { + chips.push({ + kind: 'country', key: cc, country: cc, + label: `${flag} ${countryLabel(cc)}`.trim(), + }); + for (const s of inCountry) collapsed.add(s); + continue; + } + for (const region of regions.keys()) { + const slugs = visibleSlugsInRegion(cc, region); + if (slugs.length < 2) continue; + if (slugs.every(s => filters.appellations.has(s))) { + chips.push({ + kind: 'region', key: region, country: cc, + label: `${flag} ${regionLabel(region)}`.trim(), + }); + for (const s of slugs) collapsed.add(s); + } } } for (const slug of filters.appellations) { @@ -1292,17 +1412,20 @@ if (rec) chips.push({ kind: 'appellation', key: slug, label: rec.name }); } el.innerHTML = chips.map(c => { - const cls = c.kind === 'region' ? 'filter-chip region-chip' : 'filter-chip'; + const bulk = c.kind === 'region' || c.kind === 'country'; + const cls = bulk ? 'filter-chip region-chip' : 'filter-chip'; const removeAria = fmt(LABELS.remove_filter_aria, { label: c.label }); - return `${escapeHtml(c.label)}`; + return `${escapeHtml(c.label)}`; }).join(''); } function regionsSelectedCount() { let n = 0; - for (const [region] of REGION_SLUGS) { - const st = regionTriState(region); - if (st === 'checked' || st === 'indeterminate') n++; + for (const [cc, regions] of COUNTRY_TREE) { + for (const region of regions.keys()) { + const st = triState(visibleSlugsInRegion(cc, region)); + if (st === 'checked' || st === 'indeterminate') n++; + } } return n; } @@ -1385,24 +1508,36 @@ const appEl = document.getElementById('facet-appellations'); if (appEl) { const except = new Set(['appellations']); - appEl.querySelectorAll('.region-group').forEach(group => { - let visible = 0; - let visibleParents = 0; - group.querySelectorAll('label').forEach(lbl => { - const inp = lbl.querySelector('input[type=checkbox]'); if (!inp) return; - const slug = inp.dataset.key; const rec = AOCS[slug]; - const reachable = rec ? matchesExceptFacets(rec, slug, except) : false; - const hide = !reachable && !inp.checked; - lbl.classList.toggle('facet-unavailable', hide); - if (!hide) { - visible++; - if (!(rec && rec.is_sub_denomination)) visibleParents++; - } + appEl.querySelectorAll('.country-group').forEach(countryGroup => { + let countryVisible = 0; + let countryParents = 0; + countryGroup.querySelectorAll('.region-group').forEach(group => { + let visible = 0; + let visibleParents = 0; + group.querySelectorAll('label').forEach(lbl => { + const inp = lbl.querySelector('input[type=checkbox]'); if (!inp) return; + const slug = inp.dataset.key; const rec = AOCS[slug]; + const reachable = rec ? matchesExceptFacets(rec, slug, except) : false; + const hide = !reachable && !inp.checked; + lbl.classList.toggle('facet-unavailable', hide); + if (!hide) { + visible++; + if (!(rec && rec.is_sub_denomination)) visibleParents++; + } + }); + (group.parentElement || group).classList.toggle('facet-unavailable', visible === 0); + renderRegionCount( + group.querySelector(':scope > summary > .count'), + visibleParents, visible - visibleParents, + ); + countryVisible += visible; + countryParents += visibleParents; }); - (group.parentElement || group).classList.toggle('facet-unavailable', visible === 0); + (countryGroup.parentElement || countryGroup) + .classList.toggle('facet-unavailable', countryVisible === 0); renderRegionCount( - group.querySelector(':scope > summary > .count'), - visibleParents, visible - visibleParents, + countryGroup.querySelector(':scope > summary > .count'), + countryParents, countryVisible - countryParents, ); }); } @@ -1509,13 +1644,13 @@ refreshAllGrapeChipFilters(); applyFilter({ fit: true }); } else if (type === 'region') { - setRegionSelection(key, true); + setRegionNameSelection(key, true); // Reflect the new selection in the appellation-tree checkboxes. const appEl = document.getElementById('facet-appellations'); if (appEl) appEl.querySelectorAll('input[type=checkbox]').forEach(inp => { if (inp.dataset.key) inp.checked = filters.appellations.has(inp.dataset.key); }); - refreshRegionTriStates(); + refreshTreeTriStates(); track('Omnisearch Result Picked', { type: 'region', locale: LANG }); applyFilter({ fit: true }); } else if (type === 'style') { diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index 8e68566..9489a70 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -1332,6 +1332,9 @@ def _country_name(cc: str) -> str: # buildAppellationFacet splits each region's count into appellations # vs complementary geographic designations. "is_sub_denomination", + # The appellation tree nests region groups under a country group, and + # lists a cross-border appellation under every country it spans. + "country_aliases", }) @@ -1954,11 +1957,12 @@ def _emit(slug: str, meta: dict, ssr: str, has_card: bool) -> None: #sidebar > details > summary .facet-badge:empty {{ display:none }} #sidebar > details > summary {{ display:flex; align-items:center }} #sidebar > details > summary .facet-label {{ flex:1 }} - .facet .region-group-wrap {{ display:flex; align-items:flex-start; gap:6px; margin:2px 0 }} - .facet .region-group-wrap > .region-select {{ accent-color:#934050; cursor:pointer; flex:0 0 auto; margin-top:6px }} - .facet .region-group-wrap > .region-select:checked, .facet .region-group-wrap > .region-select:indeterminate {{ accent-color:#934050 }} - .facet .region-group-wrap > .region-group {{ flex:1 1 auto; min-width:0 }} - .facet .region-group > summary {{ display:flex; align-items:center; gap:6px }} + .facet .region-group-wrap, .facet .country-group-wrap {{ display:flex; align-items:flex-start; gap:6px; margin:2px 0 }} + .facet .region-group-wrap > .region-select, .facet .country-group-wrap > .country-select {{ accent-color:#934050; cursor:pointer; flex:0 0 auto; margin-top:6px }} + .facet .region-group-wrap > .region-select:checked, .facet .region-group-wrap > .region-select:indeterminate, + .facet .country-group-wrap > .country-select:checked, .facet .country-group-wrap > .country-select:indeterminate {{ accent-color:#934050 }} + .facet .region-group-wrap > .region-group, .facet .country-group-wrap > .country-group {{ flex:1 1 auto; min-width:0 }} + .facet .region-group > summary, .facet .country-group > summary {{ display:flex; align-items:center; gap:6px }} #status {{ padding:8px 16px; font-size:11px; color:#aaa; background:#222; border-bottom:1px solid #333 }} #status .hint-action {{ background:none; border:0; padding:0; margin-left:6px; color:#9ac4ff; font:inherit; cursor:pointer; text-decoration:underline }} #status .hint-action:hover {{ color:#cfe0ff }} @@ -1985,18 +1989,38 @@ def _emit(slug: str, meta: dict, ssr: str, has_card: bool) -> None: .facet .open-aoc:hover {{ color:#fff; background:#333 }} .facet .open-aoc:focus-visible {{ opacity:1 }} .facet label.facet-unavailable {{ display:none }} - .facet .region-group-wrap.facet-unavailable {{ display:none }} + .facet .region-group-wrap.facet-unavailable, .facet .country-group-wrap.facet-unavailable {{ display:none }} .facet .tree-row[data-depth="0"] {{ padding-left:0 }} .facet .tree-row[data-depth="1"] {{ padding-left:14px }} .facet .tree-row[data-depth="2"] {{ padding-left:28px }} .facet .tree-row-parent {{ font-weight:600; color:#eee }} .facet .tree-row[data-depth="0"]:not(:first-child) {{ margin-top:4px }} .facet .tree-row[data-depth="2"] .name {{ color:#bbb }} - .facet .region-group > summary {{ padding:4px 0; border-top:none; font-size:10.5px; color:#888; letter-spacing:0.06em; display:flex; align-items:center; gap:6px }} + /* Mixed case here too. The FR bassin labels are the join key's own + ALL-CAPS form only until the catalog translates them — every locale + including fr now carries the proper name, so the transform that used + to hide that inconsistency is gone. */ + .facet .region-group > summary {{ padding:4px 0; border-top:none; font-size:11px; color:#9a9a9a; text-transform:none; letter-spacing:0.01em; display:flex; align-items:center; gap:6px }} .facet .region-group > summary:hover {{ color:#ddd }} .facet .region-group > summary .name {{ flex:1 }} .facet .region-group > summary .count {{ color:#555 }} - .facet .region-group .region-items {{ padding-left:10px }} + .facet .region-group .region-items {{ padding-left:8px }} + /* Country level sits above the regions: brighter and heavier, with the + flag as the scanning anchor. Regions keep their dimmer, smaller row. */ + /* Mixed case, against the sidebar's global uppercase `summary` rule: a + country name reads better next to its flag, and it separates the two + tree levels more clearly than size alone. The region rows keep the + uppercase — it normalises INAO's ALL-CAPS bassin names against the + mixed-case regions every other country stores. */ + .facet .country-group > summary {{ padding:5px 0; border-top:none; font-size:12px; font-weight:600; color:#c9c9c9; text-transform:none; letter-spacing:0.01em }} + .facet .country-group > summary:hover {{ color:#fff }} + .facet .country-group > summary .name {{ flex:1 }} + .facet .country-group > summary .country-flag {{ flex:0 0 auto; font-size:12px; line-height:1; letter-spacing:normal }} + .facet .country-group > summary .count {{ color:#666; font-weight:400 }} + /* Two levels of indent cost the appellation name ~10px of the ellipsis + budget, so each step is 8px rather than 10 — the disclosure marker and + the summary's weight carry most of the hierarchy anyway. */ + .facet .country-group .country-items {{ padding-left:8px }} .facet .empty {{ color:#666; font-size:11px; font-style:italic; padding:4px 0 }} #actions {{ position:sticky; bottom:0; background:#1a1a1a; padding:10px 16px; border-top:1px solid #333; display:flex; gap:8px }} #actions button {{ flex:1; padding:6px; background:#333; color:#eee; border:1px solid #555; border-radius:3px; cursor:pointer; font-size:12px }}