Skip to content

v10.0.0 - #26

Merged
JCarlosHidalgo merged 144 commits into
mainfrom
develop
Aug 18, 2026
Merged

v10.0.0#26
JCarlosHidalgo merged 144 commits into
mainfrom
develop

Conversation

@JCarlosHidalgo

Copy link
Copy Markdown
Contributor

No description provided.

JCarlosHidalgo and others added 30 commits August 15, 2026 03:14
…, because a voice could only round corners

A commerce project took `"extension": "showcase"`, the voice whose declared job is to
invite and convert. It used 48 of the 59 components, put no class of its own on any of
them, passed `--audit --strict` clean, and still read as the default Arena with rounder
corners. Then it wrote 822 lines of its own CSS to look like a shop.

The count says why. `showcase` moved 19 tokens and not one was a typeface, a text case, a
weight, an ink assignment or an internal gap. Across the 43 manifests, 122 slots pointed
at `text-base-content`, 49 at `border-base-300` or `border-neutral`, 102 at `font-mono` or
`font-body` and 25 at `uppercase`, and a voice could reach none of them; 68 named a radius
role, and a voice could reach all of those. Radius was the only family fully parameterised,
which is the whole explanation for a voice that could only round things more. The five
eyebrow slots in the system were byte-identical, always mono, always capitals, always the
primary accent, and an eyebrow is where a shop writes "Single origin, Huehuetenango".

So the role tier grew along the three axes the census named, from 28 roles to 63: which
face, case, tracking, step and weight a heading, an eyebrow and a label take; which of the
consumer's colours each kind of text and each of six edges takes; and the air a control
gives its content and the gaps between the things a component lays out. It did not grow
into density, and a voice still assigns a colour rather than authoring one, which is the
line between an extension tier and a theme tier.

Every new role is born at the value the system already computes, and where a role names a
declaration no slot paints the manifest paints it first at the value that slot INHERITS.
`lh-heading` is born at 1.5 rather than at the 1.15 that would look better, because seven
of the eight title slots inherit 1.5 and a role born at snug would have tightened them all
on the day it landed. The 475 rebound uses across 40 manifests were held to that by a
resolved-value comparison against a worktree at HEAD rather than by a text diff, which
cannot tell a renamed var() from a changed value: 2,441 declarations compared, 0 values
moved, 0 gone, and 12 added, being five `font-weight: 400` on eyebrows and seven
`line-height: 1.5` on titles, each equal to what that slot already inherited.

Two collisions surfaced that nothing in the repository could see. A role named `ls-label`
collided with the `ls.label` scale step, and since effects.generated.css loads after
typography.generated.css the role silently overwrote the step, moving every
`tracking-label` from 0.22em to 0.14em while `check:tokens` passed throughout, because each
file was in sync with its own source. `check:token-collisions` is the gate that was
missing, and the naming rule it enforces is that a role never borrows a scale's prefix.
The second: Tailwind v4 emits `font-<key>` from both `--font-*` and `--font-weight-*`, so a
family key named `heading` produced the same class as the weight role that had been called
`font-heading` since the tier was built, and ArenaCard's title compiled with a family and
no weight.

`keyword` is the one type Arena adds to DTCG 2025.10, for `text-transform`, which takes a
word that 2025.10 has no type for. A `string` would have carried the same value and given
up what makes a type worth having, so a keyword names the closed set of words it may take.
`measure-prose` needed no new type at all: the refusal of a prose measure was right about
the obstacle and wrong about the exit, since `ch` is refused for the `dimension` type and a
measure was never obliged to be one.

`gallery` is the fourth voice, on `similarity`, the slot Extensions.md had declared open on
the condition that a manifest paint an ornament no manifest painted. The condition is met
rather than waived. `continuity` stays undeclared, because the invariant anybody reaches
for first says what the voice refuses to do and never says what groups, and a mechanism
whose invariant cannot be measured is a name.

A project derives its own voice in arena.config.json now, extending one Arena ships and
inheriting its Gestalt mechanism along with its values. The shared rules live inside the
command's own directory rather than being packaged out of scripts/, because a specifier
climbing out of bin/ resolves in this repository and to nothing beside a consumer's
node_modules; the gates import upward from there, so Arena and the consumer run the same
module rather than two copies that drift. `packageExtensions()` is retired rather than
patched: it discovered voices by re-parsing the shipped CSS for `.arena-<name>`, and a
selector says which class a voice answers to and never which mechanism it groups by, which
is exactly what a derived voice has to inherit.

Three roles the plan asked for were dropped rather than shipped unused. `fw-label` and
`fw-prose` would have added a declaration nowhere and no voice moves them; `fill-accent-area`
had not one use, since all 17 `bg-base-300` are fields, tracks and dividers. That is the
doctrine Extensions.md already states about building an affordance before the voice that
would use it, applied to this change's own output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… re-exported constant carries the module it came from

`dtcg-shapes.ts` began re-exporting `ARENA_EXT` from `extension-rules.ts`, and the intro page
reaches that file through `token-preview.ts`, which wants only `childEntries` out of it. A
re-exported VALUE is not a type, so Bun has to keep the module it came from, and `PRINCIPLES`
is built at the top level where nothing can prove it free of effects: the whole extension gate,
73 lines of it, belongs in the browser bundle now whether the page calls it or not.

The commit that added the re-export did not rebuild, so `intro/overview.generated.js` stayed at
the bundle it had before. `check:intro` rebuilds and compares for exactly this, and the
`git diff --exit-code` that follows `build:release` said the same thing from the other end.
This is that rebuild and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the column they sit in

`css/rhythm.css` ships the air between components as three named steps, and `css/prose.css`
ships the width of a reading column. Between them sits the thing every page needs before
either: how wide the content column gets, how far it stands off the viewport edge, and that
a short page fills the window so its footer does not float halfway up one. A project that
adopted a voice wrote all three by hand, and the two lengths it needed, `--container-max`
and `--gutter`, were already roles a voice re-answers.

`css/page.css` is that half. `.arena-shell` is the column, `.arena-shell__main` the one
child that takes the slack, and `.arena-band` the content column itself. It is a file of
its own rather than more of `Rhythm.css` because the two measure different things: rhythm
is air BETWEEN components, and this is the geometry of the page they sit on. The growing
child carries the class rather than the shell distributing the slack, since a shell with a
header, a main and a footer has exactly one child that should grow and no rule can know
which of the three it is.

`Rhythm.css` gains five alignment modifiers over the two classes it already publishes.
They carry no length, which is why they raise no question for a voice: where a row's items
line up against each other is decided by what is in the row, a trailing price against a
wrapping name, and a voice moving it would be answering about content it cannot see.

`.arena-shell` and `.arena-band` join `NOT_A_VOICE`, which is what keeps a class the
cascade would ignore off an npm page: both are written once per page, which is the shape
of a scope class, and neither is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he page head nailed above them

`ArenaCard` sets its title through `step-title-surface` and `ArenaDialog` through
`step-title-section`. `ArenaPageHead` set its own through `text-h1`, which is a step on the
scale, so the one heading that names a screen was the one heading no voice could move.
The hierarchy a voice writes is the DISTANCE between those three, and a rung nailed down
is a hierarchy it can only bend.

`step-title-page` is born at `{fs.h1}`, the size that slot paints today, and the manifest
names it. Verified by resolved value rather than by text, which is the only comparison that
can tell a renamed `var()` from a changed one: a worktree at `HEAD` and this tree both
built, every `var()` chain in every component sheet resolved against its own tree's tokens,
1783 declarations compared, 0 moved, 0 gone, 0 added. The page head's own font-size reads
`var(--fs-h1)` on one side and `var(--step-title-page)` on the other, and both resolve to
44px, which is the case a text diff would have called a change.

`SCALE_UTILITIES` gains the four heading steps, so the ladder cannot be re-entered by hand.
Two slots genuinely mean the step and say so: `ArenaStatCard`'s value is a figure set large
and not a title of anything, and `ArenaSheet`'s trigger follows the title it repeats, which
is the reason its `font-display` sibling already carries.

`Tv.ts` learns the key too. Without it `text-title-page` and `text-title-section` are two
font sizes tailwind-merge cannot tell apart, so a variant overriding one would have left
both on the element, and the suite that walks every namespace pairwise is what said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a region or scrolls one

Two of the four shapes a shop wrote by hand are shapes any page wants. A section is a
heading over a group, and Arena drew none, so `step-title-section` had no consumer and
every project spelt its own head row. A scrolling row is one tab stop with a group role and
a name, and without that role and that stop everything past its right edge belongs to the
pointer alone, which is a WCAG 2.1.1 failure invisible to anyone testing with a mouse.

`ArenaSection` takes the section register and guards both halves: a title, trimmed, because
a section with no heading is a stack that `css/rhythm.css` already ships, and children,
counted the way the render path counts, because a heading over nothing names nothing. It
claims no `region` landmark: a section earns one only with an accessible name, and a page
where every section announced itself would bury the two or three that matter.

`ArenaScroller` binds a pattern `contracts/behaviour/` did not have. APG has no page for a
box that scrolls, since scrolling is a behaviour of a box and not a widget, so
`scrollable-region` cites WCAG 2.1.1, the way `figure-with-data-table` cites WCAG for the
same absence. Its `focus.stop` requirement is behavioural rather than decidable, so each
layer's suite asserts the stop by acting on the tree and records the verdict.

`ArenaScrollerItem` is the correction the plan did not see coming, and `check:pixel-parity`
is what found it. The row first sized its children with `*:basis-[…]`, which compiles to
`:is(& > *)`. In React that lands on the card's own root; in Angular it lands on
`<arena-card>`, whose host is `display: contents` and carries no box, so the same markup
laid out at two different widths with every source-reading gate green. A rule aimed at a
row's own children cannot be relied on when a child may render no box, which is the hazard
`frameworks/AGENTS.md` already states and whose answer it already gives: when the item needs
a box, the item is a component. The width and the snap point moved onto it, the row kept the
axis, and the eight kitchen-sink pairs are identical byte for byte again.

Two more corrections came out of executing. A recipe resolves to a component's OWN class
names, so a layer suite asserting on a Tailwind utility asserts nothing at all: those claims
moved to `manifest-claims.test.ts`, where both layers reach one claim rather than two copies.
And `ArenaSection` needed `'[attr.title]': 'null'`, because Angular writes a static attribute
to the DOM whether or not it also matches an input, so a `title` input leaves a real tooltip
on the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s a picture

Arena drew no media frame at all, so a shop wrote its own box for a product's image and
the token layer had nothing to say about it: not the shape, not the corner, not how the
picture meets the edges. It drew no hero either, so `fs.hero` and `fs.display` sat at the
top of the ladder with no consumer and the one line a landing page is built around was
whatever CSS the project reached for.

`ArenaFigure` is the frame, and it takes no source and no alternative text. The picture is
the element the consumer wrote, so `src`, `srcset` and `alt` stay where the platform asks
for them: an image's alternative says what it means on this page, which nothing can derive.
It renders a real `<figure>` with a real `<figcaption>`, which is why the Angular host takes
itself out of layout rather than pretending to be one. The fallback is a state and not an
error, drawn only when there is no media.

`ArenaHero` takes the fourth and last rung of the title ladder. Its split falls to one
column with no breakpoint deciding when, and the threshold is derived from `--grid-min`
rather than from a number the component invented, so a voice that widens the grid minimum
widens when a hero splits: one decision about density rather than two that can disagree. It
claims no `banner` landmark, because banner is the site header and a hero is content.

Four media roles arrive with the manifests that paint them, and the fifth does not. A colour
for a line around a figure would have been a role over a declaration nothing draws, which is
the dead weight `Extensions.md` refuses and which this plan has now refused three times.

Two type decisions worth their reasons. A ratio is a `number`: a keyword is one bare word
and `4 / 5` is two, `aspect-ratio: 0.8` is the same shape, and widening the one departure
from the token specification to buy a spelling is not worth it. `fit-media` IS a keyword,
the second in the system, and it reaches a manifest through a hand-authored utility for the
reason `Case.css` states, since `object-fit` has no namespace either.

Two corrections from executing. A framework source carries no comments at all, so the split
threshold's reason moved to the prompt beside it. And `check:assertions` refuses
`assert.equal` over a connected DOM node, which is right: the diff it builds when it fails
is the size of the shared document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at holds the page

Arena drew a side nav and a bottom nav and no bar across the top, no footer, and nothing
for the list of terms and values a basket, an order and an invoice all end in. Those three
are 273 of the 822 lines a shop wrote by hand, and none of them names anything a shop
sells: a documentation site and a console want all three.

`ArenaAppBar` is the banner landmark and `ArenaSiteFooter` the contentinfo one, so
`contracts/behaviour/` gains the two patterns it was missing. Neither has an APG page,
since a landmark is a practice rather than a widget, so both cite the landmark-regions
practice page the way `navigation` already does, and neither requires a name, because a
page carries one of each.

The bar draws no anchor and no navigation landmark, and both absences are the point. The
links are the consumer's router's, and Arena never wraps one; a page with a side nav as
well has two navigation landmarks, and naming them apart is a judgement about that page.

`ArenaKeyValue` renders a real definition list, so a term and its value are associated by
the platform rather than by a class name. Every value is a string, because every value in
every list it replaces is text somebody already formatted, and `numeric` is per row because
a summary mixes an address with a price and only one of them is a figure. The total is a
member and not the last row: the rule above it and the heading register are what say a
total is a total, and deriving that from position would make the last adjustment look like
one.

One correction worth the reason. A component manifest may not read `--container-max`: the
role shares its name with Tailwind's `--container-*` namespace, so the strip leaves it in
the emitted sheet and an adopter who declares that property would rescale the bar silently.
`check:component-css` is what said so. The page width now lands inline in both layers, the
way `ArenaGrid` already writes its ceiling, and the manifests carry the gutter alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six roles arrived with the components that paint them and every voice was still silent
about all of them, so a figure was the same shape in a shop, a document and a gallery, and
a landing page opened at the same size whichever voice it took.

Each voice answers by its own mechanism and by nothing else. The reading voice letterboxes,
because a diagram cropped to fill a frame loses the part being pointed at, and squares its
corners, because it spent three other roles removing the box from around a card. The shop
voice crops portrait so a product fills its column, rounds the picture to the corner its
cards take, and puts the wash under a badge on the SURFACE fill rather than the page's,
which is its figure-and-ground claim made one axis over. The gallery voice takes the corner
almost off, because the image is the mark it groups by, and holds its wash back to neutral,
since a page-coloured one reads as a hole cut in the picture.

The hero rung is where the three disagree most, and that is the evidence it was worth
having: 120 for the voice whose job is to be arrived on, 44 for the voice whose text follows
the picture, 64 for everything else.

Two things came out of executing that are worth more than the values.

A `$description` may not contain braces, because Style Dictionary resolves a reference
wherever it appears and the prose is replaced by that token's value: a description reading
"rather than as {fs.hero}" became the dimension 96px, and the generator failed on a string
that was no longer one. `contracts/design/TokenTypes.md` already said so and nothing held
it, so `check:dtcg` holds it now, and it was written by making it fail on the real case
first.

A role that aliases a step resolves against the BASE scale, because the only scopes a
reference is restated under are the ones `REDECLARED_GROUPS` names and a voice is not one of
them, and cannot be: any voice may move any step, including one a consumer derives. So the
shop voice writes its hero rung outright, since aliasing the step it had already raised
would have frozen the rung at the value it was moving away from. `Extensions.md` carries the
general form, including why emitting every such role as a reference would be the worse
trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a card ordered nothing it held

Both came out of opening the consuming shop and looking, rather than out of a red gate, and
neither is one a gate could have caught. `check:pixel-parity` compares React against Angular,
so a fault both layers render identically is a fault it reports as parity.

`fill-surface-sunken` and `fill-page` are both `{color.base-100}`. That is contrast of zero,
by definition and not by approximation, in both themes, and `ArenaFigure` paints its frame
with the first of them. A figure inside a card reads, because there the sunken step falls on
the card's own fill; a figure anywhere else is invisible. On a product page that is a frame of
some 700 by 875 holding a glyph, with nothing around it to say a frame is there at all.

The role is not what is wrong, and its own description says so: it is for "a region that reads
as recessed INTO a surface", and it closes with "a sunken region is not a floating surface and
not the page". `ArenaErrorState.code` uses it exactly that way and works. A media frame is
recessed into nothing -- it sits wherever a consumer puts it -- so `ArenaFigure` was the one
naming it wrongly.

No fill fixes this. `bg-surface` would light the figure on the page and put it out inside a
card, which is the same fault moved one context over, and no single fill reads on two grounds.
What reads on any ground is the signal the voice has already chosen to group by, which is why
the frame now takes the pair `ArenaCard.root` takes and lets the voice decide which of them
draws: an edge where a voice groups by common region, a depth where it groups by figure and
ground, and neither where it groups by proximity, whose whole claim is that nothing is drawn.
The roles are named rather than any literal width, so that last case holds by construction and
`check:extensions` has nothing new to forgive.

The correction is worth more than the fix. The first attempt was the edge alone, every gate
passed, and it was invisible in the one voice that prompted the work: `showcase` sets
`bw-surface` to zero and separates a surface with an inset rim instead, so a border was the
DEFAULT voice's answer written as though it were the system's. A component that draws a
surface has to ask the same question a surface asks, not pick one voice's answer to it.

The second is smaller and older. `ArenaCard.body` was `p-surface` and nothing else: padding on
a bare block, imposing no flow on what it was handed. Two inline children of a card therefore
touch, and in the consuming shop a rating and a price rendered as `(128)EUR18.50` on every card
of every page -- not a slip in the consumer, since JSX drops the text node between two elements
written on separate lines and the card orders nothing. It stacks now, at `gap-items`, which is
the register `ArenaFigure`, `ArenaKeyValue`, `ArenaHero`, `ArenaEmptyState` and `ArenaErrorState`
already spend on this same question.

It is not free for a consumer: a lone inline child of a card body becomes a stretched flex item,
and a consumer whose children carry margins of their own now gets the gap on top of them.

What this deliberately does not fix is the void that frame is drawn around. That a figure with
no picture has no size of its own is a question about composition, not about colour, and it is
one of four that stay open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…whoever adopts it

The partials go, and with them the three scope blocks, the `--arena-extensions` property that
advertised them, and the record `arena.tokens.json` carried so a consumer could pick from a
menu. `REDECLARED_GROUPS`, `scopeOn` and the theme and density scopes stay: none of them was
ever about a catalogue, and the plugin block needs every one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o keep coherent

`PRINCIPLES` and the uniqueness rule answered "how does this voice differ from the one beside
it", and there is nothing beside it now. The generated catalogue table goes with them, and so
does the branch in the shipped CLI that held a derived voice to a mechanism it inherited. The
floors stay, because a floor is a claim about a reader rather than about a catalogue, and
`check:boundary-contrast` now reads the file the border values actually live in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y else

The cycle control, the specimen page, the per-voice sinks, the sentence injected into every
prompt and the router's opening question all said the same thing: that a page's register is
picked from a menu Arena wrote. `Extensions.md` goes with them, and `SHAPE` says the design
level is one document lighter, which is true until `StylePlugins.md` lands. The one sink left
is `default`, and the consumer install route is charged what it now costs rather than what it
cost when a reader had a table to read first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`voice` and `extension` were the same mechanism, and `extension` collides twice over: with the
DTCG vendor key in this very tree, and with the Claude Code plugin the repository already
ships. The axis is `stylePlugins`, it takes a list because a build can carry more than one
register, and prose always qualifies the word. A config still spelling `extension` is refused
by name rather than read, because a key Arena silently ignores is a page painted by nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ships

Every `.prompt.md` a builder reads takes its member prose from `contracts/api/components/`, so
a word left there is a word handed to every consumer of both packages. `voice` is gone from the
tree in the sense this plan retired it, and the two senses it keeps are a different word: the
brand accent, and the register a document is written in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`roles.json` carried both halves, so the kernel had an appearance whether or not anybody wanted
that one. The declaration keeps the type, the description and a keyword's admissible set; the
answers move to `plugin-style-store/default/`. The vendor block splits the same way: `cssUnit`
says how a value is written and travels with it, `values` says which answers are admissible and
stays with the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…first consumer

The role values emit from `plugin-style-store/default/` into a sheet of their own, and
`intro/styles.css` imports it where a consumer's chain imports it. Every `:root` declaration
that existed still exists with the same value, which is the only claim this commit makes and
the only one worth making. The two places that listed the generated sheets by hand derive the
list from the generator now, because a fifth sheet is exactly what a hand-written list misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gates, one claim each. `check:role-contract` holds the declaration to being a declaration:
a type, a description, a closed set for a keyword, and no value. `check:style-plugin` holds the
root plugin to answering every question, because a custom property with no value is invalid at
computed-value time and takes its whole declaration with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…carrying a copy

`CSS_CHAIN` gains one entry, after the scales so a role resolves and before the palette so a
colour reference is substituted in scope. It is also the one entry a project replaces, which
is what the header now says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ee scripts/

`serialize` turns a token's `$value` into CSS, and the shipped command needs it now that a
consumer's style plugin is a `plugin.tokens.json` of their own: a package carries no `scripts/`,
so a module up there is a specifier that resolves in this repository and nowhere else. It moves
into the command's tree and the generator imports it downward, which is how `dtcg-shapes.ts`
already reads `ARENA_EXT`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… as the style layer

`extension` is two words in this tree, and the sweep that renamed the style layer caught the
other one: a specifier carrying no file extension, a `node16` consumer inferring one from a
declaration, and the tests no package ships. The kitchen-sink line beside them counted a page
per appearance, and there is one page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check:boundary-contrast` asks whether a control drawing no border still carries the 3:1 WCAG
1.4.11 wants of its boundary. It read `roles.json`, which stopped holding a value, so it walked
a file with no zero in it and passed on an empty hand. It reads the root style plugin, which is
where a width lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first plugin in the list is what a page with no class on it looks like, and every later one
emits under its own. A polarity group emits three compound selectors rather than one, because
the plugin class and the theme class sit in either order or on the same element, and the third
is what stops a light region inside a scoped root taking the dark answer. `scopeOn` spells all
three, beside the floors the same command runs, so the generator and the emitter cannot drift.

An entry is the word `default` or a path to a directory of the project's own, so the command
reads a plugin and hands the answers to an emitter that still opens nothing, and only the root
is held to totality: the rest sit over it in the cascade and are differences. A build whose root
plugin is its own drops the sheet the package assembles out of the import chain, because two
answers to every role is one too many. A bare `{color.*}` alias becomes the `var()` the rule
already promised in its refusal: resolved to a hex it would freeze one theme and inherit it into
the other, which is the failure the rule names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eal plugin

It read a config and never a plugin, so nothing outside this repository had exercised the path
a consumer takes. The fixture builds its total plugin by copying the store rather than writing
a third copy of the values, which is the rule the store exists to keep, and a two-key partial
beside it is what proves the refusal names the role nobody answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…name to the dom

`slotPart` sits beside `slotClass` for the reason that one exists: two spellings of one name
drift, and the specimen harness reads the same module the generator writes with. Nothing
renders it yet, so nothing paints differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot add one

That ceiling is why two rounds of role growth did not move the measurement they were answering.
Every slot reaches the dom as `data-arena-part` now, so a plugin can paint what no token can
express. The cost is stated where slots are defined rather than discovered at a consumer's
build: a slot name is contract, and renaming one is a break.

`check:parts` holds the element rather than the file: an element is the opening tag around a
class expression, a React object literal carrying a `className` entry, or an Angular `host`
binding, and an expression choosing between two slots is one element and takes one part. A class
string assembled inside a helper names its slot in the helper, which the gate declares as its
one blind spot rather than discovering it later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every compiled component rule sits in `@layer utilities` at one class of specificity, so the
reserved layer is declared after it and wins without an author spelling anything. Unlayered
application css still beats it, which is the right order and is why the audit keeps reporting
an application that reaches in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e it is

A part selector is sanctioned inside a declared plugin directory and reported anywhere else,
because an audit that cannot say where a project's appearance lives has given up half of what
it reports. A compiled class name is reported in both scopes: the hook is the contract and the
class is output. The gradient is the one rule that relaxes inside a plugin, because the norm
now records it as a report rather than a floor and `--strict` may not refuse what the norm
permits. The new note names the parts a plugin paints, which is what the role tier grows by.

The command writes the wrapped sheet beside the theme, from the `plugin.css` each declared
directory carries, and `check:consumer` now runs a fixture that paints a part inside a plugin
and the same rule outside one: the first audits clean and the second does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… everything moves

It is not a design and does not try to be. Every role takes an answer `default` does not use
and every part takes a rule, which is what lets the coverage gate ask a question nothing in the
tree could ask before. The gradient is deliberate: it is the floor that became a report, and the
store is where that is demonstrated rather than described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in the tree asserted that the surface the kernel advertises is the surface it exposes.
`complete` is the witness and this gate is the question: every declared role moved, every
emitted part painted. It reads the parts from the manifests rather than from the generated
modules, so a manifest that gained a slot and never rebuilt is caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`StylePlugins.md` takes the design level's fourth slot in `SHAPE`. What it adds beyond stating
the mechanism is the admission: a floor expressed as a token value is a floor over the token
half only, a plugin paints a gradient from its own stylesheet whatever the token tier says, and
a sentence that reads like a guarantee is worse than an admitted limit.

The site becomes the first reader of its own mechanism: a two-position control toggling
`arena-complete`, taking the name from the sheet the page links rather than from a list written
in the script. `check:text-contrast` goes with it, because after the split it was reading the
role values out of a sheet that stopped carrying them and measuring one surface where it used
to measure two; it reads the plugin sheets now and every scoped plugin this build emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JCarlosHidalgo and others added 29 commits August 17, 2026 02:36
The one defect a component cannot report about itself is a projection marker
written without its directive: the contentChild query misses, the slot renders
nothing, and the build, the typecheck and the audit are all green. This is the
only check that catches it, and it was reading the first `imports: [` and the
first inline template in a file and answering for those alone.

A consumer puts several components in one file as soon as two belong together,
and that turned the guard off twice over. A marker in a later component went
unchecked, which is the hole. A templateUrl in a later component was judged
against the FIRST component's imports, which is worse than a hole: it reports a
slot the consumer filled, and a guard that lies in that direction is one they
learn to skip.

componentsOf cuts the source at the decorator so each declaration is read on
its own. Merging every import in the file would have closed the first half by
widening the second, one component's import covering its sibling's marker, so
two of the four new cases exist to fail against that repair rather than this one.

Found from outside, by the Angular consumer benches: no gate here reads an
application, and every bench file that draws a marker declares more than one
component.
The dataviz validator answers "Contrast vs surface" with the state `relief` and
the 6-8 CVD band with `floor`, and sets `ok` for neither. `paletteReports` kept
only `false` and `fail`, so both verdicts were computed over the consumer's ramp
and discarded, while `check:ramp` turns the same two into failures for Arena's
own and says so in as many words.

Measured over the four consumer benches: notion's light ramp has one slot under
3:1 against its surface and duolingo's has six, and neither produced a single
report before this.

`rampFailed` is now the one statement of which state is a pass, exported from
the module the packages ship and read by the gate, the way `check:style-plugin`
already reads `style-plugin-rules.ts`, so the two cannot drift apart again.
`check:ramp` keeps its own `warned` subset, which is presentation: the states
the validator tolerates and Arena does not.

The surface passed to the validator was `base-100`, the page, rather than the
`fill-surface` role a chart card actually paints on. It resolves through the
consumer's root style plugin now, falling back to the catalogue the package
carries. No verdict in the corpus changes: the two surfaces there differ by
less than 0.02 in L.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check:text-contrast` measures every fill against its own content colour, all
seven of them, because the pair is the contract a skin defines and an illegible
one fails before a component can inherit it. The shipped command measured
`primary-content on primary` and nothing else, so six of the seven reached a
consumer as silence.

Measured over the four benches, pairs that shipped illegible with a clean build:
clickup light 2 (info 4.33, success 4.10), instagram light 1 (neutral 4.12),
duolingo light 5 (secondary 2.54, neutral 3.55, info 2.44, success 2.09,
error-fill 4.30) and duolingo dark 1 (error-fill 4.40). Four of the eight
palettes in the corpus.

The list of what has to be legible on what now lives once, in `palette-keys.ts`,
which the packages ship and the gate imports, and the gate's suite asserts the
two sets are equal. It carries no error/error-content pair, because danger is an
outline and `error` is never a fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing reads it

`error-fill` is the one optional palette key, and both this specification and the
npm page invite a consumer to leave it out and let Arena derive the fill. The
derivation was written as a CSS fallback on `--danger-fill`, and **no shipped
stylesheet reads `--danger-fill`**: the only filled danger surface compiles to
`background-color: var(--color-error-fill)` with nothing behind it. So an omitted
key emitted no declaration, the property was invalid at computed-value time, the
declaration was dropped whole, and ArenaConfirmDialog's point-of-no-return button
painted no background at all, carrying `error-content` — white, on all four light
palettes in the corpus — over the dialog surface. The comment promising "nothing
goes backgroundless" described the one thing that happened.

The derivation moves to the emitter, so the palette always carries the key and
the value is a number something can read. The pair check added in the previous
commit then measures it: with `error-fill` removed from the benches, notion dark
reports 4.39:1 and duolingo dark 4.05:1, which is a separate defect in the
darkening constant and is now visible rather than silent.

The oklab arithmetic moves to a shipped sibling that `check:text-contrast`
imports, so the gate and the command cannot compute two different fills.

No palette in the corpus omits the key, so nothing that renders today changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nsumer's

A style plugin answers ff-heading with the alias {font.display}, which is how
every plugin in the corpus and the default plugin Arena ships both write it. The
emitter deferred a {color.*} alias to a var() and resolved everything else
against Arena's own catalogue, so that answer became 'Archivo', the Dravensoft
display face, baked into the sheet. A consumer who declared Inter got Inter in
--font-display, Archivo in --ff-heading, and, because they never loaded Archivo,
system-ui in every heading, eyebrow and label on the page.

The rule was one question doing two jobs. A scale step is Arena's own and
resolves to the value the package ships; a colour and a FACE belong to the
skin, which the consuming project declares, so both are emitted as references
and resolved where that project declares them. Style Dictionary now separates
LATE_BOUND, which decides whether a reference is emitted at all, from
REDECLARED_GROUPS, which decides which scopes restate one.

Measured after: react-notion's --ff-heading resolves to Inter and
react-duolingo's to Baloo 2, where both said Archivo before. Four of four
products, eight of eight palettes.

Arena's own tree is unchanged: --font-display is Archivo here, so every sheet
resolves to the face it already painted, and the only diff in the generated
plugin sheets is six declarations turning into the references they name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fw-heading` takes a step of Arena's weight scale, which runs 400 to 900 because
all three Dravensoft faces are variable fonts covering that whole range. A
consumer's face need not, and when it does not the browser synthesizes: it
smears the nearest weight it has and the heading is not the face the project
chose. Nothing said so, at any point in the build.

`weightReports` resolves each weight role through the family role beside it,
`fw-heading` through `ff-heading` and `fw-control` through the body face the
control slots name directly, and compares the answer against what the config
declares the face covers: the `weight` field, or the `wght@` list of a Google
Fonts URL. All three URL forms are measured against the service rather than
assumed: no axis serves 400 alone, `wght@a;b;c` serves those steps, and
`ital,wght@0,400;1,700` puts the weight second in each tuple. A self-hosted
file whose range the config does not declare is left alone rather than guessed
at, because this command cannot open it.

Measured over the four benches: duolingo answers `fw-heading` with 900 and loads
Baloo 2 at 400 to 800, so every heading in that product is a synthetic weight.
The other three report nothing.

`weight` joins the `--strict` kinds, so a project can hold it in CI without
buying the strictest kind along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xt on a dark palette

The fill derived for a palette that pins no error-fill was error darkened toward
black by a fixed 85% in oklab. Darker is the right direction only when the text
riding on that fill is the lighter of the two, which is what Arena wears: both
themes put #ffffff on the filled danger, so the constant was verified twice
against the one case where it cannot be wrong.

A palette that names its own dark page as error-content — the daisyUI pairing,
and what all four benches write on their dark side — gets the opposite. The fill
is walked toward its own text.

The derivation now moves error AWAY from error-content: darkening where the
content is lighter, lightening where it is darker, by the same 85%.

Measured, with error-fill removed from each bench so the derivation is what
paints: notion dark 4.39 to 7.70, clickup dark 4.28 to 7.51, duolingo dark 4.05
to 7.10, instagram dark 5.08 to 8.91. All four light palettes derive the same
hex as before, byte for byte, and so does Arena's own skin in both themes.

No palette in the corpus omits the key, so this changes what a page paints only
for a project that takes the specification up on the offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one palette

A LEVEL is the percentage a compiled slot keeps of a colour it does not own: the
62 in text-ink-muted/62. The role says WHICH colour and the modifier says how
held back, and the modifier lives in the manifest, so no token can move it. There
are 136 such declarations in the component sheets the packages ship, 17 distinct
levels, and every one mixes against `transparent`, which means the value it
paints does not exist until a browser composites it over whatever is behind.
Nothing in the pipeline held a number.

`check:text-contrast` could not reach them either. It composites correctly, but
only over `colors.css`, whose `--mute` alias is a second spelling of the same 62
that almost nothing reads; the 68 slots that do read it go through `--ink-muted`,
which is the variable a consumer's style plugin is invited to re-point.

`levels.ts` reads them out of the sheets rather than listing them, because a list
is one more thing to keep true. Text is held to 4.5:1 and a mark built from an
ink role to 3:1; a soft wash of a status or brand colour is held to nothing,
because a tint is meant to be faint and gating it would report the design. The
disabled register is exempt, in a map that says why.

The shipped command reads the sheets from the package beside it and composites
each level against the surfaces the consumer's own plugin names. Measured over
the four benches, all previously silent:

  duolingo  light  --ink-muted 62% at 3.23:1 (67 slots) · presence dot 2.58:1
                   --ink-body 70% at 3.90:1 on the neutral tag
  notion    light  --ink-muted 62% at 3.85:1 (67 slots) · presence dot 2.96:1
  clickup   light  --ink-muted 62% at 4.03:1 (67 slots)
  instagram light  --ink-muted 62% at 4.42:1 on the page, 4.29:1 on the card

All four dark palettes clear every level, and so does Arena's own skin in both
themes, which is what makes pointing the gate at the same 136 declarations safe
rather than a repaint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An outline control IS its token as text, and its hover washes the fill in that
same token, so the text loses contrast against its own background. The pair sits
inside one rule with the hover nested under it, which is why no level check
reaches it: the ink and the wash are two declarations and the defect is the
relation between them.

Arena fails it too, which is what makes this a floor nobody measured rather than
a constant a consumer inherits: `--color-error` on its 14% wash reads 4.44:1
dark and 4.39:1 light on the page, and `--color-primary` on the 22% wash of a
pressed ArenaIconButton reads 2.58:1 dark. Three slots, both themes.

Reported and NOT gated, on the same footing as crimson-as-text, because the
resting ratio is the ceiling: reaching 4.5:1 needs a wash near zero, which is a
hover nobody can see. What would repair it is a hover that moves something other
than the fill, and that is a decision about the danger convention rather than
about a percentage. The measurement and that reasoning are now in
contracts/design/AGENTS.md beside the rule they qualify, where they fail the day
they stop being true.

The consumer's build reports it against their own palette: duolingo's light
danger reads 2.79:1 and its pressed icon button 1.77:1.

Also fixes an exemption this series broke on its way here: a level's nested
state, not the component rule, is what says a slot is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ozen into a hundred sheets

The muted scale was 62/70/82 and the presence dot 52, spelled as literals in the
manifests and compiled into the component sheets. The doc says what that costs
in as many words: clearing AA in light needs 61% and the level sits at 62. That
margin is one point, measured on Dravensoft's ink, and every consumer inherited
the number without the measurement.

A level is now a custom property a slot reads through the opacity modifier,
`text-ink-muted/(--level-ink-muted)`, which Tailwind compiles to
`color-mix(in oklab, var(--ink-muted) var(--level-ink-muted), transparent)` with
the full-strength fallback intact. `colors.css` declares the four as floors and
`arena-to-prod` RAISES one whose ink cannot clear its bar for that palette, per
palette scope, and never lowers one.

  duolingo  light  muted 62 to 76 · quiet 70 to 76 · presence 52 to 60
  notion    light  muted 62 to 68 · presence 52 to 53
  clickup   light  muted 62 to 66
  instagram light  muted 62 to 64
  every dark palette, and Dravensoft in both themes: unchanged

Raising a level costs the hierarchy it belonged to, and that half is reported
rather than applied in silence: duolingo's muted and quiet registers both land
at 76% and the build says they are 0 points apart, under the 8 two registers
need to read as different. Legibility is held by construction; the separation
cannot be, because only an ink with more room restores it and the ink belongs to
the consumer. An ink that cannot clear the bar at full strength is named as the
ink rather than as the level.

The scanner from the previous commit would have gone blind the moment these
became variables, which would have traded one silence for another, so it
resolves them from the defaults the package ships and both readers use the same
resolution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check:consumer caught a contradiction this series introduced. The wash report is
measured and deliberately not gated in Arena's own tree, because the resting
ratio is its ceiling and no percentage lifts it; but it went out under the
`contrast` kind, so a consumer running bare `--strict` got a build failure with
nothing they could change to fix it. Arena's own example config exits 1 on it,
which is how the gate found it.

`reports.ts` already carries the argument: one switch over every kind makes the
strictest of them the price of any of them. So the kinds split into the ones
--strict may hold and the ones it may not, and `wash` is the first of the second
sort. It still prints, on every run, which is the whole point of measuring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g paints

A slot can hold a colour back twice: once through the opacity modifier the level
carries, and again through an `opacity` declaration on the same rule. The scanner
read the first and never the second, so `.arena-stat-card__icon` was reported at
62% of the ink when what the browser paints is 62 of 60, or 37.2%. A number that
is wrong by 40% is worse than no number, because it reads as a measurement.

It compounds now, from the rule and from the rule around it. Measured at the
true level, that icon is 2.30:1 on Arena's own light theme and under 3:1 in nine
of the ten palettes in the corpus.

It is still not a defect, and the evidence is in the markup rather than in the
sheet: both layers draw it inside an `aria-hidden` wrapper beside a label that
already says the same thing, which is what WCAG 1.4.11 exempts as purely
decorative. So it goes in DECORATIVE, keyed by the exact part, and the paired
suite asserts every entry is still rendered aria-hidden in BOTH layers. An
exemption resting on a claim about markup is worth having only if something
fails when the markup changes, and this one does: dropping the attribute from
either layer fails the suite by name.

Exempting by the word "icon" was the alternative and it is too blunt: six gated
levels sit on a selector naming one, and ArenaIconButton's ghost variant is a
control's own glyph rather than decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ted 50% from preflight

Arena styles no placeholder. Tailwind's preflight does, and it ships inside
`css/prelude.css`: `color-mix(in oklab, currentcolor 50%, transparent)`. On an
Arena field `currentcolor` is `--ink-body`, so every placeholder in every project
was base-content at a flat 50% that nothing in this repository declared, nothing
derived and nothing measured.

Measured against the field surface it fails AA on six of the ten palettes,
Arena's own light theme among them at 3.17:1: notion 2.81, clickup 2.91,
instagram 3.09, duolingo 2.46, and this skin at 3.34 on the page.

The three slots that take a placeholder now colour it with the muted level,
`placeholder:text-ink-muted/(--level-ink-muted)`, which is the register a hint
belongs to and the one level arena-to-prod already derives and guarantees per
palette. So the value stops being inherited from a vendor default and starts
being one Arena states, and the scanner sees all three where it saw none.

This also removes an exemption I had no business writing. `placeholder` was in
EXEMPT with the reason that a hint is not the field's own text; 1.4.3 exempts an
INACTIVE component, and a field waiting for input is the opposite of one. There
is no placeholder carve-out, so the entry is gone and the reason it was wrong is
in the file header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on with its own number

`tint.soft` says how much of an identity colour a surface keeps, and says why 12
and not more: "light enough that text at the body contrast keeps its ratio over
it". ArenaCalendarEvent composed that surface by hand at 16%, over
`var(--surface-card)` rather than over the surface role, in BOTH layers. Two
spellings of one decision, at two numbers, over two grounds.

Nothing caught it, and the reason is worth writing down. `check:dimensions`
reads a value only for the props it lists, and these are colour props.
`check:duplicate-constants` reads module-level NAMED constants, and an inline
one is not. `check:appearance` classifies a template literal carrying `${` as
non-literal, which is correct for its own question and is what let this through.
So the rule gets a suite of its own: no framework source spells a percentage
inside a `color-mix(`. It named both offenders before the fix and passes after.

`arenaCatTint(colour)` is now the one expression, and `arenaCatSurface` fills
with it. It grounds on `var(--fill-surface)`, the role, rather than on
`--color-base-100`: a chart card and a calendar chip are the same question, and
a compat alias is what the audit reports a plugin for reaching through.

Measured on the chip's own title: notion light 5.82 to 6.30, duolingo light 4.66
to 4.97, duolingo dark 6.71 to 8.72. Fainter tint, more contrast, and the number
now has one home.

One latent defect fell out of the types. React took the chip colour as an
injected prop that `Partial` makes optional, so a chip rendered without one
interpolated `undefined` into the mix and the browser dropped the declaration.
It falls back to its own `colorId` now, which is what the Angular twin already
did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both DataVisuals suites asserted arenaCatSurface fills over --color-base-100,
which is the ground the previous commit replaced with the surface role. The
assertion is right to be there and was right to fail; it says what it holds now,
which is that a chip lands on whatever a style plugin calls a surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cited by a page that reached them

`contracts/behaviour/` states each accessibility pattern once, in the form markup
of a consumer's own can be held to, and `media-register.md` sends a builder there
by name for anything they draw themselves. The site copied `contracts/design`
and `contracts/design-generated` and not this one, so the reader following that
sentence over HTTP arrived at a path the site did not serve.

`COPIED` carries the directory now, and `build-site`'s `reads` names it, so a
change to a contract rebuilds the site that publishes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ever told

A palette declares a polarity and Arena drew every surface from it, but
`color-scheme` was declared nowhere. That property is what decides the furniture
Arena never draws: the scrollbars, the native controls, the autofill, and the
canvas the user agent paints wherever nothing else does. Without it a dark
palette got light scrollbars and a white canvas under the last screenful, which
is the same symptom as a page with no floor and has a different cause.

It reaches the canvas from the root element alone, which is exactly where the
`.arena-<name>` class already goes, so the declaration sits beside
`--picker-invert`: both are statements about direction rather than paint.
`--fill-page` stays the floor and stays the project's to apply. Arena says which
way the palette points and leaves the page to whoever owns it.

Nothing could have caught it. `color-scheme` is not a custom property, so no
token gate reads it, and a render suite is blind to it because happy-dom paints
nothing. `check:polarity` holds the two files that state the direction
independently, `palette-read.ts:THEMES` and `colors.css`, so a scope added to one
and not the other fails rather than shipping a page whose native furniture points
the wrong way with every other gate green. `check:consumer` grew a third palette
so the assertion covers a scope that is neither `:root` nor `.arena-light`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nsumer with nothing announcing it

`check:exports` holds one half of the router's rule, that anything a package
ships needs a home on the consumer branch, and it holds it for a symbol. The
other half had nothing. A stylesheet lands in the tarball entire, so every class
in it reaches a consumer whether or not any page names it, and a class nothing
names is one they replace with a rule of their own.

The measured case is the two density classes. `.arena-compact` and
`.arena-comfortable` shipped inside the spacing sheet and were named on neither
npm page, so a reader who had only that page could not learn either exists, and
the screens built against it re-densified by hand or not at all.

The subject is derived from the sheets the assembly copies rather than listed, so
a new consumer stylesheet is covered the day it is added. `NOT_WRITTEN` declares
the classes that are Arena's own name for something rather than something a
consumer puts on their own markup, which is the only exception the rule admits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not carry

Each layer's export table names the modal contract and says it is the surface to
reach for whenever the answer is that the markup is yours. The sentence beside it
sends the reader to `contracts/behaviour/` for what that markup has to do. Neither
package shipped that directory, so the half of the answer a consumer could reach
was the focus trap and the half they could not was the contract stating what to
trap it for.

Six cold walks measured it from three entry points. The two that started at the
installed package took the export and never read a clause of the pattern, because
there was nothing in their project to read: the repository and the site both
carry it and a tarball is the one place a consumer already has.

`copyBehaviourContracts(dir, root)` copies the JSON alone, so the contributor
page beside them stays on the branch whose reader can act on it, and both
manifests answer `./contracts/behaviour/*` so the files are reachable through the
package rather than only present in it. It throws when the directory is empty
rather than assembling a package whose page cites what it does not carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… is what holds it back

Under the appearance the packages install with, `--ink-muted` and `--ink-body`
resolve to the same colour. What makes the held-back register held back is the
level mixed into it, and forty-four component sheets spend one. No document on
the consumer branch showed the expression, so a builder reaching for the
documented role wrote `var(--ink-muted)` and painted a caption at the strength of
body copy, with every gate green: the audit reads source text for a raw value and
a token spent wrongly is not one.

Five of six cold walks shipped exactly that. The one that did not found the idiom
by reading a compiled component sheet, which this branch tells a builder never to
target, and said so.

The levels are floors rather than constants and `arena-to-prod` raises one for a
palette whose ink has too little room to clear its bar, which is why a percentage
of a consumer's own is the one value here that cannot follow the palette. That is
now on `page.md` and in the shared region both npm pages carry.

Four more things the same walks found missing, all in that shared region so
neither page can drift from the other:

The column and the rhythm existed only in a stylesheet table four hundred lines
down, so a reader arriving from the registry met `.arena-shell` as a filename.
The shape of the miss the group step replaces is stated where the classes are
listed, because a walk that had read the class list still wrote a column of its
own inside a table cell. The band carries the width and the gutter and no block
air, which every walk invented a padding for. And the members of every component
document themselves in the type declarations, which no page pointed at.

`media-register.md` gains the viewer that opens a picture at full size: Arena
ships no lightbox and six of six walks built one, each inventing the same four
answers when all four are already a token or a contract. `surface.md` says why
density is an axis of its own and how a product offers both, which is a project
decision rather than a per-screen one and is why it is not on the router.

The four route budgets that pay for this carry the measurement that bought each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… signature

`check:exports` asserted that a symbol reaching the root of a package has a home
on that layer's npm page, and it asserted it with `text.includes(name)`. A name
appearing anywhere satisfied that, so the parameter list written beside it was
prose nothing read.

Twelve call forms across the two pages named parameters no module declares, six
per layer. `arenaTrapTabKey(event, root)` against a declaration taking
`(container, event, activeElement)`, and `arenaHandleOpenTransition(host, open)`
against one taking four arguments of which none matched. A cold walk that took
the page at its word wrote a call that does not compile and went to the type
declaration to recover, which is the one document the export table exists to
save them from needing.

`signatureProblems(base, barrels)` parses the declaring module with the
TypeScript compiler rather than a pattern, so destructuring is reported as the
properties it binds and an optional parameter keeps its mark, which is the
distinction the pages were using the form to make. Ten of the twelve were not
found by any reader: they were found by turning the assertion on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…entrance anybody got

`Animations.css` answers reduced motion per animation, and for the two entrances
it does it by redefining their keyframes inside
`@media (prefers-reduced-motion: reduce)` rather than disabling the animation, so
`arena-pop` and `arena-menu` keep the fade and drop the travel. The file says so
in its header and the Tailwind README points there rather than restating it.

`keyframesIn(css)` collected `@keyframes` with a flat expression and emitted each
block bare. The two inside the media query came out at the top level, in source
order, after the pair they were written to override. Later and unguarded wins, so
every reader got the reduced variant: `ArenaDialog` and `ArenaMenu` appeared with
no travel for everyone, and `prefers-reduced-motion` appeared nowhere in the
shipped prelude at all.

The extractor keeps the at-rule around a keyframe now, since the condition is the
whole of what that block says. `preludeProblems(base)` gains the two assertions
that would have caught it from either side: a keyframe declared a second time at
the top level, which is the defect stated as a rule, and a keyframe
`Animations.css` declares that the prelude does not carry, which is the failure
mode of getting the extraction wrong in the other direction. The second one
caught this fix's own first draft, which dropped all eight top-level blocks and
left the build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y following a documented Do

The audit is the only automatic signal a consumer has, so a finding they cannot
act on costs more than a missing one: it teaches them the report is noise.

The router-link rule fired on any link whose first child was an Arena component
and told the reader to pass the href to the component and route from the event it
reports. Four components declare an href. For every other one that advice is
impossible, and `ArenaAppBar` documents wrapping its brand in a link of your own
as a **Do**, so the page and the gate disagreed on the same markup. `LINKABLE_TAGS`
is the set whose contract takes an href, derived in the suite from
`contracts/api/components` because the audit ships inside the package and cannot
read them from there.

An element carrying `headingLevel` was dropped from the rung set entirely, so
stating the level you meant removed you from the outline check and could open the
gap the check exists to find. A stated level is now the rung it states, and only
a bound expression the audit cannot read falls back to being skipped. The rule
got stricter as well as fairer: an h1 followed by a stated h3 is a real gap it
could not see before.

`JSX_OPEN` read `useState<ArenaTableSort>` as a tag. Paired with the type-only
import of the same name, both halves of the "imported and opened" test agreed and
the run reported a type as a component the package does not ship, on every build,
with `--strict=components` failing on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`useArenaDialogModal` remembers what to return focus to by reading
`document.activeElement` when `open` turns true. React's development build mounts
an effect, unmounts it and mounts it again, and by the second pass the first has
already moved focus into the panel. So the element recorded as the invoker was
the panel's first focusable, and Escape returned focus to a control that was
going away, leaving it on `<body>`.

The invoker is remembered once per open now, and cleared on close so the next
open reads fresh. The suite drives a real StrictMode tree and asserts focus lands
back on the button that opened it; with the guard removed it fails on that
assertion.

This is the export the register page tells a consumer to take instead of writing
a focus trap of their own, so the failure was worst exactly where the advice was
followed. A cold walk reported it as a development-only oddity it had not
observed; it is observable, and it is now held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dim` said "the wordmark's second half, drawn muted". Two independent cold walks
read it as a tagline sitting beside the name and shipped a bar reading
ColdwalkOn-call, because the two halves are set with nothing between them and
nothing said so. The description now says where the join is and gives the shape
of the split, so the reader who has a one-word product name learns that this
member is not for their tagline.

`mobileLayout: 'block'` said "full width with no label". The box is full width
and its contents sit at the end of it, which is the shape an actions cell wants
and the wrong one for a long value: two walks put a sentence there and got the
whole gap on its leading side. The description says what the strip does with what
is in it, so the member is chosen for the case it serves.

Both are layer-neutral, so each reaches the generated types and the prompt in
both layers from the one declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the glyph in

Tailwind compiles an opacity modifier to the colour at full strength with the mix
behind an `@supports`, which is progressive enhancement everywhere except where
the wash washes the INK'S own colour. Thirteen rules are drawn that way: the
pressed `ArenaIconButton`, six `ArenaBadge` tones, `ArenaCommandPalette`'s active
row, `ArenaSideNav`'s active item and the three destructive hovers. Wherever
color-mix does not resolve, each of them is a solid block with nothing in it, and
the four that are a resting state are that permanently rather than under a
pointer.

`dropBlindFallbacks` takes that half out at build time. With no fallback the
background is invalid at computed-value time and simply does not paint, which
leaves the ink on the surface it was measured against; nothing is lost where
color-mix resolves, so no engine that supports it sees a change.

`mergeSupports` is the second half, and the exposure there belongs to a bundler.
`bun build` keeps the FIRST block of a repeated condition and discards the rest,
measured on Bun 1.3.14, and it does so between siblings inside a `@media` as well
as inside one rule. A rule stating its condition once survives bundling whole.
The merge goes into the LAST of a group, which is what makes it safe, and refuses
any move a plain declaration of the same property would start losing to; a merge
that cannot be proven harmless is not made.

`check:component-css` holds both over the emitted sheets. `check:packages` gains
a fifth claim over the assembled `dist/` of both packages, which is the only
honest subject for this: what a consumer installs is the bundled sheet and not an
intermediate. It reads 143 stylesheets and a walk that finds none is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nch of this repository

`check:community` reported `.claude/RESUME.md` as a contributor document Context7
does not exclude, and `check:vocabulary` read the same tree. That directory is
git-ignored and written by the agent harness rather than by anybody working here,
so the finding is about a file the repository does not own and cannot fix, and it
appears only while a session is running: a fresh clone is green and the same
command goes red under an agent.

`.claude` joins `node_modules` and `.git` in `SKIPPED_ANYWHERE`, in both gates
that walk the whole tree, and both paired suites assert the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…from the branch they were on

Four of six walks wrote a `display: flex` with a gap of their own for a short
horizontal strip inside an app bar, a mark beside a product name or two links in
a bar. `.arena-row` was described as "a wrapping line", and a two element lock-up
does not read as one, so the sentence covered the stacked cell and left out the
case that actually comes up. The row is stated as the horizontal half of the
three steps now, with the misses named, and that it wraps when the line runs out
is a property it has rather than the test for whether it applies.

One walk refused to put a class of its own on `<arena-main>`, which is right, and
wrote no wrapper, which leaves `.arena-shell__main` on nothing and the shell
distributing its slack to an element that cannot hold it. No document said what
to do when the child that must grow IS a component. Both pages say it, and the
rule is declared at the thing as well, in each layer's `ArenaMain.prompt.md`,
worded per layer: Angular takes the host out of layout and React accepts no
`className`.

`--gutter` was applied flat at every width, so 88px either side of a 430px
viewport left a 254px column and two fifths of the screen was margin. `.arena-band`
holds it to `min(var(--gutter), 7%)`. The share is what the authored pair already
implies, 88px of 1240px, so the band reaches its full gutter exactly where the
column reaches its full width and holds that proportion below it; at or above the
page width the `min()` never binds and nothing a desktop page draws moves. It is
a ceiling rather than a clamp because a style plugin answering a smaller gutter
has said a smaller gutter.

The React npm page showed three `import './*.generated.css'` lines and never said
that a `.css` specifier resolves to no module, so a strict build stops at the file
that wires Arena in. Three walks wrote a declaration file of their own to get past
it. The page names the declaration most toolchains already ship and gives the two
lines for a project whose toolchain ships none, and says why neither is Arena's to
ship: an ambient `*.css` is global to whoever compiles it.

The viewer section opened by saying every value it needs is already a token, and
the one value that is not went unsaid, so walks sized the picture with a chosen
`vh`. A fixed layer at `inset: 0` needs no length at all, and the unit that
follows a phone's browser chrome is `dvh`, which is what the chosen number was
compensating for. There is no role because there is no decision.

Three route budgets are raised with what each measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ves were a dead link

`style-kernel.md` tells a reader to read a worked style plugin before writing
one, and links `plugin-style-store/default/` and `complete/` by a path beside
itself. Both walks that entered through `llms.txt` followed it and got a 404: the
site copies `contracts/` and `intro/` and never copied that directory, so the one
instruction the page calls the hardest was the one with nothing behind it. The
tree is published now, which makes the link right from a clone, a package and the
site at once.

Nothing was holding that. `check:site` read `href` and `src` out of every emitted
HTML page and never opened a markdown document, and markdown is what the corpus
hands an agent: those documents are read through their links and nothing renders
them, so a path correct in a clone and absent from the output fails silently at
exactly the sentence saying to go and read something. It reads all 178 of them
now, and it found three more: the router's route to the contributor branch, and
`StylePlugins.md` reaching `CHARTS.md` and `frameworks/tailwind/AGENTS.md`, and
`intro/AGENTS.md` reaching `DOUBTS.md`. Each of those names a document that is
the repository's and reaches nothing a package or the site carries, so each is
named by its URL, which answers from everywhere the document is read.

Publishing the AGENTS files those pages reach was the other exit and it is the
wrong one: a contributor's front door standing on the consumer's site opens a
route into a tree the site does not carry, and the consumer branch reaches
`contracts/design/AGENTS.md` on purpose, so a blanket skip breaks a link the
router means.

One route budget is raised for the router's own sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JCarlosHidalgo
JCarlosHidalgo merged commit 32f2366 into main Aug 18, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant