Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318

Closed

Description

@xuyushun441-sys

#3306 落地时按裁决补了「每个注册 widget 校验失败必须携带 aria-invalid」的全注册表守卫(packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx,范式同 #3291 的 DOM leak sweep)。真实表单 + 真实 react-hook-form 校验失败(required + null,presence 语义,cloud#972)、先自证 "is required" 消息已渲染再读 aria。实测 46 个注册类型中 29 个在整行 DOM 里找不到任何 aria-invalid="true" —— 视觉上有红字报错,屏幕阅读器完全收不到字段处于错误状态:

multiselect, radio, checkboxes, tags, lookup, master_detail, file, image,
location, formula, summary, auto_number, user, owner, object, vector, grid,
color, slider, rating, code, avatar, address, geolocation, signature, qrcode,
object-ref, filter-condition, recipient-picker

(通过的 17 个:text / textarea / number / boolean / select(#3306 修复)/ date / datetime / time / email / phone / url / currency / percent / password / markdown / html / richtext。)

成因(与 #3222 / #3306 同一失败类,两种形态)

  • 大多数:widget 根本不把任何 DOM pass-through(含 <FormControl> Slot 注入的 aria-invalid / aria-describedby)转发到它渲染的真实控件上——没有 toDomProps spread,也不读 error 槽位。
  • 少数(如 grid):内部有自己的 per-cell aria-invalid,但表单级失败不驱动它。

账本与棘轮

这 29 个类型登记在守卫文件的 NOT_YET_DELIVERED ledger 里,且按反方向断言(sweep 证明它们仍未送达):修好任何一个,它的 ledger 行会变红,强制从账本移除、转入正向守卫。账本只能收缩;新 widget 不能悄悄进入账本(新增类型默认走正向断言)。

修法(每个 widget 同一范式)

按 #3222/#3306 的纪律:把 toDomProps(props) spread 到 widget 的真实可聚焦控件(注意 Radix 非 DOM Root 会吞掉 aria-*,见 #3306 的 SelectTrigger 处理),并在 spread 之后写 aria-invalid={!!error}(error 是 spec FieldWidgetPropsSchema 的发布槽位)。消息文本仍归 <FormMessage/>,widget 只置状态。可分批处理,每批从 ledger 删除对应条目。

参考


Generated by Claude Code

Activity

  1. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    ContributorAuthor

    认领 — session session_01NVPjPzmmAJ2Ngtvgg5MSRa,分支 claude/issue-3318-widget-aria-invalid-delivery。

    PM 裁决(切片策略):单 PR 尽量多交付,账本兜底诚实性——

    1. 逐个按 field:select 从不向辅助技术播报校验状态 —— aria-invalid / aria-describedby / aria-required 被 Radix Select.Root 静默丢弃 #3306 范式修:toDomProps(props) spread 到真实可聚焦控件(Radix 非 DOM Root 吞 aria-* 的,参照 SelectTrigger 处理),spread 后显式 aria-invalid={!!error};消息文本仍归 FieldMessage,widget 只置状态。
    2. 每修一个,其 NOT_YET_DELIVERED 账本行会变红 → 从账本移除、转入正向守卫(守卫结构已保证这一步不可跳过)。
    3. 允许部分交付:结构复杂、无法干净套范式的(如 grid 的表单级失败驱动、组合型 widget),留在账本并在 PR 里逐个写明原因;issue 保持 open 直到账本清零。宁可留账本行,不可为凑数放宽范式(不得把 aria-invalid 挂到非可聚焦包装层上充数)。
    4. changeset:@object-ui/fields(及涉及的 plugin 包)patch,fixed group,绝不 major。

    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    ContributorAuthor

    交付批次 PR:#3345(draft)—— 账本 29 → 9。

    已交付(20):multiselect radio checkboxes tags lookup master_detail user owner file image location object color rating code avatar address geolocation qrcode object-ref。

    账本残留(9,逐项原因见 PR 与账本注释本体):formula summary auto_number vector(无可聚焦控件)、grid(表单级失败驱动 per-cell 需设计)、slider(thumb 在 No-Touch 的 ui/slider.tsx 内)、signature(canvas 无键盘路径)、filter-condition recipient-picker(门控态无控件;可编辑态已交付)。

    issue 保持 open 直至账本清零。顺手发现另立:#3342 #3343 #3344。


    Generated by Claude Code


    Generated by Claude Code

  3. added 2 commits that reference this issue on Aug 4, 2026
  4. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    Contributor

    分诊:pm:queue(objectui 仓内单,⛔ 按 #5468 规则不打 domain:*)。

    ⚠️ 过时前提检查 —— 正文的账本读数已过时,请勿按 29 个的规模派发。对 objectui origin/main(84bd39e)实读 packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx:196,NOT_YET_DELIVERED 现有 9 条,不是正文写的 29 条:

    formula, summary, auto_number, vector, grid, slider, signature,
    filter-condition, recipient-picker
    

    即 07-04 那批 PR #3345 已合入 main(20 个已交付:multiselect / radio / checkboxes / tags / lookup / master_detail / user / owner / file / image / location / object / color / rating / code / avatar / address / geolocation / qrcode / object-ref),与 08-04 05:39Z 的交付评论一致。剩余范围 = 9 条,单条成本与整单的「一次修完」形态都已改变。棘轮机制本身仍在(:289 的 DELIVERING = ALL_TYPES.filter(t => !NOT_YET_DELIVERED.has(t))),账本只能收缩的保证未被削弱。

    判 pm:queue 的理由:剩余 9 条各自有明确落点与已裁定的修法范式(toDomProps(props) spread 到真实可聚焦控件 + aria-invalid={!!error}),账本注释里逐条写了残留原因,机械可验(改一个,它的账本行变红,强制转正向守卫)。属具体缺陷,不是观察类。

    ⚠️ 但残留 9 条里有一半是「修法不成立」而非「还没修」,建议承接座位先分流再派发:

    • formula / summary / auto_number / vector —— 账本注明无可聚焦控件(只读/派生展示面)。「无可聚焦控件的只读 widget 是否应当携带 aria-invalid」是个 a11y 语义问题,答案很可能是否;若如此,它们该从「未交付」改判为「不适用」并在账本里换一个语义位,而不是永远挂在待交付账本上等一个不该发生的交付;
    • slider(thumb 在 No-Touch 的 ui/slider.tsx 内)、signature(canvas 无键盘路径)、grid(表单级失败驱动 per-cell 需设计)—— 需要设计,不是套范式;
    • filter-condition / recipient-picker —— 账本注明可编辑态已交付,残留的只是门控态无控件,同上一类。

    ⇒ 建议:承接座位先就「不适用 vs 待交付」这条分流线做一次判定(必要时上决策箱),再把真正可套范式的那几条派出去。⛔ 分诊座位不代拍 a11y 语义。

    ⚠️ 认领状态,分诊只补标签:本单 assignee 已设,session_01NVPjPzmmAJ2Ngtvgg5MSRa 于 08-04 05:03Z 留认领评论(分支 claude/issue-3318-widget-aria-invalid-delivery),PM 切片裁决在案。本座位⛔未动 assignee、未动认领;补 pm:queue 仅为纳入状态机(此前无任何 pm:*,每轮都被当新单捞起)。⚠️ 该认领已 > 48h 无新产出(最后动静 08-04 05:39Z),若承接座位需要重新调度,按 stale-claim 规则处理——分诊座位不代做。

    查重(三仓 open issue + PR 各一遍):无重复单。同族在案:#3306(select 修复 + 守卫落地)、#3222(首批 7 个)、#3290(aria-required 通道)、#3291(sweep 范式)、#3342/#3343/#3344(交付时顺手另立)。⚠️ 这一族全部落在 packages/fields/src/,派发时按 SKILL step 3 严格串行。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  5. yinlianghui commented on Aug 9, 2026

    @yinlianghui
    Contributor

    补一条实测:slider 这一行在账本里记的是「不携带 aria-invalid」,但它丢的不止这一个通道 —— SliderField 根本没有 ...props / toDomProps spread(只手动取了 props.disabled 和 props.className),所以 FormControl 那个 Slot 注入的全部内容都进不去。

    修 #3952 时用一次性 probe 在真表单里逐个 widget 量了 host label 的落点,slider 那一行的结果是:

    ### slider
      hostLabelFor=_r_g_-form-item -> MISSING
      labels=[{"text":"PROBE LABEL","for":"_r_g_-form-item","resolves":false}]
      ids=[]                     <- 整行 DOM 里没有任何带 id 的元素
    

    ids=[] 是关键:不是 id 被换成了别的值,而是这一行没有任何元素带 id,所以表单的可见 label(FormLabel htmlFor 指向 _r_N_-form-item)指向一个不存在的目标 —— 点击标签不会聚焦/操作滑块,标签也不在滑块的可访问名里。同时 aria-describedby(错误消息与 description 的关联)、aria-required(#3290 的状态通道)、name / onBlur / onFocus / tabIndex 一并丢失。

    好消息是本单给出的修法(把 toDomProps(props) spread 到真实可聚焦控件,spread 之后再写 aria-invalid={!!error})一次把这几条通道全部接上,包括 label 关联 —— 所以这不是另一个单,只是提醒 slider(以及其它同样「什么都不转发」的行)修好后值得顺手确认 for 能解析到控件,而不只是确认 aria-invalid 变红。

    参考:#3952 / PR #3959 是同一失效类在 boolean 上的另一种形态(那边是把 host 下发的 id 覆写掉,而不是丢掉)。复合/分组 widget(address / geolocation / checkboxes / radio / rating / file)的 label 关联另有一层裁决,已单独立单 #3961 —— 那几个即使按本单接上 toDomProps 也不会自动修好,因为它们的 id 是被覆写或落在不可 label 的 div 上。


    Generated by Claude Code

  6. yinlianghui commented on Aug 9, 2026

    @yinlianghui
    Contributor

    补一条同类实测,接在上一条 slider 之后:账本残留 9 条里的 signature 也是「整行没有任何带 id 的元素」那一形态,不只是缺 aria-invalid。

    实施 #3961 时把 probe 扩到 18 个字段类型跑了一遍(probe 未提交),signature 一行:

    signature   for=…-form-item   ownId=(none) -> MISSING   byLabelText=0()   byRole+name=[]
    

    SignatureField 里没有 toDomProps / ...props spread(canvas 与几个按钮都是手写属性),所以 FormControl Slot 注入的 id / aria-describedby / aria-invalid / aria-required 一并落空,可见 label 的 for 指向一个不存在的 id。与 slider 同因同治,属本单完成范围,不另立单。

    ⚠️ 一点差别值得在修的时候先想清楚:账本给 signature 记的残留原因是「canvas 无键盘路径」。若最终结论是它确实没有可聚焦控件,那么 aria-invalid 那一半可能判「不适用」(与 formula / summary / auto_number / vector 同一条分流线,见 08-06 分诊评论),但 label 关联这一半不会因此消失 —— 一个没有可聚焦控件的字段仍然需要它的可见标签成为某个元素的可访问名(参照 #3961 对 file 的处置:那里的控件是 div[role=button],不可 for-label,于是改由 host 发 aria-labelledby、控件自己接)。两半可以分别判,别让「aria-invalid 不适用」顺手把 label 关联也一起判掉。

    参考:#3961(复合/分组 widget 的 label 关联 + ComponentMeta.labelling 声明位,已实施);#3976(内建 select 分支把 host id 展开到 Radix Select.Root,同一 probe 顺带测到,另立)。


    Generated by Claude Code

  7. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    Contributor

    【补充读数,来自 #4788 的形态扫描 —— 不认领,只是把同一批 widget 的另一根轴量给这条账本】

    本单的 sweep 只看 aria-invalid 一根轴。用同一套 probe(真 form renderer + 裸注册,字段带 description)把 host 下发的另外两件 pass-through 一起量了一遍,账本里几个「widget 根本不 spread」的类型在可编辑态是这样的:

    slider     editable  hostIdEl=NONE  descConsumers=0  for=DANGLING
    grid       editable  hostIdEl=NONE  descConsumers=0  for=DANGLING
    signature  editable  hostIdEl=NONE  descConsumers=0  for=DANGLING
    text       editable  hostIdEl=input descConsumers=1  for=RESOLVES   ← 对照组
    

    即:本单正文说的「大多数:widget 根本不把任何 DOM pass-through 转发到它渲染的真实控件上」不止让 aria-invalid 缺席 —— 同一处缺失也让 host 的 id(可见 label 的 for 因此悬空) 和 aria-describedby(渲染出来的 FormDescription 零消费者) 一起没落地。好消息是本单给的修法(toDomProps(props) spread 到真实可聚焦控件)一次就把三件都修好,不需要额外动作;写在这里只是为了让分批收工的人知道每移除一行 ledger,实际关掉的是三个缺陷而不是一个,顺手可以把 for / describedby 也钉一下。

    两点边界:

    1. markdown / html / richtext 不在本单的 29 个里(它们在 PASS 的 17 个中,RichTextEditorSurface 自己置了 aria-invalid),但它们的 id / aria-describedby 同样不落地 —— 本单的断言维度看不到,照本单收工也不会碰到它。已单独立 [fields] markdown / html / richtext 的**可编辑**分支从不转发 host 下发的 id 与 aria-describedby —— label for 悬空、description 零消费者,且 #3318 的 aria-invalid 账本按设计看不见这一半 #4810。
    2. 只读态的同类缺失是另一条([fields] 非 group-labelled widget 的只读替换显示丢掉 host 下发的整份 plumbing —— label 的 for 悬空、description 零消费者(email / url / phone 实测) #4788),机制方向待裁,不要混进本单的修法里。

    Generated by Claude Code

  8. self-assigned this
    on Aug 17, 2026
  9. 19 remaining items

  10. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    ⚠️ This card's body is stale by two merged PRs. The ledger is at 1 row, not 29.

    PM (seat repo:objectui, session session_01RV6yuVCxymHYE16PL9vQkE). Dispatched, measured, no PR — premise_still_valid: false, which is the correct delivery here. Releasing the claim: pm:dispatched → needs-user-decision, unassigned.

    Measured on origin/main @ 6c68b13c9, in the guard this card is accounted by (packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx:208):

    const NOT_YET_DELIVERED: ReadonlySet<string> = new Set(['grid'])

    Sweep runs 50/50 green. The registry is 45 types, not 46 (owner was retired by #4814): 37 positively guarded as DELIVERING, 7 NOT_APPLICABLE (formula, summary, auto_number, vector, signature, filter-condition, recipient-picker), 1 NOT_YET_DELIVERED (grid).

    The 29-widget reading in the body predates merged PRs #3345 (29 → 9) and #4933 (9 → 1). ⛔ Do not dispatch this card at that size again — I did, on the body's authority, and the premise check is the only reason it did not turn into a batch of work against an empty ledger. That is my error to own: I sized this card from its body without opening the ledger file the body itself names as the source of truth.

    The residual is not a widget fix — it is a decision, and grid is not "unfixed"

    GridField is not a widget that forgot to forward ARIA. Since #4857 it already spreads toDomProps(props) onto its container and then deliberately strips the host aria-invalid, with the reason stated in place: "control-channel state; this grid reports validity per CELL with its own inline marks." So the remaining work is overturning a documented decision, not applying the batch pattern. It also cannot take the NOT_APPLICABLE route — that ledger's guard requires zero focusable controls, and grid renders a button (pinned by mutation M4).

    The question: what should a form-level validation failure mark on a composite widget that reports validity per cell?

    Four-prism block

    1. Platform long-term coherence — A and B are workarounds that trip this repo's own "never mark a non-focusable wrapper" rule; C extends the channel the widget already owns instead of bolting on a second one. D institutionalises an exception.
    2. Measured business pull — real but narrow: a screen-reader user submitting a required empty line-items grid is told nothing. Populated grids already mark cells inline, so the uncovered case is specifically the empty one.
    3. AI-agent error-resistance — the decisive axis. The sweep currently cannot tell a wrapper mark from a real delivery on a DELIVERING row, so option A would land, read clean, empty the ledger, and leave the defect alive and invisible to the next agent. That hole is bigger than grid and I have filed it separately (below).
    4. Startup scope discipline — one widget, one ledger row. C is contained; D adds permanent ledger surface for a single case.

    PM recommendation: C, paired with tightening the sweep's DELIVERING assertion to require that the element carrying aria-invalid="true" be focusable. ⚠️ Unmeasured caveat, stated rather than buried: that tightening may turn some of the 37 currently-green rows red, because several deliver onto elements nobody individually checked. It must be measured before it is committed — which is part of why this is a decision and not a batch.

    Filed separately

    The gate hole in prism 3 stands on its own merits regardless of which option is chosen — #5221.

    Secondary, for the triage seat

    The body should be corrected to the 1-row reading with a pointer at the ledger file. The ledger already declares itself the source of truth in its own header, but the body is what dispatch reads, and it has now mis-sized this card at least once. ⛔ Not editing another seat's filed report myself; flagging it.


    Generated by Claude Code

  11. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Correction to the comment above: the separately-filed gate finding is #5223, not #5221.

    I wrote the number before creating the card and guessed it. #5221 is an unrelated issue. Everything else in that comment stands — the ledger reading, the four options, and the recommendation.

    Correct reference: #5223 — the aria-invalid registry sweep cannot tell a wrapper mark from a real delivery.


    Generated by Claude Code

  12. os-support-ai commented on Aug 20, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 21
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-3318-grid-widget-aria-invalid
    Worktree: objectui-issue-3318
    Domain: repo:objectui
    File surface: the grid field widget in packages/fields and packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx (the ledger + the DELIVERING assertion) (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — delivers an already-declared a11y state channel; no contract or public surface change
    Serial constraints cleared: packages/fields is disjoint from the other three cards this round (#5240 plugin-grid, #5301 plugin-dashboard, #5389 auth). No open PR touches packages/fields.

    ⚠️ Dispatched at the CORRECTED scope, not the card body's. The body still says "29 of 46"; the maintainer's 2026-08-19 ruling above (「全部接受」) records the ledger down to 1 row — grid — denominator 45, and states verbatim ⛔ "do NOT redispatch at the stale '29 of 46' sizing". The dispatch order carries the ruling's own sequencing caveat: measure the sweep tightening's impact on the currently-green rows first, and report the list rather than landing it blind if legitimate rows go red.


    Generated by Claude Code

  13. os-support-ai commented on Aug 20, 2026

    @os-support-ai
    Collaborator

    os-dev-report

    {
      "issue": 3318,
      "status": "done",
      "branch": "claude/issue-3318-grid-widget-aria-invalid",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5429",
      "premise_still_valid": true,
      "summary": "Implemented the maintainer's 2026-08-19 ruling (Option C) for the last ledger row, `grid`. Verified the corrected premise first on origin/main at bd977f86f: NOT_YET_DELIVERED held exactly one row (`grid`), registry 45 types, sweep 50/50 green -- the dispatch's corrected sizing was accurate, the card body's '29 of 46' is two merged PRs stale. GridField's container strip of the host aria-invalid (#4857) STANDS; what was added is the half it implied: when the `error` slot is set on an EMPTY required grid (`hostFailedEmpty`), the ghost entry row stops being skipped and its required cells flag, and the mark moved off the non-focusable `td` onto each cell's own control (Input, SelectTrigger, and LookupField via its published `error` slot). Also tightened the sweep's DELIVERING assertion to require a focusable carrier, closing #5223. NOT_YET_DELIVERED is now EMPTY (29 -> 20 -> 9 -> 1 -> 0) and a new assertion keeps it empty, making the header's long-standing 'do not add to this list' prose mechanical. Two things the PM should look at rather than skim: (1) this PR carries a live closing keyword for #5223 -- deliberate and flagged at the top of the PR body, but it is the PM's call to drop it before landing; (2) Option C could not be delivered under the sweep's existing harness config at all -- see the tests field.",
      "tests": "All from the repo ROOT (never `pnpm --filter (pkg) test` -- AGENTS.md trap 1), each heavy run wrapped in `flock -E 99 -w (seconds) /tmp/os-heavy-verify.lock`. Gate union re-run AT the final commit `7023649b9` (`git rev-parse --short HEAD` from that run).\n\nMEASUREMENT THE RULING REQUIRED FIRST -- the tightening's blast radius on the currently-green rows: a throwaway probe (not committed) over all 45 registered types read the actual carrier element of every aria-invalid='true' after a real failure. RESULT: all 37 previously-green DELIVERING rows ALREADY carry on a focusable element -- the tightening reds ZERO legitimate rows, so there is no list to report. The cases I predicted might be at risk all passed: address=[input x5], checkboxes=[button[role=checkbox]], file=[div[role=button]], radio=[div[role=radiogroup]] (roving tabindex), rating=[button x5], slider=[span[role=slider]], select=[button[role=combobox]].\n\nSURPRISING MEASUREMENT that changed the shape of the work: Option C says 'put aria-invalid on the cell's own input', but the sweep's own harness gave it NO INPUT TO LAND ON. Measured: `bare grid (the sweep config) focusables=[button] inputs=0` vs `grid WITH columns focusables=[input(Item), input(Qty), button] inputs=2`. A grid with no `columns` renders an empty table whose only focusable element is the 'Add line' button -- the exact carrier option B was rejected for. So the harness now gives `grid` a two-column config, which is the precedent already in that file for the option widgets (OPTION_TYPES get OPTIONS, 'unfillable placeholder unless offered a list'). This makes the assertion STRICTER, not looser. It is a change to the harness's render state and is called out prominently in the PR body so a reviewer sees it rather than finds it.\n\nREVERSE VERIFICATION, predicted before each run, all three matched. (1) Ratchet fires: fix landed with `grid` still in the ledger -> `Tests 1 failed | 49 passed (50)`, 'field:grid now delivers aria-invalid -- remove it from NOT_YET_DELIVERED', received `input aria-invalid=\"true\" aria-label=\"Item\" data-cell=\"0-0\"` -- a focusable cell control. (2) ABLATION proving the new half is not a phantom check: re-introduced the wrapper mark (rejected option A) by putting aria-invalid back on the `td` and removing it from the input -> `Tests 1 failed | 50 passed (51)`, 'field:grid carries aria-invalid ONLY on non-focusable element(s) [td, td]'. This is the load-bearing one: the OLD assertion would have PASSED that exact state, because a `td` is found by a row-wide query -- that is #5223's hole, demonstrated failing. ABLATION REBUILD NOTE: no build was needed or done for either leg, and that is correct here rather than skipped -- the root vitest config aliases every `@object-ui/*` specifier to `src` (vitest.config.mts resolve.alias), so both legs are source-resolved and no stale `dist` can fake a green. The fix was COMMITTED before the ablation, so the restore was `git checkout (branch) -- (path)` against a real commit (verified byte-identical: empty `git diff HEAD`). (3) After removing the ledger row: `Tests 51 passed (51)` = 38 DELIVERING + 0 ledgered + 7 NOT_APPLICABLE + 6 structural.\n\nSUITES: widget-aria-invalid-registry-e2e.test.tsx 51/51 | packages/fields/ 107 files 1803 passed | packages/plugin-detail/ 87 files 824 passed (master-detail renders this widget) | packages/plugin-form/ 56 files 574 passed | packages/components/src/renderers/form/ 49 files 316 passed. Consumer radius chosen by grepping who references GridField/field:grid/line-items, then checking which of them couple aria-invalid to grid (only two SOURCE files, no consumer TEST does).\n\nGATES: `turbo run type-check --filter=@object-ui/fields` 11/11 successful (output echoed the real script name -- objectui spells it `type-check` with a hyphen, and a misspelling would have exited 0 having run nothing). check:control-bytes OK (4486 files). check-changeset-presence OK. check-changeset-no-major OK. Manual control-byte grep over all three changed files: clean. eslint on both changed files: 0 errors; GridField.tsx shows 27 warnings BOTH at origin/main and after my change (measured both ways) -- this PR adds none.\n\nDIFF-SCOPE TRAP HIT AND AVOIDED, worth recording: `git diff origin/main..HEAD --stat` reported SEVEN files including ci.yml (-259 lines), a sibling changeset, a docs page and a script. None are mine. Two-dot evaluates against the CURRENT origin/main, which advanced past my branch point while I worked. Against the merge-base (`bd977f86f`) the true set is exactly 3 files / 195 insertions / 24 deletions, which matches GitHub's own count on the PR. Anyone deriving a gate list from the two-dot output here would have gotten a wrong one that still exits 0.",
      "open_questions": [
        {
          "question": "PR #5429 carries a live closing keyword for #5223 ('Also closes #5223'), so merging it will close that card. I made it deliberate and flagged it at the top of the PR body rather than leaving it buried in a heading where it would have been discovered at merge time -- but whether it should close is the PM's call, not mine.",
          "options": [
            "A. Keep it. #5223's proposed shape is verbatim what this PR implements ('require that the element carrying aria-invalid be focusable'), including its 'measure before committing, then triage whatever goes red' caveat (nothing went red), and #5223 itself says the tightening should be settled WITH #3318 rather than before it. Leaving it open would leave a card describing a hole that no longer exists, and burn a future dispatch.",
            "B. Drop the keyword before landing and close #5223 separately, if the PM wants that card's closure recorded on its own terms or thinks it has residual scope beyond the DELIVERING assertion."
          ],
          "recommendation": "A. The card's whole proposed fix is implemented and demonstrated failing-then-passing by the ablation, so its accounting is genuinely satisfied here. I flag it only because auto-closing a card this seat was not dispatched for is exactly the accounting leak this repo trips on most, and it should be an explicit decision rather than a side effect of my wording."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5431: a required `file` column in the grid widget flags its cell visually but never announces it -- FileCell's closed prop set has no aria-invalid path, so delivering it means a contract choice (add an `error` slot mirroring LookupField, vs a narrower `invalid` boolean) rather than a mechanical edit. Not fixed in this PR: it fails the bounded-in-place test on 'mechanical, correct shape already pinned by existing evidence'. The registry sweep does not and will not catch it -- it configures the grid with text/number columns, so `grid` sits legitimately in the positive guard while this per-column-type gap survives inside a passing widget."
      ]
    }

    Plainly, for the next seat

    The ledger is empty. NOT_YET_DELIVERED went 29 → 20 → 9 → 1 → 0. Every one of the 45 registered types now either delivers aria-invalid on a focusable control (38) or carries a measured NOT_APPLICABLE verdict (7). The set stays in the file as the ratchet, and its emptiness is now asserted.

    Housekeeping: the zero-commit probe branch claude/issue-3318-widget-aria-invalid-batch1 from this session's round 1 is still on the remote and can be deleted. Its worktree has now been reclaimed and removed (git worktree remove succeeded without --force, so nothing uncommitted was lost).

    ⛔ Auto-merge not enabled. Draft PR — the PM lands it.


    Generated by Claude Code


    Generated by Claude Code

  14. os-support-ai commented on Aug 20, 2026

    @os-support-ai
    Collaborator

    ACCEPT — PR #5429 enqueued, and it closes #5223 too (decision below)

    repo:objectui execution seat, session session_01RV6yuVCxymHYE16PL9vQkE. All 22 check runs completed: 19 success, 3 skipped. Shards 9m05s–9m19s, Type Check 7m00s, Lint 4m51s. Marked ready, then auto-merge (SQUASH).

    The ledger is empty: 29 → 20 → 9 → 1 → 0. Every registered widget now announces its own validation failure to a screen reader.

    On the #5223 closing keyword — keep it, decided rather than defaulted

    The dev flagged at the top of its PR body that Also closes #5223 is live and asked the PM to drop it before landing if that card should close on its own terms. That is the right way to raise it — auto-closing a card the seat was not dispatched for is exactly the accounting leak this repo trips on.

    I read #5223 rather than take the summary: its Proposed shape is verbatim what this PR implements ("require that the element carrying aria-invalid='true' be focusable"), it carries the same "measure before committing, then triage whatever goes red" caveat, and it states in its own words that the tightening "should be settled with #3318 rather than before it". Nothing of it is left undone. Keyword kept.

    The ruling's binding sequencing was honoured, and it came back clean

    The ruling made this a precondition: measure the tightening's blast radius on the currently-green rows first; if it reds legitimate ones, report the list rather than landing blind.

    All 37 previously-green DELIVERING rows already carry on a focusable element. Zero reds. No list to report. Every case I would have flagged as at risk was probed and named — radio on a div[role=radiogroup] with roving tabindex, file on a div[role=button], slider on span[role=slider], select on button[role=combobox], checkboxes, rating, address.

    The measurement that changed the shape of the work

    Option C says "put aria-invalid on the cell's own input". Measured, the sweep's own harness gave grid no input to land on:

    bare grid (the sweep's config)   focusables=[button]                          inputs=0
    grid WITH columns                focusables=[input(Item), input(Qty), button]  inputs=2
    

    A grid with no columns renders an empty table whose only focusable element is the "Add line" button — the exact carrier option B was rejected for. The sweep was asking a widget that renders no controls whether it marks its control. The harness now gives grid a two-column config, following the precedent already in that file for OPTION_TYPES, which makes the assertion stricter. Called out prominently as a change to the harness's render state rather than left for a reviewer to find.

    The ablation that matters

    Re-introducing the wrapper mark (rejected option A — aria-invalid back on the td, off the input) fails with "carries aria-invalid ONLY on non-focusable element(s) [td, td]". The old assertion would have passed that exact state, because a td is found by a row-wide query. That is #5223's hole, demonstrated failing rather than argued — and it is what makes the new half a real check instead of a phantom.

    Also verified: GridField's #4857 container strip stands — this upholds that decision rather than silently overturning it, which is what option A would have done.

    A diff-scope trap worth recording repo-wide

    The dev found git diff origin/main..HEAD --stat reporting seven files including ci.yml (−259 lines), a sibling changeset, a docs page and a script — none of them theirs. Two-dot evaluates against the current origin/main, which advanced past their branch point while they worked. Against the merge base the true set is 3 files, matching GitHub's own count.

    That is not academic today: #5403, #5301 and #5240 all merged while cards were in flight. Deriving a gate list from a two-dot diff yields a wrong list that still exits 0. Three-dot against the merge base is the only safe form, and it is now in the dispatch orders going out.

    Follow-up filed, correctly not folded in

    #5431 — a required file column in the grid flags visually but never announces. FileCell's prop set is closed, so delivering it is a contract choice (an error slot mirroring LookupField, vs a narrower invalid boolean), not a mechanical edit. ⚠️ The dev also noted the registry sweep will not catch it: the harness configures grid with text/number columns, so grid sits legitimately in the positive guard while this per-column-type gap survives inside a passing widget.

    Cards close on merge via Fixes #3318 and Also closes #5223.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions