The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Region index"
Library page: https://astro.baysixmedia.com/components/region-index/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/RegionIndex.astro =====
---
/**
* RegionIndex (candidate, Step 36)
*
* A business's locations as a register: each place name set in condensed
* display type on a ruled row, optionally grouped under small region labels
* that sit in their own column beside the rows. Every row that has facts is a
* native disclosure (details/summary) that opens that place's own address,
* phone, email, hours, a short note and its actions — never hover-only. An
* optional primary location (a head office, a main clinic) is singled out
* above the index on a deep panel, always open. Per-place status renders only
* when supplied, with a small shape that differs by tone, not by colour alone.
*
* Three scales, chosen from the number of places in the index:
* few (1–4) names set large and every disclosure open, so a short list
* reads as a finished directory, never a sparse one;
* mid (5–9) the drawn-for case: closed rows, names at a strong size;
* many (10+) compact rows; on wide containers each group reads down two
* independent columns, so opening one place moves only its own
* column. `expand` ("auto" | "all" | "none") overrides the open
* default.
*
* Truth rules. Nothing is invented: every place, address line, phone, email,
* hours line, status, note, count and link renders only when supplied, in the
* order supplied. No default places. A group with no places does not render.
* A place with nothing but a name is a plain row, not an empty disclosure. No
* places and no primary location, no section. Links only through safeHref;
* tel: is built from the digits of the supplied number. No map, pin, icon per
* place or photography.
*
* Responsive (container queries on the section's own width): stacked below
* 960px — region label above its rows, facts in one column, then two from
* 560px. From 960px: with region groups, the heading and intro split across
* the top and each group becomes a register row (label column 3, places 9,
* label sticky); without groups, the lead sits beside the index (4:8). The
* primary panel sets its name beside its facts once its own width allows.
*
* Reads, never declares: --rix-bg, --rix-text, --rix-text-muted, --rix-brand,
* --rix-brand-text, --rix-border, --rix-panel-bg, --rix-panel-text,
* --rix-panel-text-muted, --rix-panel-border, --rix-family, --rix-display,
* --rix-grid-max, --rix-grid-gutter, --rix-heading-size. No script.
*/
export interface RegionIndexPlace {
/** The place as the business names it: a city, a branch, a clinic, an office. */
name: string;
/** Supplied status only, e.g. "Open", "Opening in spring", "Limited service". `tone` sets its mark. */
status?: { label: string; tone?: "active" | "upcoming" | "limited" } | null;
/** Address lines, as supplied. */
address?: readonly string[];
phone?: string;
email?: string;
/** Hours lines, as supplied. */
hours?: readonly string[];
/** One short line about this place, only from supplied facts. */
note?: string;
/** A directions or map link for this place. */
mapHref?: string;
/** One action for this place, e.g. booking at this branch. */
link?: { label: string; href: string } | null;
}
export interface RegionIndexGroup {
/** Small region label; omit for an unlabelled group. */
label?: string;
/** A supplied count line, e.g. "4 clinics". Never computed. */
count?: string;
places: readonly RegionIndexPlace[];
}
export interface RegionIndexProps {
heading: string;
headingLevel?: "h2" | "h3";
eyebrow?: string;
intro?: string;
/** The one location singled out above the index, always open. */
primary?: {
/** e.g. "Head office", "Main clinic". */
label?: string;
name: string;
status?: { label: string; tone?: "active" | "upcoming" | "limited" } | null;
address?: readonly string[];
phone?: string;
email?: string;
hours?: readonly string[];
note?: string;
mapHref?: string;
link?: { label: string; href: string } | null;
} | null;
/** Places grouped under region labels. */
groups?: readonly RegionIndexGroup[];
/** Places with no grouping; rendered after any groups. */
places?: readonly RegionIndexPlace[];
/** Which disclosures start open: "auto" opens all when the index holds four or fewer. */
expand?: "auto" | "all" | "none";
/** Field labels inside a place. */
labels?: { address?: string; phone?: string; email?: string; hours?: string; directions?: string };
/** One action under the intro. */
cta?: { label: string; href: string } | null;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends RegionIndexProps {}
const {
heading,
headingLevel = "h2",
eyebrow = "",
intro = "",
primary = null,
groups = [],
places = [],
expand = "auto",
labels = {},
cta = null,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..700&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string | undefined) => {
const h = (href ?? "").trim();
return h && SAFE.test(h) ? h : "";
};
const text = (v: unknown) => (typeof v === "string" ? v.trim() : "");
const lines = (v: readonly string[] | undefined) => (Array.isArray(v) ? v.map(text).filter(Boolean) : []);
const telHref = (n: string) => {
const digits = n.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
return digits.replace(/\D/g, "").length >= 3 ? `tel:${digits}` : "";
};
const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const L = {
address: text(labels?.address) || "Address",
phone: text(labels?.phone) || "Phone",
email: text(labels?.email) || "Email",
hours: text(labels?.hours) || "Hours",
directions: text(labels?.directions) || "Directions",
};
type Fact = { key: string; term: string; lines: string[]; href?: string };
type Act = { label: string; href: string; kind: "solid" | "line" };
type Clean = { name: string; status: { label: string; tone: string } | null; facts: Fact[]; note: string; actions: Act[]; peek: string };
function clean(p: RegionIndexPlace | null | undefined): Clean | null {
if (!p || typeof p !== "object") return null;
const name = text(p.name);
if (!name) return null;
const statusLabel = text(p.status?.label);
const tone = p.status?.tone && ["active", "upcoming", "limited"].includes(p.status.tone) ? p.status.tone : "plain";
const facts: Fact[] = [];
const address = lines(p.address);
if (address.length) facts.push({ key: "address", term: L.address, lines: address });
const phone = text(p.phone);
if (phone) {
const href = telHref(phone);
facts.push({ key: "phone", term: L.phone, lines: [phone], ...(href ? { href } : {}) });
}
const email = text(p.email);
if (email) facts.push({ key: "email", term: L.email, lines: [email], ...(emailOk(email) ? { href: `mailto:${email}` } : {}) });
const hours = lines(p.hours);
if (hours.length) facts.push({ key: "hours", term: L.hours, lines: hours });
const actions: Act[] = [];
const linkHref = safeHref(p.link?.href);
if (text(p.link?.label) && linkHref) actions.push({ label: text(p.link!.label), href: linkHref, kind: "solid" });
const mapHref = safeHref(p.mapHref);
if (mapHref) actions.push({ label: L.directions, href: mapHref, kind: "line" });
/* The first supplied address line (or the phone), shown in the closed row on wide rows so the resting index is never empty. */
return { name, status: statusLabel ? { label: statusLabel, tone } : null, facts, note: text(p.note), actions, peek: address[0] ?? phone };
}
/* An email breaks only after "@" or before a ".", never mid-word. */
const emailParts = (e: string) => e.split(/(?<=@)|(?=\.)/).filter(Boolean);
const hasDetail = (c: Clean) => c.facts.length > 0 || Boolean(c.note) || c.actions.length > 0;
/* Groups as supplied; empty groups dropped; ungrouped places become one unlabelled group at the end. */
const cleanGroups = [
...(Array.isArray(groups) ? groups : []).map((g) => ({
label: text(g?.label),
count: text(g?.count),
places: ((Array.isArray(g?.places) ? g.places : []) as readonly RegionIndexPlace[]).map(clean).filter((c): c is Clean => c !== null),
})),
{ label: "", count: "", places: (Array.isArray(places) ? places : []).map(clean).filter((c): c is Clean => c !== null) },
].filter((g) => g.places.length > 0);
const main = primary ? clean(primary) : null;
const mainLabel = primary ? text(primary.label) : "";
const total = cleanGroups.reduce((n, g) => n + g.places.length, 0);
const scale = total <= 4 ? "few" : total <= 9 ? "mid" : "many";
const labelled = cleanGroups.some((g) => g.label);
const openAll = expand === "all" || (expand === "auto" && scale === "few");
/* Many: each group reads down two independent columns (first half, second half). */
const columnsOf = (list: Clean[]) => (scale === "many" && list.length > 1 ? [list.slice(0, Math.ceil(list.length / 2)), list.slice(Math.ceil(list.length / 2))] : [list]);
const leadAction = cta && text(cta.label) && safeHref(cta.href) ? { label: text(cta.label), href: safeHref(cta.href) } : null;
const Heading = headingLevel;
const Sub = headingLevel === "h2" ? "h3" : "h4";
const show = total > 0 || main !== null;
---
{show && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["rix", `rix--${scale}`, labelled ? "rix--grouped" : "rix--solo", { "rix--has-primary": main }, className]} id={id}>
<div class="rix-inner">
<div class="rix-lead">
<div class="rix-lead-head">
{eyebrow && <p class="rix-eyebrow">{eyebrow}</p>}
<Heading class="rix-title">{heading}</Heading>
</div>
{(intro || leadAction) && (
<div class="rix-lead-body">
{intro && <p class="rix-intro">{intro}</p>}
{leadAction && (
<a class="rix-cta" href={leadAction.href}>
<span>{leadAction.label}</span>
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4 10h11M11 5.5 15.5 10 11 14.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</a>
)}
</div>
)}
</div>
<div class="rix-body">
{main && (
<div class="rix-primary">
<div class="rix-primary-grid">
<div class="rix-primary-head">
{mainLabel && <p class="rix-primary-label">{mainLabel}</p>}
<Sub class="rix-primary-name">{main.name}</Sub>
{main.status && (
<p class="rix-status" data-tone={main.status.tone}><span class="rix-mark" aria-hidden="true"></span>{main.status.label}</p>
)}
</div>
{(main.facts.length > 0 || main.note) && (
<div class="rix-primary-detail">
{main.facts.length > 0 && (
<dl class="rix-facts">
{main.facts.map((f) => (
<div class:list={["rix-fact", `rix-fact--${f.key}`]}>
<dt>{f.term}</dt>
<dd>{f.href ? <a class="rix-fact-link" href={f.href}>{f.key === "email" ? emailParts(f.lines[0]).map((part, i) => <>{i > 0 && <wbr />}{part}</>) : f.lines[0]}</a> : f.lines.map((l) => <span class="rix-line">{l}</span>)}</dd>
</div>
))}
</dl>
)}
{main.note && <p class="rix-note">{main.note}</p>}
</div>
)}
{main.actions.length > 0 && (
<div class="rix-actions rix-primary-actions">
{main.actions.map((a) => <a class:list={["rix-btn", `rix-btn--${a.kind}`]} href={a.href}>{a.label}</a>)}
</div>
)}
</div>
</div>
)}
{total > 0 && (
<div class="rix-index">
{cleanGroups.map((g) => (
<div class:list={["rix-group", { "rix-group--labelled": g.label }]}>
{(g.label || g.count) && (
<div class="rix-group-head">
{g.label && <Sub class="rix-group-label">{g.label}</Sub>}
{g.count && <p class="rix-group-count">{g.count}</p>}
</div>
)}
<div class:list={["rix-cols", { "rix-cols--two": columnsOf(g.places).length > 1 }]}>
{columnsOf(g.places).map((col) => (
<ul class="rix-list" role="list">
{col.map((p) => (
<li class="rix-item">
{hasDetail(p) ? (
<details class="rix-place" open={openAll}>
<summary class="rix-sum">
<span class="rix-name">{p.name}</span>
{p.peek && <span class="rix-peek" aria-hidden="true">{p.peek}</span>}
{p.status && <span class="rix-status" data-tone={p.status.tone}><span class="rix-mark" aria-hidden="true"></span>{p.status.label}</span>}
<span class="rix-toggle" aria-hidden="true"></span>
</summary>
<div class="rix-panel">
{p.facts.length > 0 && (
<dl class="rix-facts">
{p.facts.map((f) => (
<div class:list={["rix-fact", `rix-fact--${f.key}`]}>
<dt>{f.term}</dt>
<dd>{f.href ? <a class="rix-fact-link" href={f.href}>{f.key === "email" ? emailParts(f.lines[0]).map((part, i) => <>{i > 0 && <wbr />}{part}</>) : f.lines[0]}</a> : f.lines.map((l) => <span class="rix-line">{l}</span>)}</dd>
</div>
))}
</dl>
)}
{p.note && <p class="rix-note">{p.note}</p>}
{p.actions.length > 0 && (
<div class="rix-actions">
{p.actions.map((a) => <a class:list={["rix-btn", `rix-btn--${a.kind}`]} href={a.href}>{a.label}</a>)}
</div>
)}
</div>
</details>
) : (
<div class="rix-sum rix-sum--static">
<span class="rix-name">{p.name}</span>
{p.peek && <span class="rix-peek">{p.peek}</span>}
{p.status && <span class="rix-status" data-tone={p.status.tone}><span class="rix-mark" aria-hidden="true"></span>{p.status.label}</span>}
</div>
)}
</li>
))}
</ul>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
</>
)}
<style>
.rix {
--rix-surface: var(--rix-bg, #f3f4f1);
--rix-ink: var(--rix-text, #131a20);
--rix-soft: var(--rix-text-muted, #545e66);
--rix-accent: var(--rix-brand, #0d5c55);
--rix-accent-ink: var(--rix-brand-text, #ffffff);
--rix-line: var(--rix-border, #c9d0cd);
--rix-deep: var(--rix-panel-bg, #12302c);
--rix-deep-ink: var(--rix-panel-text, #f1f4f0);
--rix-deep-soft: var(--rix-panel-text-muted, rgba(241, 244, 240, 0.8));
--rix-deep-line: var(--rix-panel-border, rgba(241, 244, 240, 0.22));
--rix-font: var(--rix-family, "Archivo", ui-sans-serif, system-ui, sans-serif);
--rix-font-display: var(--rix-display, "Archivo", ui-sans-serif, system-ui, sans-serif);
container: rix / inline-size;
background: var(--rix-surface);
color: var(--rix-ink);
font-family: var(--rix-font);
}
.rix *,
.rix *::before,
.rix *::after {
box-sizing: border-box;
}
.rix-inner {
display: grid;
gap: 40px;
max-width: var(--rix-grid-max, 1232px);
margin: 0 auto;
padding: 60px 20px 72px;
}
/* ---- lead ---- */
.rix-lead {
display: grid;
gap: 18px;
min-width: 0;
}
.rix-eyebrow {
margin: 0 0 14px;
color: var(--rix-accent);
font: 600 15px/1.4 var(--rix-font);
overflow-wrap: anywhere;
}
.rix-title {
max-width: 16ch;
margin: 0;
font-family: var(--rix-font-display);
font-size: var(--rix-heading-size, clamp(34px, 4.4vw, 56px));
font-stretch: 88%;
font-weight: 600;
line-height: 1.02;
letter-spacing: -0.02em;
text-wrap: balance;
overflow-wrap: anywhere;
}
.rix-intro {
max-width: 44ch;
margin: 0;
color: var(--rix-soft);
font: 400 17px/1.6 var(--rix-font);
overflow-wrap: anywhere;
}
.rix-cta {
display: inline-flex;
align-items: center;
gap: 12px;
min-height: 44px;
margin-top: 16px;
padding: 4px 0;
border-bottom: 2px solid var(--rix-accent);
color: var(--rix-ink);
font: 600 16px/1.3 var(--rix-font);
text-decoration: none;
transition: color 0.2s ease;
}
.rix-cta svg {
flex: none;
width: 18px;
height: 18px;
color: var(--rix-accent);
transition: transform 0.2s ease;
}
.rix-cta:hover {
color: var(--rix-accent);
}
.rix-cta:hover svg,
.rix-cta:focus-visible svg {
transform: translateX(4px);
}
.rix-body {
display: grid;
gap: 40px;
min-width: 0;
}
/* ---- primary location: the deep panel, always open ---- */
.rix-primary {
container: rix-card / inline-size;
min-width: 0;
background: var(--rix-deep);
color: var(--rix-deep-ink);
}
.rix-primary-grid {
display: grid;
gap: 28px;
padding: 28px 22px 30px;
}
.rix-primary-head {
min-width: 0;
}
.rix-primary-label {
margin: 0 0 10px;
color: var(--rix-deep-soft);
font: 500 15px/1.4 var(--rix-font);
}
.rix-primary-name {
margin: 0;
font-family: var(--rix-font-display);
font-size: clamp(40px, 12cqi, 88px);
font-stretch: 78%;
font-weight: 600;
line-height: 0.96;
letter-spacing: -0.02em;
overflow-wrap: break-word;
text-wrap: balance;
}
.rix-primary .rix-status {
margin: 16px 0 0;
color: var(--rix-deep-ink);
}
.rix-primary .rix-facts {
border-top: 1px solid var(--rix-deep-line);
}
.rix-primary .rix-fact {
border-color: var(--rix-deep-line);
}
/* Labels on the panel: the panel's own muted ink (AA on the deep ground), never the page's. */
.rix-primary .rix-facts dt,
.rix-primary .rix-note {
color: var(--rix-deep-soft);
}
.rix-primary .rix-fact-link {
color: var(--rix-deep-ink);
text-decoration-color: var(--rix-deep-soft);
}
.rix-primary .rix-btn--solid {
background: var(--rix-deep-ink);
border-color: var(--rix-deep-ink);
color: var(--rix-deep);
}
.rix-primary .rix-btn--line {
border-color: var(--rix-deep-line);
color: var(--rix-deep-ink);
}
.rix-primary .rix-btn:focus-visible,
.rix-primary .rix-fact-link:focus-visible {
outline-color: var(--rix-deep-ink);
}
/* Wide panel: name and status top-left, the actions anchor the bottom-left, the facts fill the right
in two even columns — every corner of the panel carries content. */
@container rix-card (min-width: 720px) {
.rix-primary-grid {
grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
grid-template-areas:
"head detail"
"actions detail";
grid-template-rows: auto 1fr;
column-gap: 56px;
row-gap: 32px;
padding: 44px 44px 44px;
}
.rix-primary-head {
grid-area: head;
}
/* Sized to the name column (4 of 12), so a one-word place holds one line. */
.rix-primary-name {
font-size: clamp(44px, 6.2cqi, 76px);
}
.rix-primary-detail {
grid-area: detail;
}
.rix-primary-actions {
grid-area: actions;
align-self: end;
}
/* Two even columns (address | phone, email | hours): wide enough that an email holds one line. */
.rix-primary .rix-primary-detail .rix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 18px;
}
/* Here each column is wide enough for an email: keep the supplied order, no spanning. */
.rix-primary .rix-primary-detail .rix-fact--email {
grid-column: auto;
order: 0;
}
.rix-primary .rix-primary-detail .rix-fact:nth-child(-n + 2) {
padding-top: 0;
}
.rix-primary .rix-facts {
border-top: 0;
}
}
/* ---- the index ---- */
.rix-index {
display: grid;
gap: 36px;
min-width: 0;
}
.rix-group {
container: rix-group / inline-size;
min-width: 0;
border-top: 2px solid var(--rix-ink);
}
.rix-group-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 16px;
padding: 14px 0 6px;
}
.rix-group-label {
margin: 0;
font: 600 17px/1.3 var(--rix-font);
letter-spacing: 0;
overflow-wrap: anywhere;
}
.rix-group-count {
margin: 0;
color: var(--rix-soft);
font: 400 15px/1.4 var(--rix-font);
font-variant-numeric: tabular-nums;
}
.rix-cols {
display: grid;
min-width: 0;
}
.rix-list {
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.rix-item {
min-width: 0;
border-bottom: 1px solid var(--rix-line);
}
/* Summary row: name, status, toggle. The whole row is the control. */
.rix-sum {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"name toggle"
"peek toggle"
"status toggle";
align-items: center;
column-gap: 16px;
row-gap: 6px;
min-height: 64px;
padding: var(--rix-row-pad, 16px) 0;
cursor: pointer;
list-style: none;
}
.rix-sum::-webkit-details-marker {
display: none;
}
.rix-sum--static {
cursor: default;
}
/* A plain row keeps the toggle's width so statuses stay on one edge. */
.rix-sum--static::after {
content: "";
grid-area: toggle;
width: 40px;
}
.rix--many .rix-sum--static::after {
width: 34px;
}
.rix-name {
grid-area: name;
min-width: 0;
font-family: var(--rix-font-display);
font-size: var(--rix-name-size, 28px);
font-stretch: 78%;
font-weight: 600;
line-height: 1;
letter-spacing: -0.012em;
overflow-wrap: break-word;
text-wrap: balance;
transition: color 0.2s ease;
}
/* One-line identifier in the closed row (first address line, else the phone): tells places apart without opening them. */
.rix-peek {
display: block;
grid-area: peek;
min-width: 0;
color: var(--rix-soft);
font: 400 15px/1.4 var(--rix-font);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rix-place[open] > .rix-sum .rix-peek {
display: none;
}
.rix-status {
grid-area: status;
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--rix-ink);
font: 500 14px/1.35 var(--rix-font);
overflow-wrap: anywhere;
}
/* Tone is carried by shape as well as colour: filled, ring, half. Plain has none. */
.rix-mark {
flex: none;
display: none;
width: 9px;
height: 9px;
border: 1.5px solid var(--rix-accent);
border-radius: 50%;
}
.rix-primary .rix-mark {
border-color: currentColor;
}
[data-tone="active"] > .rix-mark {
display: block;
background: var(--rix-accent);
}
.rix-primary [data-tone="active"] > .rix-mark {
background: currentColor;
}
[data-tone="upcoming"] > .rix-mark {
display: block;
background: transparent;
}
[data-tone="limited"] > .rix-mark {
display: block;
background: linear-gradient(90deg, currentColor 50%, transparent 50%);
color: var(--rix-accent);
}
.rix-primary [data-tone="limited"] > .rix-mark {
color: inherit;
}
.rix-toggle {
grid-area: toggle;
position: relative;
flex: none;
width: 40px;
height: 40px;
border: 1.5px solid var(--rix-line);
border-radius: 50%;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.rix-toggle::before,
.rix-toggle::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 1.8px;
margin: -0.9px 0 0 -7px;
background: var(--rix-ink);
transition:
transform 0.24s ease,
background-color 0.2s ease;
}
.rix-toggle::after {
transform: rotate(90deg);
}
.rix-place[open] > .rix-sum .rix-toggle {
border-color: var(--rix-ink);
background: var(--rix-ink);
}
.rix-place[open] > .rix-sum .rix-toggle::before,
.rix-place[open] > .rix-sum .rix-toggle::after {
background: var(--rix-surface);
}
.rix-place[open] > .rix-sum .rix-toggle::after {
transform: rotate(0deg);
}
.rix-sum:hover .rix-name,
.rix-sum:focus-visible .rix-name {
color: var(--rix-accent);
}
.rix-sum:hover .rix-toggle {
border-color: var(--rix-ink);
}
.rix-sum:focus-visible {
outline: 3px solid var(--rix-accent);
outline-offset: 3px;
}
/* ---- a place's own facts ---- */
.rix-panel {
display: grid;
gap: 20px;
padding: 2px 0 24px;
animation: rix-reveal 0.28s ease both;
}
@keyframes rix-reveal {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
.rix-facts {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
align-items: start;
margin: 0;
}
.rix-fact {
display: grid;
align-content: start;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
min-width: 0;
padding: 10px 0;
border-bottom: 1px dotted var(--rix-line);
}
.rix-fact:last-child {
border-bottom: 0;
}
.rix-facts dt {
padding-top: 2px;
color: var(--rix-soft);
font: 500 14px/1.45 var(--rix-font);
}
.rix-facts dd {
min-width: 0;
margin: 0;
font: 400 16px/1.5 var(--rix-font);
overflow-wrap: break-word;
}
.rix-line {
display: block;
}
/* Block-level flex so its 46px target never grows the line box: the text stays level with its neighbours' first lines. */
.rix-fact-link {
display: flex;
width: fit-content;
max-width: 100%;
align-items: center;
min-height: 46px;
margin: -11px 0;
color: var(--rix-ink);
font-weight: 600;
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--rix-line);
text-underline-offset: 4px;
overflow-wrap: break-word;
transition: text-decoration-color 0.2s ease;
}
.rix-fact-link:hover {
text-decoration-color: currentColor;
}
.rix-fact-link:focus-visible,
.rix-btn:focus-visible,
.rix-cta:focus-visible {
outline: 3px solid var(--rix-accent);
outline-offset: 3px;
}
.rix-note {
max-width: 60ch;
margin: 0;
color: var(--rix-soft);
font: 400 15px/1.55 var(--rix-font);
overflow-wrap: anywhere;
}
/* Narrow: the actions stack full width, the place's own action first. */
.rix-actions {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
@container rix-group (min-width: 560px) {
.rix-actions {
display: flex;
flex-wrap: wrap;
}
}
@container rix-card (min-width: 460px) {
.rix-actions {
display: flex;
flex-wrap: wrap;
}
}
.rix-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
max-width: 100%;
padding: 10px 22px;
border: 1.5px solid var(--rix-accent);
border-radius: 999px;
font: 600 15px/1.25 var(--rix-font);
text-align: center;
text-decoration: none;
overflow-wrap: anywhere;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.rix-btn--solid {
background: var(--rix-accent);
color: var(--rix-accent-ink);
}
.rix-btn--solid:hover {
background: color-mix(in srgb, var(--rix-accent) 82%, var(--rix-ink));
}
.rix-btn--line {
border-color: var(--rix-line);
color: var(--rix-ink);
}
.rix-btn--line:hover {
border-color: var(--rix-ink);
}
/* ---- scales ---- */
.rix--few .rix-index {
--rix-name-size: clamp(34px, 9cqi, 40px);
--rix-row-pad: 22px;
}
.rix--mid .rix-index {
--rix-name-size: 28px;
--rix-row-pad: 16px;
}
.rix--many .rix-index {
--rix-name-size: 24px;
--rix-row-pad: 12px;
}
.rix--many .rix-sum {
min-height: 56px;
}
.rix--many .rix-toggle {
width: 34px;
height: 34px;
}
/* ---- a group's own width: facts beside each other, two columns for many ---- */
@container rix-group (min-width: 560px) {
.rix-sum {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
"name status toggle"
"peek status toggle";
column-gap: 24px;
}
.rix-status {
justify-self: end;
text-align: right;
}
.rix-facts {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
column-gap: 28px;
border-top: 1px solid var(--rix-line);
}
.rix-fact {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
padding: 14px 0 0;
border-bottom: 0;
}
.rix-panel {
gap: 22px;
padding-bottom: 28px;
}
.rix--few .rix-index {
--rix-name-size: clamp(40px, 7.4cqi, 60px);
--rix-row-pad: 26px;
}
.rix--mid .rix-index {
--rix-name-size: clamp(32px, 4.8cqi, 42px);
--rix-row-pad: 20px;
}
.rix--many .rix-index {
--rix-name-size: 26px;
}
}
/* Wide single-column rows: the first address line sits in an aligned column while closed. */
/* Wide single-column rows: the identifier sits in its own column. Every track is a fixed share of the
row (never sized by content), so the column lines up on every row whether or not a status is present. */
@container rix-group (min-width: 700px) {
.rix-cols:not(.rix-cols--two) .rix-sum {
grid-template-columns: minmax(0, 5fr) minmax(0, 4fr) minmax(0, 3fr) 40px;
grid-template-areas: "name peek status toggle";
}
.rix-cols:not(.rix-cols--two) .rix-peek {
display: block;
}
.rix-cols:not(.rix-cols--two) .rix-place[open] > .rix-sum .rix-peek {
display: block;
visibility: hidden;
}
}
/* An email takes two tracks, last, so it holds one line instead of breaking at "@" or ".". */
@container rix-group (min-width: 560px) {
.rix-fact--email {
grid-column: span 2;
order: 4;
}
}
@container rix-card (min-width: 460px) {
.rix-primary .rix-fact--email {
grid-column: span 2;
order: 4;
}
}
@container rix-group (min-width: 740px) {
.rix-cols--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 36px;
align-items: start;
}
/* In two columns the facts stack again: each column is narrow. */
.rix-cols--two .rix-facts {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
.rix-primary .rix-fact {
border-bottom-color: var(--rix-deep-line);
}
@container rix-card (min-width: 460px) {
.rix-primary .rix-facts {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
column-gap: 28px;
}
.rix-primary .rix-fact {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
padding: 14px 0 0;
border-bottom: 0;
}
}
.rix-primary-detail {
display: grid;
gap: 22px;
min-width: 0;
}
/* ---- section width: tablet ---- */
@container rix (min-width: 600px) {
.rix-inner {
gap: 52px;
padding: 84px 32px 96px;
}
.rix-index {
gap: 44px;
}
}
/* ---- desktop: register rows (grouped) or lead beside the index (solo) ---- */
@container rix (min-width: 960px) {
.rix-inner {
padding: 112px var(--rix-grid-gutter, 48px) 120px;
}
/* Grouped: heading and intro split across the top. */
.rix--grouped .rix-lead {
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
column-gap: 48px;
align-items: end;
}
/* Each group a register row: label column, places column. */
.rix--grouped .rix-index {
gap: 0;
}
.rix--grouped .rix-group {
display: grid;
grid-template-columns: minmax(0, 2.6fr) minmax(0, 9.4fr);
column-gap: 48px;
padding-bottom: 40px;
}
.rix--grouped .rix-group:last-child {
padding-bottom: 0;
}
.rix--grouped .rix-group-head {
position: sticky;
top: 24px;
display: block;
align-self: start;
padding: 22px 0 0;
}
/* The label column carries the group in display type, the count set under it at the same scale. */
.rix--grouped .rix-group-label {
font-family: var(--rix-font-display);
font-size: clamp(32px, 3.5cqi, 44px);
font-stretch: 78%;
font-weight: 600;
line-height: 1;
letter-spacing: -0.012em;
text-wrap: balance;
}
.rix--grouped .rix-group-count {
margin-top: 10px;
font-size: 16px;
}
.rix--grouped .rix-cols {
grid-column: 2;
}
/* Solo: lead beside the index. */
.rix--solo .rix-inner {
grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
column-gap: 64px;
align-items: start;
}
.rix--solo .rix-lead {
position: sticky;
top: 32px;
gap: 22px;
}
.rix--solo .rix-title {
max-width: 12ch;
}
.rix--solo .rix-intro {
max-width: 36ch;
}
}
@media (prefers-reduced-motion: reduce) {
.rix-panel {
animation: none;
}
.rix-cta,
.rix-cta svg,
.rix-name,
.rix-toggle,
.rix-toggle::before,
.rix-toggle::after,
.rix-btn,
.rix-fact-link {
transition: none;
}
.rix-cta:hover svg,
.rix-cta:focus-visible svg {
transform: none;
}
}
@media (forced-colors: active) {
.rix-group,
.rix-item,
.rix-toggle,
.rix-btn {
border-color: CanvasText;
}
.rix-primary {
border: 2px solid CanvasText;
}
.rix-toggle::before,
.rix-toggle::after {
background: CanvasText;
forced-color-adjust: none;
}
.rix-mark {
border-color: CanvasText;
forced-color-adjust: none;
}
[data-tone="active"] > .rix-mark {
background: CanvasText;
}
.rix-sum:focus-visible {
outline-color: Highlight;
}
}
</style>
===== END src/components/RegionIndex.astro =====
===== FILE (supporting): src/components/RegionIndex.md =====
# Region index
A business's locations as a typographic register: place names in condensed display type on ruled rows, optionally grouped under region labels in their own column, each row a native disclosure that opens that place's address, phone, email, hours, note and actions. An optional primary location sits above on a deep panel. Everything renders only as supplied; no map, pins or photos.
## Integration
- A section heading (h2 by default); group labels and the primary location's name are one level down.
- No places and no primary location, no section. A group with no places does not render. Every fact, status and count renders only when supplied, in the order supplied.
- Each place with facts is a native <details> disclosure; four or fewer places start open (`expand` overrides). No script.
- With region labels, groups become register rows (label column beside the places) from 960px of the section's own width; without labels the lead sits beside the index. Ten or more places read down two columns per group from 740px of the group's width.
- tel: links are built from the digits of the supplied number; every other link goes through safeHref.
- Loads Archivo (width axis) from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces (via --rix-family / --rix-display).
## Provenance
Staged as candidate `region-index` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/region-index.json.
===== END src/components/RegionIndex.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.