-
Notifications
You must be signed in to change notification settings - Fork 0
Rulings
Taste questions the loop cannot settle, and what was decided.
A ruling is a question only Cindy can answer — tier (prop vs hook vs provider vs primitive), naming, whether surface earns its permanence, whether we want a capability at all. Everything verifiable is the loop's own job (R22); a question that reaches this page must already have the browser work done.
Both runs ask, both runs apply. Cindy, 2026-08-23: "You can ask me twice a day but keep rulings running in case I can't get to them."
- Every run puts open rulings in the last block of its report — chat scrolls up, so the final block is the one that gets read.
- Cindy replies in that thread:
1c 2a, partial fine, any order. Replying is never expected. A run that asks and gets no answer is normal, not a stall. - Every run's first job: read this page, match any replies since the last run, apply and ratify — the answer becomes a rule in Critic Rules or a line on the relevant page, so the question is asked once, ever. Then unblock what was waiting and say so.
The queue never blocks the work. An unanswered ruling holds that PR, not the run — everything else proceeds. Twelve unanswered questions means twelve parked PRs and a normal night otherwise.
Ask at most 3 new per run and carry the rest. Two runs a day is two chances to be seen, not double the questions. The cap is per run because it is a limit on what a person can absorb in one sitting, and the escalation clock below is in days, not runs, for the same reason.
Escalation is by age in DAYS, not by how many times it was shown. 2 days → mark stale · 3 days → the loop takes its own recommendation, applies it, and records that it did. Being shown six times in three days is not six chances ignored; it is one question that aged out, and the loop resolves it rather than asking a seventh time.
One entry per QUESTION, not per PR. "Which tier does this belong in" arriving from a review, the gap loop and the nightly audit is one entry with three blocked things — that is what earns it her attention.
Every entry names a recommendation. An entry without one is not ready to ask.
Every blocked PR below re-checked against GitHub 2026-08-24 05:00 UTC — all still open, all still blocked for the reason stated.
5. treegrid v1: row focus, or full cell navigation? Blocks #4690 (AKnassa)
This one has a clock on it — the author was told it is queued, after already waiting 10 days.
- a row focus for v1, cells later — ships now, a second migration later
- b full cell navigation now — correct against the ARIA pattern, larger, and the author has not signed up for it
- c row focus, and say plainly in the docs that it is v1 ← recommended, ships the accessible thing without promising the rest
Raised 2026-08-23 by the batch-1 aged sweep. #4690 still open at 2026-08-24 05:00 UTC — the clock is still running.
7. Does Markdown's rendered DOM carry what the SOURCE said? — WITHDRAWN, 2026-08-23
Asked, then dissolved by asking a better question of the same PR. Kept because the dissolution is the useful part.
The question assumed #5288 posed one problem. It posed two, and separating them removed the ruling:
-
Rendering.
cellAlignStyles(Markdown.tsx:238) has onlycenterandend— nostart— so| :--- |renders identically to| --- |. Fine in LTR by accident; wrong in RTL, where an explicitly left-aligned column goes right. A real bug, no consumer needed, and the PR does not fix it. → R3j -
Introspection. Reading the source back out of the DOM. Real need, but not
a guarantee to bury in Markdown's contract undisclosed — and
componentsalready hands renderers typed values at render time (headingreceives a generatedidfor exactly this). It has notable/tableCell/listentry; that gap is the actual finding.
Cindy: "The former is something to fix, the latter is not a guarantee in our contract a test should rely on."
#5288 is still open with that request-changes standing (2026-08-23 23:08 UTC). Posted as request-changes asking which problem is being solved. What may still
need a ruling later, and only if lexs says components does not serve him: do we
owe a round-trip path at all? Much smaller than the original question.
8. Is a menu divider's vertical rhythm a theme value, or spacing the menu owns? Cost #4667 and #4743 their subject — asked in round one of #4743, never answered, and both PRs deleted the contested part rather than wait. No divider fix has shipped from either.
Cindy, on #4743: "I'm working through a rule I haven't written into the wiki yet, so treat this as direction rather than settled policy: layout properties encode a component's structure, so they shouldn't become theme seams. … I said on #4667 that a themable divider seemed okay to me, and I still lean that way for its paint. Margin is the part I want to be deliberate about."
- a paint is themable, layout is not — a divider's colour and thickness are theme values, its margin is the menu's ← recommended, and it is her own lean written as a rule
- b margin too, as a public var — what #4743 originally proposed
- c neither; the menu owns the whole divider
Raised 2026-08-23 out of the #4743 retraction. Not attached to that PR — it is merged. Answering it also settles the general rule, which is what the two dead attempts were really waiting on.
9. Six layers still own their own Escape — one integration branch, or six tracked PRs? Blocks nothing today · ruling 6 answered its parent, not this half
#4881 merged whole on 2026-08-24 without them, so "does it land as one piece" was answered incrementally — by events, not by choice. Six layers still own their own Escape: BottomSheet, BottomSheetSwitcher, CommandPalette, ContextMenu, DropdownMenuSubMenu, PowerSearchEditPopover. A stacked layer inside an unstacked one is the double-dismiss the stack exists to fix (mechanism named, still not measured), and #5322 put a hand-rolled IME guard into BottomSheet, one of the six.
- a six PRs, one per layer, ordered by risk and tracked on this page ← recommended — the stack has already shipped, so an integration branch has nothing left to integrate against
- b one integration branch (R26b) carrying all six — one migration for consumers, one large review
- c migrate a layer when it breaks — the double-dismiss stays live and gets found by a user
Raised 2026-08-24 out of ruling 6's unanswered second half. Nothing on GitHub tracks the six today — #3343 is the umbrella program, #5168 the nearest live bug. It blocks nothing today, so it is not a release block — this page is its record until it is answered.
10. Is the ChatComposerDrawer's collapsed count a chip, or plain text? Blocks #5399 (athz, +19/-3)
The PR swaps the collapsed Badge for {count} {label} in one span. The design
half is genuinely open — Design Conventions has no rule on count indicators, and
its Design-vs-Structure table only says a change of this kind is Core, i.e.
who owns it, not what the answer is. No design owner has looked.
The mechanism half is not open and is already a block: rubric §9 I6 forbids
JSX-composed interpolation, and the composed form renders "1 Items" at
count={1}, "1 User feedback requested" in the shipped Feedback story, "3 项目"
where zh-CN ships "3 个项目", and "1234" with no grouping. Driven in Chromium,
frames on assets/pr-5399.
The part that makes this a ruling rather than a fix: label is a free-form
consumer prop, so no catalog key of the shape {count} {label} can be correct —
the plural lives in the noun the consumer handed us. Plain text is not
under-implemented here; it has no correct spelling.
-
a keep the chip; if the ask is weight, that is a theme call on
.astryx-badge← recommended — costs nothing in grammar and keeps the only theme handle the count had, which this PR removes -
b plain text, and the drawer stops taking a free-form
label— it takes a catalog key or an ICU-shaped pair, which is new API and its own decision - c plain text as written, accepting "1 Items" and no measure word in CJK
-
d plain text only when
countis absent, chip otherwise — two collapsed treatments to design and document
Raised 2026-08-24 by the review of #5399. Exit state is held for a ruling, not a nudge: the next mover is Cindy, not athz, and the review says so in as many words. The GitHub state will read CHANGES_REQUESTED because of the I6 block, which is separate and stands whatever the design answer is.
12. Which tier owns a header's block-end dock? — ANSWERED 2026-08-26. LayoutHeader owns it.
"using the tablist, the underline is in the right place so it's an improvement over the current one." — Cindy
The ownership split is ratified:
-
TabList owns inline full bleed. Its existing
isFullBleedimplementation is accepted; it reaches the inline edge and compensates labels from the container variables. There is no remaining choice about the prop or whether the behavior is an improvement. -
LayoutHeader owns the block-end dock. Add
paddingBlockEnd?: SpacingStepin the per-edge padding family already published bySection, then usepaddingBlockEnd={0}ondetail-page. The wholetabsRowobject andstyle={tabsRow}disappear; no replacement constant remains.
This is the smallest general owner because LayoutHeader publishes the block padding
and paints the divider. The driven arm preserves all five targets at both tested
header paddings; the exact-head marginBottom: -16 misses the divider by 7.00px at
padding={6}, while the LayoutHeader arm remains docked at −1.00px. The arm also
passes the 0/8/16/24px, RTL, hug/fill, trailing TabMenu, and prop-false matrix.
The alternatives are closed: a bottom slot adds a new concept and gap rule; a
TabList-owned block margin cannot see the header geometry and moves the ghost; the
existing Section seam restates the magic number; keeping template CSS contradicts
ruling 4.
The remaining work on #3938 is implementation, not design: land the LayoutHeader prop and zero-CSS template usage, and move the geometry assertion into a required Chromium CI path. Full measurements, frames, and exact arm diff are in Review 3938 round 3b.
Ratified 2026-08-26 by Cindy after reviewing the current-head and LayoutHeader-arm
redline. LayoutFooter.paddingBlockStart remains outside this PR's scope.
3. isEdgeAligned on List — ANSWERED 2026-08-25. It is isFullBleed, and it needs a clamp.
"Full bleed as a component prop is okay. It cancels out container padding. List items are weird though. We technically cancel list item padding but only up to container padding." … "I dislike adding another prop naming convention." … "Maybe we need to rename the whole thing but I don't want to do breaking changes like that yet." — Cindy
One prop name across the family: isFullBleed. Read at the CONTENT level,
not the box level, it means the same thing everywhere — your content reaches
the container's content edge:
| component | what moves | amount |
|---|---|---|
| Divider | the line | the container's full padding (no inset of its own) |
| List | the row label | its own 8px inset, clamped |
| TabList | the labels, and the underline to the divider | its own inset, clamped |
The amount differs per component; the promise does not. Additive, no rename of anything shipped, no breaking change — which is the constraint.
THE CLAMP, and it is the part #4590 is missing:
marginInline: calc(-1 * min(var(--_item-inset-inline), var(--container-padding-inline-start, 0px)))
Cancel the component's own inset only up to the container's padding. #4590 today cancels the inset outright. In a Card that is 8 against 12, so the clamp never bites and it looks right — but in a popover (12px) or a 0px full-bleed area it over-pulls and the rows hang outside the content edge.
The clamp also makes the design doc's special case disappear: "Except for 0px
full bleed, then just render it normally" falls straight out of
min(inset, 0) = 0. Nothing to special-case.
Source of truth: Layouts and Spacing, "Content spacing compensation". Container padding is 12px for popovers, 16px otherwise, 0px full bleed. Per-component rules: buttons and inputs 8px from the edge in headers/footers/toolbars/corners; lists always −8px matching their inner padding; tables and scroll areas flush.
Noted, not acted on: the doc says lists do this always, where #4590 makes it opt-in. Left opt-in for now — making it default is a behaviour change on shipped surface and this ruling is deliberately additive. Worth revisiting with the rename Cindy says she does not want yet.
4. isFullBleed on TabList — ANSWERED 2026-08-25.
On a component with content of its own, isFullBleed does BOTH halves.
"I don't want breaking the current default behavior." … "The template shouldn't need extra css if we're adding the prop — that's the whole point of adding it." … "Yeah that seems okay to do both actually." — Cindy
The two halves, and why one is not enough:
| what it does | computed from | |
|---|---|---|
| bleed | pulls the box out through the container's padding so the strip spans edge to edge — this is what makes the active-tab underline meet the header divider | calc(-1 * var(--container-padding-inline-start/end)) |
| compensate | pads the first and last tab back in by that same amount, so the label lands on the container's content inset |
max(var(--container-padding-inline-start, <fallback>), --spacing-2) — Table's containerEdgeStyles, table.stylex.ts:73
|
They cancel at the box level and do not at the content level. That gap is the measured 4px: the box moves 16, the label was already inset 12, so it lands 4px past the body text below it.
Divider only ever needs the first half — it is a line, it has no content to align. Copying Divider's prop onto a component that has labels gets you half a feature, which is exactly what #3938 shipped.
The acceptance test is the template. detail-page's tabsRow must lose ALL
its hand-written CSS — not shrink, disappear. A template still carrying a
style={{marginInline: -12}} after the prop lands is the prop failing its
purpose. The -12 is also a magic number: the container publishes 16, and
Section.tsx:66 already shows the right spelling
(calc(-1 * var(--container-padding-inline-start, 0px))).
Everything is computed from the container system vars. No hardcoded amount anywhere — not in the prop, not in the template. That is what makes it correct at any container padding and any density, and it is why the 4px cannot come back.
Not --container-padding-*-blind: a standalone TabList, where those vars are
unset, keeps its current geometry via the fallback. Same shape Table uses so a
standalone table is unchanged.
Ruling 3 stays unpaired from this. Different families: bleed cancels the container's padding; edge compensation cancels a component's own inset. #4590 is the second, and its measurement (12px vs 8px in a Card) is what proved the two are not one question.
Open, for whoever implements it: Toolbar is already a landed member of the
edge-compensation family (edgeCompSlot.inset(...), four call sites) and its own
docs show a TabList inside it. The template hand-rolls <HStack style={tabsRow}>
where a Toolbar may already do this. Worth measuring before building the prop —
if Toolbar solves it, #3938 closes as unnecessary and the template gets simpler.
Answered by Cindy, or answered by what landed. Three moved here on 2026-08-24.
| # | question | ruling | the general rule behind it | now decided without asking | still needs asking |
|---|---|---|---|---|---|
| 1 | Markdown source position — which spelling does the parser take? |
Offsets, opt-in — option a, not the recommended c. #5290 merged 2026-08-23 20:51: range behind {sourceRanges: true}, top-level blocks only, nested children carry none. #5366 reshaped it to {start, end} an hour later. #5154 closed |
When two spellings of one concept compete, the one that keeps the existing invariant wins. Coverage bought with a lost cache is not free coverage. A second unit waits for a consumer the first is shown to fail, and new introspection ships opt-in and absent by default | Any "add a second unit for what we already emit" PR — lines beside offsets, ids beside paths: no, until the first spelling provably fails someone. And any new introspection surface: opt-in, or it is not additive | The nested case — a blockquote or list child carrying a range means threading a base offset through two recursive parses, a design change and not an addition. And whether <Markdown> emits ranges into the DOM at all (#5290 deliberately left data-md-range out): new public surface, R1g-surface, a fresh ruling |
| 2 | The #4163 doc-contract PRs each ship their own hand-written test — keep, or one derived gate? | Option b, and she had already answered it hours earlier: "Docs only good to merge as long as they're correct", then "Docs don't need tests". #4315 · #4317 · #4320 merged 01:24, #4316 at 04:14; #5382 deleted all five tautological tests (+0/−263). The gate was never filed | Docs are graded on correctness, not on coverage. A test written in the same PR as the thing it asserts proves only that the author typed both. Drift is caught by a gate derived from the source, or it is not caught | Every "should this docs PR carry a test" question: land it if it is correct. And a hand-written mirror of the source in any PR is a finding, not a merit | When the doc file is runtime input — consumed by the CLI or codegen — it is code and takes real tests. And who schedules the derived gate: filed 2026-08-24 as #5421 |
| 6 | Does the dismissal stack ship with the hover-layer machinery, or without? |
Option c — shipped whole. #4881 merged 2026-08-24 03:50 as 47d5160d, +3706/−394 across 22 files (the ask said +3173/−346, 18 files). Its changeset is [breaking]/minor, so the cut is 0.5.0, not 0.4.8. Cindy: "just classify it correctly don't try to game the versioning"
|
The changeset category describes what the change does to public meaning; the version follows the category, never the reverse. A published prop whose name is unchanged and whose meaning is not is breaking — no codemod can carry that, so the changeset is the only warning a user gets | Any "can we call this a patch to stay in the line" question: no. Any meaning change to published surface: [breaking], and it lands in the release breaking changes land in |
Surface that is merged but unreleased is not covered — renaming it before the cut is free, and that is a different call. Release timing — which Monday a breaking stack cuts — stays hers |
| 10 | Is the checkbox's label target astryx-checkbox-label or astryx-checkbox-input-label? | astryx-checkbox-label — keep it. Cindy, 2026-08-24: "It's fine." #5183 merged as 75b5af29 with the short spelling. The convention argument was real — the root target is astryx-checkbox-input, <root-target>-<part> holds across all 238 targets on main, and the same doc file deprecates astryx-checkbox three lines above — and it lost anyway | A convention derived by grep is evidence, not a verdict. 238-for-238 establishes what the system does; it does not establish that a shorter name is wrong. Where a target reads unambiguously to a theme author, brevity beats derivational consistency, and the person who owns the system decides which one this is | Any "this name breaks the <root>-<part> pattern" finding on an unreleased target: state it once as a note, never as a hold, and never as the reason a PR does not merge. A name that reads right is right | The twelve remaining #5417 rows are not settled by this — each is its own read, and this ruling says the pattern does not decide them either way. And a target that a theme has already reached is frozen regardless: this only covers naming before first use |
| 11 | A component-scoped themeable glyph — new member of the core IconName union, or a namespaced extension key? | Namespaced, to start. Cindy, 2026-08-24: "Probably namespaced to start" — "for component icons". Raised by #5466 (numberInputStepperDown); the enabling change is #5475, which makes <Icon icon="numberInput:stepperDown">, useIcon and defineTheme({icons}) take the key | The union is the expensive tier, and expense is measured on the people who did not ask for the change. IconRegistry is Record<IconName, ReactNode>, so a union member adds a required key to every downstream registry and a scaffolded theme stops compiling on a patch. A key one component owns goes in the tier that costs nobody else | Any "give component X its own semantic icon name" PR: namespaced key, no IconName member, Core default in defaultIcons under the same key. The 13 other components sharing chevronDown are covered by this without asking again | Whether a glyph is component-scoped at all — a name several components would resolve is a system concept and belongs in the union. And whether the namespaced tier stays the answer once several components use it: "to start" is explicitly provisional |
Reference. Moved here 2026-08-24 from the pre-release debt register, which is now astryx/release_blocks. Ruling 6 is the taste half of this; below is the mechanics.
The changeset category picks the version, never the reverse.
scripts/check-changesets.mjs couples them: a [breaking] changeset must be
minor (0.x.y → 0.(x+1).0), every other category must be patch, and major
is rejected while we are pre-1.0.
Cindy, on exactly that temptation: "just classify it correctly don't try to game the versioning." Stated intent, 2026-08-22: "we're cutting 0.5.0 Monday and that's the release we're landing breaking changes in."
The 0.5.0 cut, as it stood on 2026-08-24. @astryxdesign/core published at
0.4.7 with 29 pending changesets, all patch but two:
#4881 (the dismissal stack) and
#5255 (Banner collapsible),
both [breaking]/minor, both merged that morning. One honest category on one
merged PR is what moved the release — not the six feature-shaped changesets
sitting beside it.
| item | codemod owed? |
|---|---|
MultiSelector.formatTriggerCount rename |
Not if it happens before the cut — the prop is unreleased, so there is nobody to migrate. After the cut: yes, plus a [breaking] changeset. Tracked as kt-eo47
|
Markdown range → any other spelling |
None owed — ruling 1 settled as landed. range publishes as {start, end} offsets; there is no second spelling to migrate to |
Banner defaultIsExpanded → collapsible
|
Shipped. banner-collapsible-content is staged in transforms/next/ and promotes at the cut (verified end to end by simulating the promotion). It skips props-object sites deliberately; the changeset names the spread hole and says to grep — see Review 5255
|
hasActiveFocusTrapEscape meaning change (#4881) |
No codemod can carry it — the name is unchanged and the meaning is not. The [breaking]/minor changeset is the only warning the user gets |
A ruling that only settles one PR was half wasted. Cindy: "We should strive to keep a ledger of those decisions to inform and harden rules so the loop can handle more automatically over time."
So every ratified ruling records what it now lets the loop decide alone. That is the number that matters: not how many questions were answered, but how many future questions stopped existing.
When ratifying, ask three things and write the answers into the row:
- What is the general rule behind this answer? Not "5290 uses offsets" but "one concept gets one spelling; a second spelling needs the first to have landed." A ruling phrased about a PR teaches nothing.
- What class of question does it now close? Name it, so the next run can match against it instead of asking. This is the hardening.
- What would still need asking? The edge the rule does not reach — so the loop knows the boundary and does not over-apply a ruling into ground it never covered. Over-application is the failure mode here: a rule stretched past its evidence is worse than the question it replaced.
The health metric: rulings asked per 10 PRs should fall over time. It was measured at ~1.9 per 10 on 2026-08-23 (feat 62%, fix 0%) — 12 blocked PRs across the 64 reviewed.
2026-08-24: 0.5 per 10. Nine of those twelve came unblocked in one night — ruling 1 (2 PRs), ruling 2 (5), ruling 6 (1), ruling 7 dissolved (1) — leaving three blocked (#4590 · #3938 · #4690) against the same 64.
What moved it was answers landing, not questions going unasked. One new question was raised (ruling 9), and it came out of an answered ruling's unanswered half — which is the honest way for the number to grow. The test of the three Decided rows is not that the number fell tonight; it is whether it stays low because those rows stopped the next questions from being asked. If it climbs back with questions the rows already answer, the ledger has rows and the critic has no new rules.
These predate the page and are recorded so they are not re-litigated. All are in Critic Rules.
| question | ruling | rule |
|---|---|---|
| Does finding another open PR adding the same surface establish the class? | No. A class is made of LANDED members; a draft is weaker than an open PR, and a closed one is evidence of rejection. Mechanical test: are the other members merged? | R22 |
| A team member's in-flight design overlaps a contributor's PR — arbitrate? | No. R6b: the team's system-level solution takes precedence; redirect rather than compare. Authorship changes what an overlap means | R22 × R6b |
Is a direct el.style ref write a defect to be replaced with StyleX? |
No — it may be deliberate for render count. Ask why before proposing the layer. | R7c |
| May styling key off an ARIA attribute? | Never. ARIA is an accessibility contract. Key off theme-exposed state; if it is not exposed, expose it. | R7c |
| Does a new prop always lose to a mechanism change? | No — R1g-surface is a high bar, not a ban. hasRowHighlight was fine. |
R7c |
| Where do competing PRs get resolved? | One review for the set; hold all judgements until the last is reviewed, then post together, winner first. | R6d |
| What does the loop owe a feature PR? | Settle everything verifiable, hand over the shapes and one question. | R22 |