Skip to content

Rulings

Cindy Zhang edited this page Aug 26, 2026 · 41 revisions

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.

How it runs

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."

  1. 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.
  2. 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.
  3. 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.

Open

Every blocked PR below re-checked against GitHub 2026-08-24 05:00 UTC — all still open, all still blocked for the reason stated.

3. isEdgeAligned on List — what do we call it? Blocks #4590 (jiunshinn, 25 days, pushed 2026-08-25)

Restated 2026-08-25 after browser work. The old framing — "a per-component prop vs an existing container-level mechanism", paired with ruling 4 — was wrong, and its option (a) was not an available answer. Measured inside a real Card, where the container vars are actually set:

--container-padding-inline-start   12px   <- what the container mechanism cancels
--_item-inset-inline                8px   <- what the row insets its text by
heading -> row text gap             8px   <- the misalignment #2626 reports

Cancelling 12 cannot produce 8. Redone against --container-padding-inline-* this PR would slide the list 12px out through the card's padding and leave the gap exactly as it is — bug unfixed, list hanging off the card.

Everything else dissolved under measurement: it is the fourth use of the derived-private-var pipeline Theming Infrastructure documents (beside --_field-radius, --_button-radius, --_dropdown-menu-padding, --_avatar-radius), the coupling survives a real defineTheme override at three densities and in RTL, Item is byte-identical two-sided, and API Conventions settles is vs has.

What is left is one word.

  • a isEdgeAligned, as written — merges now; two words in the codebase for two genuinely different mechanisms, and a reader has to learn which is which <- recommended: it is accurate, and Cindy's own #2626 comment said isFullBleed is "not quite accurate" here
  • b spell it in the isFullBleed family — one word for the visual outcome, at the cost of two mechanisms wearing one name, which is what made the loop pair these two PRs wrongly in the first place
  • c hold for a second consumer — costs nothing to defer, leaves our own detail-page template wrong and a 25-day-old PR open indefinitely

Free to change today either way: @astryxdesign/core is 0.4.7 and the prop has never shipped.



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 only center and end — no start — 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 components already hands renderers typed values at render time (heading receives a generated id for exactly this). It has no table/tableCell/list entry; 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-badgerecommended — 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 count is 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.


Decided

4. isFullBleed on TabList — ANSWERED 2026-08-25.

"I don't want breaking the current default behavior. We can add a new prop that works with container padding." — Cindy

isFullBleed means box-at-the-edge, and keeps meaning that. It cancels the container's padding with negative margins, exactly as Divider.isFullBleed does, and it takes on nothing else. #3938 ships as written.

Content alignment is a SEPARATE, future prop — the thing Table does with containerEdgeStyles (table.stylex.ts:73), padding its first and last cells to max(var(--container-padding-inline-start, …), --spacing-2) so cell text lands on the container's content inset. Whoever needs a tab strip both bled and aligned gets a second opt-in prop; they do not get it silently folded into this one.

Consequences, applied:

  • The detail-page template keeps its current alignment — the marginInline: -12 hunk comes back out of the PR. Removing it moved the tab labels 4px outside the content column, measured, in the template the PR ships as its own proof. That is the "breaking the current default behavior" the ruling refuses.
  • isFullBleed does not grow a second responsibility, so the next reader learns one meaning per prop.
  • The 4px case is not lost: it becomes the motivating consumer for the new prop whenever someone needs it.

The standing rule this establishes: bleeding the box and aligning the content are different amounts and get different props. A prop that cancels a container's padding never also compensates a component's own inset.

And ruling 3 is unpaired from this. They are different families — bleed cancels the container's padding, edge compensation cancels a component's own. Treating them as one question is what produced a recommendation to redo #3938 against a mechanism that structurally cannot see it (edgeCompensation.stylex.ts needs a direct-child :has() opt-in that no Layout container implements).


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 |

How the version is decided

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.

Codemods owed

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 owedruling 1 settled as landed. range publishes as {start, end} offsets; there is no second spelling to migrate to
Banner defaultIsExpandedcollapsible 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

What each ruling bought

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:

  1. 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.
  2. 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.
  3. 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.

Rulings already made in-session

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

Clone this wiki locally