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