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: "Comparison ledger"
Library page: https://astro.baysixmedia.com/components/comparison-ledger/
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/ComparisonLedger.astro =====
---
/**
* ComparisonLedger (candidate, Step 40)
*
* A comparison section set as a professional ledger, not a pricing-card row:
* two to four options as the columns of one real <table>, the things being
* compared as ruled rows down the left. Each option heads its column with its
* name in the display face on a short ink rule, then its price, one line and
* any note exactly as supplied. Values sit left-aligned under the name like
* entries in a ledger: a drawn tick, a short rule, the business's own words,
* or "Not stated".
*
* Truth rules:
* - No default options, rows, prices, notes or claims. No section at all
* (not even the heading) without at least two named options and one
* labelled row.
* - Cells: true = "Included", false = "Not included" (glyph plus words for
* assistive tech), a string exactly as worded, null / missing / empty =
* "Not stated" as VISIBLE words — never a dash that could read as "no".
* - No option is emphasised by default and nothing says "best", "popular"
* or "recommended". `emphasis` (an option index) is honoured only when that
* option has a supplied `note`; the emphasis is a quiet tinted column and
* the note is the only words that explain it.
* - Per-option actions only from a supplied href, through safeHref; the
* visible label is generic, the accessible name carries the option name.
*
* Responsive (container queries on the section's own width):
* - >= 680px: the full ledger; label column 34% / 28% / 24% for 2 / 3 / 4
* options, option columns equal.
* - < 680px, 2 options: the table stays; each row's label moves to its own
* line above the two values (explicit table roles keep the semantics when
* the rows become grids).
* - < 680px, 3–4 options: the table stays whole inside a contained,
* keyboard-focusable scroller with a sticky label column, column snapping,
* an edge shadow and a visible hint. The page itself never scrolls sideways.
*
* No script. Motion: only the text links' colour eases on hover; none
* under reduced motion. Forced colours: the emphasised column keeps its cap
* as a border and every glyph uses currentColor.
*
* Hooks read (never declared): --cl-bg, --cl-panel-bg, --cl-text,
* --cl-text-muted, --cl-brand, --cl-border, --cl-family,
* --cl-display, --cl-grid-max, --cl-grid-gutter, --cl-heading-size.
*/
export type ComparisonValue = boolean | string | null;
export interface ComparisonOption {
/** The option's name, as the business calls it. Required for the option to render. */
name: string;
/** One line on what the option is, as supplied. */
summary?: string;
/** The price exactly as stated ("$65 a month", "2.5% of sale price"). */
price?: string;
/** A supplied note about this option; the only words that may explain an emphasis. */
note?: string;
/** The option's own page or enquiry anchor; renders a per-option action. */
href?: string;
}
export interface ComparisonRow {
/** What is being compared, as supplied. */
label: string;
/** One value per option, in option order: true, false, words as stated, or null (not stated). */
values: ComparisonValue[];
/** A short supplied qualifier under the label. */
note?: string;
}
export interface ComparisonLedgerProps {
heading: string;
headingLevel?: "h2" | "h3";
eyebrow?: string;
intro?: string;
/** Framing line under the table (what prices assume, what "Not stated" means). */
footnote?: string;
options?: ComparisonOption[];
rows?: ComparisonRow[];
/** Index of the option to set in the tinted column. Honoured only when that option has a note. */
emphasis?: number | null;
/** Interface words; English defaults. `scrollHint` may contain {count}. */
labels?: { included?: string; notIncluded?: string; notStated?: string; action?: string; scrollHint?: string };
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ComparisonLedgerProps {}
const {
heading,
headingLevel = "h2",
eyebrow = "",
intro = "",
footnote = "",
options: optionsProp = [],
rows: rowsProp = [],
emphasis = null,
labels = {},
fontStylesheet = "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=Newsreader:opsz,wght@6..72,400;6..72,500&display=swap",
class: className,
id,
} = Astro.props as Props;
const L = {
included: "Included",
notIncluded: "Not included",
notStated: "Not stated",
action: "Ask about this option",
scrollHint: "Scroll sideways to see all {count} options.",
...labels,
};
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: unknown) => (typeof href === "string" && SAFE.test(href.trim()) ? href.trim() : "");
const text = (v: unknown) => (typeof v === "string" ? v.trim() : "");
/* Keep each option's ORIGINAL index so row values stay aligned when an unnamed option is dropped. */
const kept = (Array.isArray(optionsProp) ? optionsProp : [])
.map((o, source) => ({
source,
name: text(o?.name),
summary: text(o?.summary),
price: text(o?.price),
note: text(o?.note),
href: safeHref(o?.href),
}))
.filter((o) => o.name)
.slice(0, 4);
type Cell = { kind: "yes" } | { kind: "no" } | { kind: "none" } | { kind: "text"; value: string };
const cell = (v: unknown): Cell => {
if (v === true) return { kind: "yes" };
if (v === false) return { kind: "no" };
const t = text(v);
return t ? { kind: "text", value: t } : { kind: "none" };
};
const rows = (Array.isArray(rowsProp) ? rowsProp : [])
.map((r) => ({
label: text(r?.label),
note: text(r?.note),
cells: kept.map((o) => cell(Array.isArray(r?.values) ? r.values[o.source] : null)),
}))
.filter((r) => r.label)
.slice(0, 12);
const n = kept.length;
const show = n >= 2 && rows.length > 0;
/* Emphasis only with the emphasised option's own supplied note. */
const em = typeof emphasis === "number" ? kept.findIndex((o) => o.source === emphasis && o.note) : -1;
const hasDetails = kept.some((o) => o.summary || o.price || o.note);
const hasActions = kept.some((o) => o.href);
const multi = n >= 3;
const kinds = new Set(rows.flatMap((r) => r.cells.map((c) => c.kind)));
/* The glyph key lists only the glyphs this table uses; "Not stated" and worded cells explain themselves. */
const key = [
...(kinds.has("yes") ? [{ kind: "yes", label: L.included }] : []),
...(kinds.has("no") ? [{ kind: "no", label: L.notIncluded }] : []),
];
const Heading = headingLevel;
const uid = `${(id ?? "cl").replace(/[^\w-]/g, "") || "cl"}-${Math.random().toString(36).slice(2, 7)}`;
const headingText = text(heading);
const headingId = headingText ? `${uid}-h` : undefined;
const hintId = `${uid}-hint`;
/* Two options: rows become grids on narrow widths, which can drop native table semantics in some engines; explicit roles keep them. */
const r = (role: astroHTML.JSX.AriaRole) => (n === 2 ? role : undefined);
const emClass = (i: number, end = false) => (i === em ? ["cl-em", { "cl-em-end": end }] : []);
---
{fontStylesheet && show && <link rel="stylesheet" href={fontStylesheet} />}
{
show && (
<section class:list={["cl", `cl--n${n}`, { "cl--multi": multi, "cl--em": em >= 0 }, className]} id={id} aria-labelledby={headingId}>
<div class="cl-inner">
{(headingText || eyebrow || intro) && (
<header class="cl-head">
<div class="cl-titles">
{eyebrow && <p class="cl-eyebrow">{eyebrow}</p>}
{headingText && (
<Heading class="cl-title" id={headingId}>
{headingText}
</Heading>
)}
</div>
{intro && <p class="cl-intro">{intro}</p>}
</header>
)}
{multi && (
<p class="cl-hint" id={hintId}>
{L.scrollHint.replace("{count}", String(n))}
</p>
)}
<div
class="cl-frame"
role={multi ? "region" : undefined}
tabindex={multi ? 0 : undefined}
aria-labelledby={multi ? headingId : undefined}
aria-label={multi && !headingId ? L.scrollHint.replace("{count}", String(n)) : undefined}
aria-describedby={multi ? hintId : undefined}
>
<table class="cl-table" aria-labelledby={headingId} role={r("table")}>
<colgroup>
<col class="cl-col-label" />
{kept.map(() => (
<col />
))}
</colgroup>
<thead role={r("rowgroup")}>
<tr class:list={["cl-names", { "cl-names--solo": !hasDetails }]} role={r("row")}>
<td class="cl-corner" role={r("cell")}>
{!hasDetails && key.length > 0 && (
<p class="cl-key cl-key--head" aria-hidden="true">
{key.map((k) => (
<span class="cl-key-item">
<svg class:list={["cl-glyph", `cl-glyph--${k.kind}`]} viewBox="0 0 20 20" width="18" height="18" focusable="false">
{k.kind === "yes" ? (
<path d="M4 10.5l3.8 3.8L16 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
) : (
<path d="M5 10h10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
)}
</svg>
{k.label}
</span>
))}
</p>
)}
</td>
{kept.map((o, i) => (
<th scope="col" class:list={["cl-opt", ...emClass(i)]} role={r("columnheader")}>
<span class="cl-name">{o.name}</span>
</th>
))}
</tr>
{hasDetails && (
<tr class="cl-details" role={r("row")}>
<td class="cl-corner" role={r("cell")}>
{key.length > 0 && (
<p class="cl-key cl-key--head" aria-hidden="true">
{key.map((k) => (
<span class="cl-key-item">
<svg class:list={["cl-glyph", `cl-glyph--${k.kind}`]} viewBox="0 0 20 20" width="18" height="18" focusable="false">
{k.kind === "yes" ? (
<path d="M4 10.5l3.8 3.8L16 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
) : (
<path d="M5 10h10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
)}
</svg>
{k.label}
</span>
))}
</p>
)}
</td>
{kept.map((o, i) => (
<td class:list={["cl-detail", ...emClass(i)]} role={r("cell")}>
{o.price && <span class="cl-price">{o.price}</span>}
{o.summary && <span class="cl-summary">{o.summary}</span>}
{o.note && <span class:list={["cl-note", { "cl-note--em": i === em }]}>{o.note}</span>}
</td>
))}
</tr>
)}
</thead>
<tbody role={r("rowgroup")}>
{rows.map((row, ri) => (
<tr class:list={{ "cl-last": ri === rows.length - 1 && !hasActions }} role={r("row")}>
<th scope="row" class="cl-label" role={r("rowheader")}>
<span class="cl-label-text">{row.label}</span>
{row.note && <span class="cl-rnote">{row.note}</span>}
</th>
{row.cells.map((c, i) => (
<td class:list={["cl-val", `cl-val--${c.kind}`, ...emClass(i, ri === rows.length - 1 && !hasActions)]} role={r("cell")}>
{c.kind === "yes" && (
<>
<svg class="cl-glyph cl-glyph--yes" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M4 10.5l3.8 3.8L16 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="cl-sr">{L.included}</span>
</>
)}
{c.kind === "no" && (
<>
<svg class="cl-glyph cl-glyph--no" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M5 10h10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<span class="cl-sr">{L.notIncluded}</span>
</>
)}
{c.kind === "none" && <span class="cl-ns">{L.notStated}</span>}
{c.kind === "text" && <span class="cl-text">{c.value}</span>}
</td>
))}
</tr>
))}
</tbody>
{hasActions && (
<tfoot role={r("rowgroup")}>
<tr class="cl-actions" role={r("row")}>
<td class="cl-corner" role={r("cell")} />
{kept.map((o, i) => (
<td class:list={["cl-act", ...emClass(i, true)]} role={r("cell")}>
{o.href && (
<a class="cl-link" href={o.href}>
{L.action}
<span class="cl-sr">: {o.name}</span>
</a>
)}
</td>
))}
</tr>
</tfoot>
)}
</table>
</div>
{((n === 2 && key.length > 0) || footnote) && (
<div class="cl-foot">
{n === 2 && key.length > 0 && (
<p class="cl-key cl-key--foot" aria-hidden="true">
{key.map((k) => (
<span class="cl-key-item">
<svg class:list={["cl-glyph", `cl-glyph--${k.kind}`]} viewBox="0 0 20 20" width="18" height="18" focusable="false">
{k.kind === "yes" ? (
<path d="M4 10.5l3.8 3.8L16 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
) : (
<path d="M5 10h10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
)}
</svg>
{k.label}
</span>
))}
</p>
)}
{footnote && <p class="cl-footnote">{footnote}</p>}
</div>
)}
</div>
</section>
)
}
<style>
.cl {
--cl-surface: var(--cl-bg, #f2f4f2);
--cl-panel: var(--cl-panel-bg, #e2ebe6);
--cl-ink: var(--cl-text, #15201d);
--cl-soft: var(--cl-text-muted, #56615d);
--cl-accent: var(--cl-brand, #1f5a4e);
--cl-line: var(--cl-border, #cdd5d1);
--cl-font: var(--cl-family, "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif);
--cl-font-display: var(--cl-display, "Newsreader", ui-serif, Georgia, serif);
container: cl / inline-size;
background: var(--cl-surface);
color: var(--cl-ink);
font-family: var(--cl-font);
padding-block: 64px;
}
.cl *,
.cl *::before,
.cl *::after {
box-sizing: border-box;
}
.cl-inner {
max-width: var(--cl-grid-max, 1232px);
margin-inline: auto;
padding-inline: 20px;
}
/* ---------- Header ---------- */
.cl-head {
display: grid;
gap: 16px;
margin-bottom: 36px;
}
.cl-eyebrow {
margin: 0 0 12px;
color: var(--cl-accent);
font-size: 15px;
font-weight: 500;
line-height: 1.3;
}
.cl-title {
margin: 0;
max-width: 18ch;
font-family: var(--cl-font-display);
font-size: var(--cl-heading-size, clamp(32px, 3.2vw + 12px, 50px));
font-weight: 400;
line-height: 1.04;
letter-spacing: -0.018em;
text-wrap: balance;
}
.cl-intro {
margin: 0;
max-width: 46ch;
color: var(--cl-soft);
font-size: 17px;
line-height: 1.55;
text-wrap: pretty;
}
.cl-hint {
display: none;
margin: 0 0 4px;
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.4;
}
/* ---------- The ledger ---------- */
.cl-frame {
padding-block: 14px;
}
.cl-frame:focus-visible {
outline: 3px solid var(--cl-accent);
outline-offset: 2px;
}
.cl-table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: 15px;
line-height: 1.45;
}
.cl--n2 .cl-col-label {
width: 34%;
}
.cl--n3 .cl-col-label {
width: 28%;
}
.cl--n4 .cl-col-label {
width: 24%;
}
.cl-table th,
.cl-table td {
position: relative;
padding: 16px;
text-align: start;
vertical-align: top;
font-weight: inherit;
}
/* Ledger columns: every option column starts on a vertical hairline. */
.cl-table :is(.cl-opt, .cl-detail, .cl-val, .cl-act) {
padding-inline: 20px 16px;
border-inline-start: 1px solid var(--cl-line);
}
/* The label column has no start padding: labels sit on the section's text edge. */
.cl-table .cl-corner,
.cl-table .cl-label {
padding-inline: 0 28px;
}
/* Column heads: the option's name under a short ink rule inset to its text. */
.cl-table .cl-opt {
padding-top: 22px;
padding-bottom: 10px;
vertical-align: bottom;
}
.cl-opt::before {
content: "";
position: absolute;
top: 0;
inset-inline: -1px 28px;
border-top: 2px solid var(--cl-ink);
}
.cl-name {
display: block;
font-family: var(--cl-font-display);
font-size: clamp(22px, 0.9vw + 13px, 30px);
font-weight: 400;
line-height: 1.12;
letter-spacing: -0.01em;
overflow-wrap: break-word;
hyphens: auto;
}
.cl-table .cl-detail {
padding-top: 0;
padding-bottom: 22px;
}
.cl-details > .cl-corner,
.cl-names--solo > .cl-corner {
vertical-align: bottom;
padding-bottom: 22px;
}
.cl-names--solo > .cl-corner {
padding-bottom: 14px;
}
.cl-price {
display: block;
margin-bottom: 6px;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
font-variant-numeric: tabular-nums lining-nums;
}
.cl-summary {
display: block;
max-width: 34ch;
color: var(--cl-soft);
font-size: 14.5px;
line-height: 1.45;
}
.cl-note {
display: block;
margin-top: 10px;
max-width: 34ch;
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.4;
}
.cl-note--em {
color: var(--cl-accent);
font-weight: 500;
}
/* The head closes on one ink hairline across the full width; body rows on quiet ones. */
.cl-details > *,
.cl-names--solo > * {
border-bottom: 1px solid var(--cl-ink);
}
tbody > tr > * {
border-bottom: 1px solid var(--cl-line);
}
.cl-label-text {
display: block;
font-size: 15.5px;
font-weight: 500;
line-height: 1.4;
text-wrap: pretty;
}
.cl-rnote {
display: block;
margin-top: 4px;
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.4;
}
.cl-val {
font-variant-numeric: tabular-nums lining-nums;
}
.cl-text {
display: block;
overflow-wrap: break-word;
}
.cl-glyph {
display: block;
width: 20px;
height: 20px;
margin-top: 1px;
}
.cl-glyph--yes {
color: var(--cl-accent);
}
.cl-glyph--no {
color: var(--cl-soft);
}
.cl-ns {
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.5;
}
/* Actions: one quiet outlined link under each option that has a destination. */
.cl-table .cl-act {
padding-top: 12px;
padding-bottom: 12px;
}
.cl-link {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--cl-ink);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
text-decoration: underline;
text-decoration-color: var(--cl-accent);
text-decoration-thickness: 1px;
text-underline-offset: 6px;
}
.cl-link:hover {
color: var(--cl-accent);
text-decoration-thickness: 2px;
}
.cl-link:focus-visible {
outline: 3px solid var(--cl-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
.cl-link {
transition:
color 0.16s ease,
text-decoration-color 0.16s ease;
}
}
/* ---------- Emphasis: a tinted column rising above the heads and running past the last row ---------- */
.cl-em {
background: var(--cl-panel);
}
.cl-opt.cl-em::before {
top: auto;
bottom: 100%;
inset-inline: 0;
height: 14px;
border-top: 3px solid var(--cl-accent);
background: var(--cl-panel);
}
.cl-em-end::after {
content: "";
position: absolute;
top: 100%;
inset-inline: 0;
height: 14px;
background: var(--cl-panel);
}
/* ---------- Foot ---------- */
.cl-foot {
display: grid;
gap: 10px;
margin-top: 14px;
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.5;
}
.cl-key {
display: flex;
flex-wrap: wrap;
gap: 6px 22px;
margin: 0;
color: var(--cl-soft);
font-size: 13.5px;
line-height: 1.4;
}
.cl-key--head {
flex-direction: column;
align-items: flex-start;
}
.cl-names--solo .cl-key--head {
flex-direction: row;
}
.cl-key--foot {
display: none;
}
.cl-key-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cl-key .cl-glyph {
width: 18px;
height: 18px;
margin: 0;
}
.cl-footnote {
margin: 0;
max-width: 72ch;
text-wrap: pretty;
}
.cl-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- Tablet and up ---------- */
@media (min-width: 600px) {
.cl {
padding-block: 88px;
}
}
@media (min-width: 1200px) {
.cl {
padding-block: 112px;
}
}
@container cl (min-width: 600px) {
.cl-inner {
padding-inline: 32px;
}
}
@container cl (min-width: 880px) {
.cl-head {
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
align-items: end;
gap: 48px;
margin-bottom: 48px;
}
}
@container cl (min-width: 960px) {
.cl-inner {
padding-inline: var(--cl-grid-gutter, 48px);
}
}
@container cl (max-width: 679.98px) {
/* ---------- Two options: each label on its own line above its two values ---------- */
.cl--n2 .cl-table,
.cl--n2 .cl-table > thead,
.cl--n2 .cl-table > tbody,
.cl--n2 .cl-table > tfoot {
display: block;
width: 100%;
}
.cl--n2 .cl-table colgroup,
.cl--n2 .cl-table .cl-corner {
display: none;
}
.cl--n2 .cl-table tr {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cl--n2 .cl-table :is(.cl-opt, .cl-detail, .cl-val, .cl-act) {
padding-inline: 12px;
border-inline-start: 0;
}
.cl--n2 .cl-opt:not(.cl-em)::before {
inset-inline: 12px;
}
.cl--n2 .cl-key--foot {
display: flex;
}
.cl--n2 .cl-table .cl-label {
grid-column: 1 / -1;
padding: 18px 0 2px;
border-bottom: 0;
}
.cl--n2 .cl-table .cl-val {
padding-top: 8px;
padding-bottom: 16px;
}
/* ---------- Three or four options: a contained scroller, labels held at the left ---------- */
.cl--multi .cl-hint {
display: block;
}
.cl--multi .cl-frame {
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scroll-padding-inline-start: 7.25rem;
background:
linear-gradient(to left, var(--cl-surface) 40%, transparent) right center / 32px 100% no-repeat local,
radial-gradient(farthest-side at 100% 50%, rgb(0 0 0 / 0.16), transparent) right center / 12px 100% no-repeat scroll;
}
.cl--n3 .cl-table {
width: calc(7.25rem + 3 * 7.75rem);
}
.cl--n4 .cl-table {
width: calc(7.25rem + 4 * 7.75rem);
}
.cl--multi .cl-col-label {
width: 7.25rem;
}
.cl--multi .cl-table :is(.cl-opt, .cl-detail, .cl-val, .cl-act) {
padding-inline: 11px 8px;
}
.cl--multi .cl-opt::before {
inset-inline-end: 16px;
}
.cl--multi .cl-opt.cl-em::before {
inset-inline: 0;
}
.cl--multi .cl-name {
font-size: 17.5px;
line-height: 1.15;
}
.cl--multi :is(.cl-summary, .cl-note) {
font-size: 13px;
}
.cl--multi .cl-val {
font-size: 14px;
}
.cl--multi .cl-opt {
scroll-snap-align: start;
}
.cl--multi .cl-table :is(.cl-corner, .cl-label) {
position: sticky;
left: 0;
z-index: 2;
padding-inline-end: 14px;
background: var(--cl-surface);
box-shadow: 1px 0 0 var(--cl-line);
}
/* Cover the emphasised column's rise and run-past as it passes under the held labels. */
.cl--multi .cl-names > .cl-corner::before,
.cl--multi tfoot .cl-corner::after,
.cl--multi .cl-last > .cl-label::after {
content: "";
position: absolute;
inset-inline: 0 -1px;
height: 16px;
background: var(--cl-surface);
}
.cl--multi .cl-names > .cl-corner::before {
bottom: 100%;
}
.cl--multi tfoot .cl-corner::after,
.cl--multi .cl-last > .cl-label::after {
top: 100%;
}
.cl--multi .cl-label-text {
font-size: 14px;
}
.cl--multi .cl-link {
font-size: 14px;
}
}
/* ---------- Forced colours: the emphasised column keeps an outline and its cap ---------- */
@media (forced-colors: active) {
.cl-em {
outline: 1px solid CanvasText;
outline-offset: -1px;
}
.cl-opt.cl-em::before {
border-top-color: Highlight;
}
}
</style>
===== END src/components/ComparisonLedger.astro =====
===== FILE (supporting): src/components/ComparisonLedger.md =====
# Comparison ledger
A comparison section for two to four options set as a ruled ledger: one real table with each option's name, price and line heading its column on a short ink rule, and the compared points as hairline rows read across every option. Cells say Included, Not included, the business's own words or a visible 'Not stated'. No option is emphasised unless the owner sets one together with its supplied note. Phones keep the table.
## Integration
- A section heading (h2 by default) that also names the table (aria-labelledby). Option names are column headers, row labels are row headers.
- No section at all (not even the heading) without at least two named options and one labelled row. Options beyond four and rows beyond twelve are dropped; row values align with the options by position.
- Cells: true reads 'Included', false 'Not included' (glyph plus words for assistive tech), strings as worded, null or missing as a visible 'Not stated'.
- No default emphasis. `emphasis` (an option index) is honoured only when that option has a supplied note; the column is tinted and the note is shown in the accent colour.
- Per-option links render only from a safe href; visible text is labels.action, the accessible name adds the option name.
- Container queries: full ledger from 680px of the section's own width. Below that, two options keep the table with each label on its own line; three or four sit in a focusable, contained scroller with sticky labels and a visible hint. Reads --cl-* hooks including panel-bg, grid-max, grid-gutter and heading-size.
- No script. Loads Newsreader and Hanken Grotesk by default; pass fontStylesheet="" when the site loads its fonts.
## Provenance
Staged as candidate `comparison-ledger` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): https://www.functionhealth.com/, https://mercury.com/pricing, https://www.parsleyhealth.com/, https://www.odfl.com/us/en/home.html. Quality history: quality/candidates/records/comparison-ledger.json.
===== END src/components/ComparisonLedger.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.