Skip to content

Research 026 — Painted section and panel ​

Status: complete

Verified on 2026-08-19, against branch 026-painted-section at spec commit, @base-ui/react 1.6.0, Panda 1.11.5, Storybook 10.5.8, Chrome 151.0.7922.71 (headless, which reports prefers-color-scheme: dark). Measurements were taken against the spike story paint/spike-PaintedSection, which is deleted before merge (R14) — the numbers survive, the code does not.

V1 — Is the ground past the band's reach clean for every band parameter? ​

Question. R6 and SC4 claim the multiplicative dissolve leaves the region past reach — where the meta text and the chevron sit — with no paint, for any value of the other six band parameters. Visual inspection cannot establish "for any value"; only a measurement over the parameter space can.

Verdict. Confirmed. Every parameter except reach leaves that region byte-identical to a control rendered with the band switched off. reach extends the paint, which is its purpose.

Evidence. Storybook story rendered headless at 760×300, cropping the 150×70 region at x=555 (the meta and chevron; the band's own reach ends near x=519). Each render's crop hashed and compared with a control at opacity:0;base:0;rule:0:

control (rendered twice)                                     identical  <- pipeline is reproducible
defaults                                                     identical
opacity:1;base:0.6;rule:1                                    identical
breakup:1;brush:1;wobble:8                                   identical
breakup:0;brush:0;wobble:0                                   identical
seed:42;opacity:1;rule:1                                     identical
pad:18;opacity:1;base:0.6                    (vs pad:18 control)  identical
pad:0;opacity:1;base:0.6;rule:1;breakup:1;brush:1;wobble:8   identical
reach:85 / reach:95 / reach:100                              DIFFERENT  <- negative control

The negative control is what makes the rest meaningful: the method can detect paint in that region, so "identical" means "nothing was painted" rather than "the crop was in the wrong place".

Second measurement, against the shipped component (T7, 2026-08-19). The sweep above measured the spike. Re-run against PaintedSection as it ships, story paint/PaintedSection → Default, headless at 800×260. Two crops: the meta/chevron region at x=450 w=170 y=80 h=45 (past the band's reach, which ends near x=390), and a left-band region at x=80 w=220 y=80 h=45 where the band certainly paints. The control suppresses the band at source (BAND with opacity, rule and base at 0); the negative control sets reach: 100.

Rendermeta/chevron cropleft-band crop
shippede5a17fc8…d2c5aec0…
control (band suppressed)e5a17fc8… — identicaldc5e3953… — differs
negative control (reach: 100)b7669e7b… — differs97a81e96… — differs
source restorede5a17fc8… — identical—

The three rows together are the argument, and no two of them would be enough:

  • shipped = control past the reach → the band contributes nothing where the meta and chevron sit.
  • shipped ≠ control on the left → the control's mutation actually took effect, so the row above is not two renders of the same unchanged page.
  • negative control ≠ shipped past the reach → the crop is in the right place and the method can see paint when there is paint to see.

SC4 holds for the component, not just for the spike it grew from.

Caveat. Two false results were produced on the way, and both would have been reported as findings if the controls had been weaker:

  1. The first sweep ran against a stale Storybook on port 6006 serving the other working tree, where the spike story does not exist. Every screenshot was Storybook's "couldn't find story" error page, so all crops matched — including what should have been the negative control. The worktree's own server had silently fallen back to port 6007. A negative control that passes is the signal that the harness, not the subject, is being measured.
  2. pad initially read as PAINT. It is not paint: pad drives the trigger's vertical margins, so the whole row shifts down and the fixed crop catches moved text. Against a pad-matched control it is identical. A parameter that moves the subject needs a control that moves with it.

V2 — Does text.muted inside a painted scope resolve to that theme's textDim? ​

Question. R8 drops the spike's --paint-text-dim stub and claims the section's secondary ink needs no new token, because 025's theme scope already re-points --colors-text-muted. This has to hold through the content slot, which sets color: var(--paint-text) on the subtree — an inherited value that an element setting its own colour must beat.

Verdict. Confirmed, exactly. No new token and no new --paint-* custom property.

Evidence. The generated stylesheet (apps/web/dist/assets/index-BnC0HJiJ.css, from pnpm --filter @gw2priory/web build) carries both halves:

.painted-surface__root--theme_Bark{ … --colors-text-muted:var(--colors-paint--bark-text-dim); … }
.c_text\.muted{color:var(--colors-text-muted)}
--colors-paint--bark-text-dim:#ac9a7c;

Computed colours measured in headless Chrome against that same stylesheet:

ElementComputedIs
text.muted, no painted scopergb(209,213,219)gray.300 — the token's _osDark value
unclassed p inside a Bark scopergb(226,212,184)paint.Bark.text — the content slot's ink (025 R3)
text.muted inside a Bark scopergb(172,154,124)#ac9a7c = paint.Bark.textDim
text.muted inside a Deep scopergb(132,150,168)#8496a8 = paint.Deep.textDim

The third and fourth rows are the answer: the element's own token wins over the inherited ink, and it re-tints per theme.

Caveat. Measured against the built stylesheet in a standalone page, not against the running app. It establishes the cascade, not that the section's markup applies the class — that is an implementation test.

V3 — Does a panel of ten sections issue at most three band images? ​

Question. SC2's cap, and R7's mechanism: seed chosen by hashing the section's title, modulo three.

Verdict. Confirmed for the cap, with a caveat on the distribution that is a design decision, not a measurement error. See Open decisions below.

Evidence. The band image is a pure function of (theme, band parameters, seed, pad). Everything but seed is a module constant (R4), so distinct images = distinct seeds, and the seed is hash(title) % 3 — three values by construction, whatever the section count. Running the reference's hash over the thirteen real material-storage section names:

distinct seeds       = 3  (cap holds for 13 sections)
bucket counts        = 7 / 5 / 1
adjacent collisions  = 4 of 12 pairs

Caveat. The cap is what SC2 claims and it holds. But the spread is poor: one pattern takes seven of thirteen sections, another takes one, and four neighbouring pairs share a band. Hashing a title cannot see its neighbours, so ~1/3 adjacent repeats is the expected rate, not bad luck. If the goal is "no two adjacent sections look alike", title-hashing cannot deliver it at any quality of hash.

F4 — The rendered structure, and what Base UI omits when closed ​

Evidence. DOM dumped from the running story:

html
<h3 class="ps-h"><button type="button" data-panel-open="" aria-controls="base-ui-_r_1_"
    aria-expanded="true" class="ps-trigger">…</button></h3>
<div class="ps-panel" style="--collapsible-panel-height: auto; --collapsible-panel-width: auto;">

h3 > button is valid (a button takes phrasing content), which is what R9's heading decision rests on. The closed section's trigger carries aria-expanded="false" and no aria-controls — Base UI omits it while the panel is unmounted rather than pointing at a missing id.

Why it matters. R2 and R9. A test asserting aria-controls unconditionally would fail on a closed section; the correct assertion is aria-expanded, plus aria-controls only when open.

F5 — The panel's transition needs a rule the spike does not have ​

Evidence. Base UI's own documented panel CSS (@base-ui/react/docs/react/components/collapsible.md:154):

css
height: var(--collapsible-panel-height);
overflow: hidden;
transition: height 150ms ease-out;
&[hidden]:not([hidden='until-found']) { display: none; }
&[data-starting-style], &[data-ending-style] { height: 0; }

The spike has every line of this except the [hidden] rule. --collapsible-panel-height reads auto in the dumped DOM because that is its resting value; Base UI substitutes a measured pixel height while the panel transitions, which is what makes transition: height animate at all.

Why it matters. R2. The implementation copies the documented shape including the [hidden] guard — without it, a panel that sets its own display would stay visible when closed. The spike gets away with it only because .ps-panel sets no display, leaving the user-agent's [hidden] rule in force.

F6 — Accordion was rejected on a fact, not a preference ​

Evidence. @base-ui/react/accordion/root/AccordionRoot.d.ts:31 marks orientation @deprecated: "Deprecated following the APG guidance update to remove roving focus. This state no longer affects keyboard focus behavior."

Why it matters. The Assumptions section. Accordion's historical advantage over Collapsible was roving arrow-key navigation between headers. In 1.6.0 that is gone, so it would contribute only shared value state — which R3 explicitly does not want.

F7 — A correction to an earlier claim about pnpm lint ​

Evidence. pnpm lint on this branch exits 0 (7 warnings, 15 infos, no errors), with .vitepress/theme/custom.css:33's !important present and unchanged since commit a7cfe92.

Why it matters. During the spike I reported a pre-existing lint failure on main caused by that !important. That was wrong: the run predated formatting the spike story, so the error was the spike's own import order, and the truncated diagnostic list displayed the unrelated !important warning. There is no pre-existing lint failure. noImportantStyles is a warning here, not an error.

Open decisions ​

D1 — R7's seed mechanism (from V3). Settled: cycle by position. The human chose position-cycling over title-hashing on 2026-08-19. PaintedPanel.Column walks its children with Children.map and supplies each index through context; bands run 0, 1, 2, 0, … — even by construction, never repeating a neighbour, still capped at three rasters. A section outside a Column takes the first band.

The two rejected options, for the record: title-hashing (bounded and pure, but the measured 7/5/1 spread and four adjacent repeats are inherent — a hash cannot see its neighbours), and one band (025's rule unamended, one raster, every section identical).

Consequence to carry into the plan: the cycle counts a Column's direct children, so sections wrapped in intervening markup fall back to the first band. R7 states this as an API constraint rather than hiding it.

Refuted claims ​

None. V1 and V2 confirmed as written; V3's criterion (SC2) is confirmed, and the caveat concerns R7's rationale rather than a claim the spec makes. No entry here sends the spec back to step 1 — but D1 is a decision the human should settle before the gate opens.

Graduation ​

Done at step 6 (T8): all three moved into docs/architecture/design-system.md, under Painted sections and panels (spec 026) — together with two findings that arrived after this file was first written: the surface publishing BandContext (the defect T6 exposed), and Base UI's [hidden] rule plus its omission of aria-controls on a closed panel.

The candidates were:

  • The dissolve is multiplicative, and why — belongs in design-system.md beside the painted-surface notes: an additive ramp cannot produce a clean end, and the measurement method in V1 (crop-hash against a band-off control, with a negative control) is the way to test any future paint claim.
  • text.muted is the paint-aware secondary ink (V2) — a one-line rule for design-system.md: inside a painted scope, ask for the semantic token; do not add --paint-* properties for roles the scope already re-points.
  • Base UI omits aria-controls while a panel is closed (F4) — a testing note for react.md.