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: "Problem ledger"
Library page: https://astro.baysixmedia.com/components/problem-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/ProblemLedger.astro =====
---
/**
* ProblemLedger — a sticky editorial intro beside a numbered, hairline-ruled
* list of points.
*
* One self-contained file. No imports, no global stylesheet, no npm packages:
* drop it into any Astro project and render it.
*
* The shape is an editorial two-column split. The intro column sticks while
* the list scrolls past it, so the framing stays on screen for the whole
* read. Below the desktop breakpoint the split collapses and the intro simply
* sits above the list.
*
* Measured behaviour:
* split 46/54 at >=1024, single column below
* column gap 64px at >=1024, 48px stacked
* row rhythm 32px vertical padding, 1px hairline between rows
* numerals share the heading's fluid size, so they scale together
* hover indent 0 -> 8px over 700ms, fine pointers at >=768 only
* reveal 900ms mask-up per row, 60ms stagger between title and detail
* easing cubic-bezier(0.16, 1, 0.3, 1) throughout
*
* Several instances can share a page: the reveal script scopes every query to
* the instance root and observes that instance's rows only.
*/
export interface LedgerItem {
/** Used for the row's element id. Falls back to no id. */
id?: string;
/** The point itself, set in the row's display size. */
title: string;
/** One line of elaboration under the title. Optional. */
detail?: string;
}
interface Props {
/** Small tracked label above the heading. Empty string hides it. */
eyebrow?: string;
/** Section heading. Empty string hides it. */
heading?: string;
/** Paragraph under the heading. Empty string hides it. */
intro?: string;
/**
* The numbered rows. Six reproduce the tested rhythm; any count works.
* Rows without a title are dropped; with none the component renders nothing.
*/
items?: LedgerItem[];
/** First number in the sequence, for a list that continues another. */
numberFrom?: number;
/** Stick the intro column while the list scrolls. Desktop only either way. */
sticky?: boolean;
/** Offset from the top of the viewport when stuck. */
stickyTop?: string;
/** grid-template-columns for the desktop split. */
split?: string;
/** Section background. */
surface?: string;
/** Body and heading colour. Muted tones are derived from it. */
ink?: string;
/** How far a row slides right on hover. "0px" turns the indent off. */
hoverIndent?: string;
/** Animate rows in as they enter the viewport. */
reveal?: boolean;
/** Heading level, for pages where h2 is wrong. */
headingLevel?: "h1" | "h2" | "h3";
class?: string;
id?: string;
}
/* No sample rows or copy: the points, and the framing around them, are the caller's. */
const {
eyebrow = "",
heading = "",
intro = "",
items = [],
numberFrom = 1,
sticky = true,
stickyTop = "96px",
split = "46fr 54fr",
surface = "#f7f6f3",
ink = "#14161a",
hoverIndent = "8px",
reveal = true,
headingLevel = "h2",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const label = (i: number) => String(i + numberFrom).padStart(2, "0");
/* A row without a title is dropped rather than drawn half-empty. No rows, no ledger. */
const rows = items.filter((item) => item && typeof item.title === "string" && item.title.trim() !== "");
---
{rows.length > 0 && (
<section
class:list={["ledger", className]}
id={id}
data-ledger
data-reveal={reveal ? "" : undefined}
data-sticky={sticky ? "" : undefined}
style={`--pl-surface:${surface};--pl-ink:${ink};--pl-sticky-top:${stickyTop};--pl-split:${split};--pl-indent:${hoverIndent};`}
>
<div class="shell">
<div class="intro" data-group>
{
eyebrow && (
<p class="kicker">
<span class="kicker-text">{eyebrow}</span>
<span class="kicker-rule" data-r-rule />
</p>
)
}
{
heading && (
<Heading class="heading" data-r-mask style="--d:0ms">
<span>{heading}</span>
</Heading>
)
}
{
intro && (
<p class="intro-text" data-r-up style="--d:80ms">
{intro}
</p>
)
}
</div>
<ol class="rows">
{
rows.map((item, i) => (
<li class="row" id={item.id} data-group>
<div class="row-inner">
<span class="num" aria-hidden="true">
{label(i)}
</span>
<div class="row-body">
<h3 class="row-title" data-r-mask style="--d:0ms">
<span>{item.title}</span>
</h3>
{item.detail && (
<p class="row-detail" data-r-up style="--d:60ms">
{item.detail}
</p>
)}
</div>
</div>
</li>
))
}
</ol>
</div>
</section>
)}
<style>
.ledger {
/* Every tone is derived from --pl-ink, so one override re-tints the
whole section instead of leaving half of it behind. */
--pl-muted: color-mix(in srgb, var(--pl-ink) 52%, transparent);
--pl-detail: color-mix(in srgb, var(--pl-ink) 68%, transparent);
--pl-hairline: color-mix(in srgb, var(--pl-ink) 13%, transparent);
--pl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* Fluid scale: 22px at a 390px viewport, 40px from 1280px up. */
--pl-display: clamp(1.375rem, 1.05rem + 1.6vw, 2.5rem);
--pl-title: clamp(1.125rem, 1.02rem + 0.5vw, 1.5rem);
--pl-body: 1rem;
--pl-gutter: clamp(20px, 5vw, 80px);
--pl-maxw: 1280px;
--pl-ease: cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
display: block;
position: relative;
padding: clamp(64px, 9vw, 128px) var(--pl-gutter);
background: var(--pl-surface);
color: var(--pl-ink);
font-family: var(--pl-font);
}
.ledger :where(*, *::before, *::after) {
box-sizing: border-box;
}
.shell {
max-width: var(--pl-maxw);
margin-inline: auto;
display: grid;
grid-template-columns: 1fr;
gap: 48px;
}
/* ---------- intro column ---------- */
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
margin: 0;
}
.kicker {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
margin: 0;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--pl-muted);
}
.kicker-text {
flex-shrink: 0;
}
.kicker-rule {
flex: 1;
height: 1px;
background: var(--pl-hairline);
transform-origin: left center;
}
.heading {
margin: 0;
font-size: var(--pl-display);
line-height: 1.35;
font-weight: 650;
letter-spacing: -0.02em;
text-wrap: balance;
}
.intro-text {
margin: 0;
max-width: 46ch;
font-size: var(--pl-body);
line-height: 1.6;
color: var(--pl-detail);
}
/* ---------- list ---------- */
.rows {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--pl-hairline);
}
.row {
border-bottom: 1px solid var(--pl-hairline);
}
.row-inner {
display: flex;
gap: 16px;
padding-block: 32px;
padding-left: 0;
transition: padding-left 700ms var(--pl-ease);
}
.num {
flex-shrink: 0;
font-size: var(--pl-display);
line-height: 1;
padding-top: 0.15em;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: var(--pl-muted);
}
.row-body {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.row-title {
margin: 0;
font-size: var(--pl-title);
line-height: 1.45;
font-weight: 650;
letter-spacing: -0.01em;
}
.row-detail {
margin: 0;
font-size: var(--pl-body);
line-height: 1.6;
color: var(--pl-detail);
text-wrap: pretty;
}
/* ---------- reveal ----------
* Hidden in CSS rather than armed by script, so the first paint is already
* correct and nothing flashes visible then snaps away. The reduced-motion
* and <noscript> escapes below cover the cases where it must not apply.
*/
.ledger[data-reveal] [data-r-mask] {
overflow: hidden;
/* Give descenders the room the clip would otherwise cut. */
padding-bottom: 0.12em;
margin-bottom: -0.12em;
}
.ledger[data-reveal] [data-r-mask] > span {
display: block;
transform: translateY(110%);
opacity: 0;
transition:
transform 900ms var(--pl-ease) var(--d, 0ms),
opacity 900ms var(--pl-ease) var(--d, 0ms);
}
.ledger[data-reveal] [data-r-up] {
transform: translateY(28px);
opacity: 0;
transition:
transform 900ms var(--pl-ease) var(--d, 0ms),
opacity 900ms var(--pl-ease) var(--d, 0ms);
}
.ledger[data-reveal] [data-r-rule] {
transform: scaleX(0);
transition: transform 1100ms var(--pl-ease);
}
.ledger[data-reveal] [data-in] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
}
.ledger[data-reveal] [data-in] [data-r-rule] {
transform: scaleX(1);
}
/* ---------- breakpoints ---------- */
@media (min-width: 768px) {
.row-inner {
gap: 32px;
}
}
@media (min-width: 1024px) {
.shell {
grid-template-columns: var(--pl-split);
gap: 64px;
}
.ledger[data-sticky] .intro {
position: sticky;
top: var(--pl-sticky-top);
align-self: start;
}
}
/* The indent is a pointer affordance. On a touch screen :hover sticks
after the finger lifts, which leaves a row indented for no reason. */
@media (min-width: 768px) and (hover: hover) and (pointer: fine) {
.row:hover .row-inner {
padding-left: var(--pl-indent);
}
}
@media (prefers-reduced-motion: reduce) {
.ledger[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
transition: none;
}
.ledger[data-reveal] [data-r-rule] {
transform: scaleX(1);
transition: none;
}
.row-inner {
transition: none;
}
}
</style>
<noscript>
<style>
.ledger[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
}
.ledger[data-reveal] [data-r-rule] {
transform: scaleX(1);
}
</style>
</noscript>
<script>
/*
* One observer per instance. Every query is scoped to the instance root,
* so two ledgers on a page reveal independently and never see each
* other's rows.
*/
const setup = (root: HTMLElement) => {
if (!root.hasAttribute("data-reveal")) return;
const groups = [...root.querySelectorAll<HTMLElement>("[data-group]")];
if (groups.length === 0) return;
/* Without IntersectionObserver there is no way to know when a row
arrives. Show everything rather than leave the section blank. */
if (!("IntersectionObserver" in window)) {
groups.forEach((group) => group.setAttribute("data-in", ""));
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.setAttribute("data-in", "");
/* Reveal is one-way: a row that has arrived stays arrived. */
observer.unobserve(entry.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0 },
);
groups.forEach((group) => observer.observe(group));
};
document.querySelectorAll<HTMLElement>("[data-ledger]").forEach(setup);
</script>
===== END src/components/ProblemLedger.astro =====
===== FILE (supporting): src/components/ProblemLedger.md =====
# Problem ledger
A sticky editorial intro beside a numbered, hairline-ruled list. The intro
column holds still while the list scrolls past it, so the framing stays on
screen for the whole read. Rows indent slightly under the pointer, and each row
rises into place as it enters the viewport.
Catalog page: `/components/problem-ledger`
## Copying it into another Astro project
Copy **`ProblemLedger.astro`** — one file, the whole component.
- No imports — not from this catalog, not from anywhere.
- No companion files, no npm packages, no Astro built-ins.
- No global stylesheet. Every colour, size, breakpoint and easing value it
needs is declared on its own root element as a `--pl-*` custom property, so
it inherits nothing from the host page and leaks nothing back.
- No webfonts, no images, no network requests of any kind.
- The only assumption about the host is that Astro compiles `.astro` files.
Drop it in and render it:
```astro
---
import ProblemLedger from "../components/ProblemLedger.astro";
---
<ProblemLedger
eyebrow="01 — Friction"
heading="Speed is rarely the problem. Agreement is."
intro="A shared library only pays off when everyone reaches for the same part."
items={[
{ title: "Components get copied, not shared", detail: "A pattern is pasted into a new screen and quietly forks." },
{ title: "Tokens live in three places at once" },
]}
/>
```
Every prop is optional, and the content props are empty by default: the
component carries no sample rows or copy. **With no `items` it renders
nothing**, and a row without a `title` is dropped rather than drawn half
empty. One row passed renders exactly one row. The six-row demo on the catalog
page is passed as props.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `LedgerItem[]` | `[]` | The numbered rows. Any count works; untitled rows are dropped. |
| `eyebrow` | `string` | `""` | Small tracked label above the heading. Empty hides it. |
| `heading` | `string` | `""` | Section heading. Empty hides it. |
| `intro` | `string` | `""` | Paragraph under the heading. Empty hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `numberFrom` | `number` | `1` | First number in the sequence, for a list continuing another. |
| `sticky` | `boolean` | `true` | Stick the intro column. Desktop only either way. |
| `stickyTop` | `string` | `"96px"` | Offset from the top of the viewport when stuck. |
| `split` | `string` | `"46fr 54fr"` | `grid-template-columns` for the desktop split. |
| `surface` | `string` | `"#f7f6f3"` | Section background. |
| `ink` | `string` | `"#14161a"` | Text colour. Muted tones are derived from it. |
| `hoverIndent` | `string` | `"8px"` | How far a row slides right on hover. `"0px"` turns it off. |
| `reveal` | `boolean` | `true` | Animate rows in as they enter the viewport. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |
### `LedgerItem`
```ts
interface LedgerItem {
id?: string; // element id for the row; omit for none
title: string; // the point itself
detail?: string; // one line of elaboration
}
```
Only `ink` and `surface` are needed to re-tint the section: the muted, detail
and hairline tones are all `color-mix()` derivations of `--pl-ink`, so one
override moves all of them together instead of leaving half the section behind.
## Layout
| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Columns | `46fr 54fr` | single | single |
| Column gap | 64px | 48px | 48px |
| Intro column | sticky at `96px` | static, above the list | static, above the list |
| Number ↔ body gap | 32px | 32px | 16px |
| Heading / numerals | 40px | fluid | 22px |
| Row title | 24px | fluid | 18px |
| Section gutter | `clamp(20px, 5vw, 80px)` | ← | ← |
Rows are 32px of vertical padding either side of a 1px hairline. The numerals
share the heading's fluid size rather than a size of their own, so the two
scale as one system — the numbers stay proportionate to the headline at every
width instead of drifting large on mobile.
## Behaviour
- **Hover indent:** a row's contents slide right by `hoverIndent` over `700ms`
on `cubic-bezier(0.16, 1, 0.3, 1)`. It is gated behind
`(hover: hover) and (pointer: fine)` and `min-width: 768px`, because on a
touch screen `:hover` sticks after the finger lifts and would leave a row
indented for no reason.
- **Reveal:** each row and the intro block are observed independently. On
arrival the title rises out from behind a clipping mask over `900ms` and the
detail line follows `60ms` later. Reveal is one-way — a row that has arrived
stays arrived, and its observer is dropped.
- **Reduced motion:** `prefers-reduced-motion: reduce` shows everything
immediately and removes the hover transition.
- **Several per page:** every query is scoped to the instance root and each
instance gets its own `IntersectionObserver`, so two ledgers on a page reveal
independently and never see each other's rows.
### Why the hidden state is in CSS, not the script
The pre-reveal state is declared in the stylesheet and keyed on the root's
`data-reveal` attribute, rather than being applied by the script on load. If
the script armed it instead, the first paint would show the finished section
and then snap it back to hidden — a visible flash on every load.
The cost of that choice is that content hidden by CSS must be un-hidden by
something. Three escapes cover it:
- `prefers-reduced-motion: reduce` shows everything in CSS alone.
- A `<noscript>` block shows everything when scripting is off.
- If `IntersectionObserver` is missing, the script reveals every row at once
rather than leaving the section blank.
Passing `reveal={false}` removes the attribute the hidden state is keyed on, so
the section renders fully static with no observer at all.
## Notes
The catalog demo's copy (passed as props, not built in) is written for this
catalog and is about component libraries drifting. It is illustrative only — no real organisation, product or quotation
is involved, and nothing here carries anyone's branding. Replace `items`,
`heading` and `intro` with your own and the design is unchanged.
===== END src/components/ProblemLedger.md =====
--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.
--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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.