You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318
⚠️ 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):
Sweep runs 50/50 green. The registry is 45 types, not 46 (owner was retired by #4814): 37 positively guarded as DELIVERING, 7NOT_APPLICABLE (formula, summary, auto_number, vector, signature, filter-condition, recipient-picker), 1NOT_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?
C. Drive the widget's existing per-cell channel from form-level failure — when the host error slot is set on a required-but-empty grid, stop skipping the ghost row for required cells and move the mark from the td onto the cell's own input. A real UX change (a fresh required grid would show marks on the entry row after submit), so it needs a call — but it puts the state on the control the user would actually edit.
D. Give grid a third verdict — "reports validity through a different channel" — with its own falsifiable guard. Honest, but adds ledger surface and risks becoming an escape hatch.
Four-prism block
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.
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.
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).
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.
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.
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 (#5240plugin-grid, #5301plugin-dashboard, #5389auth). 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.
{
"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.
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 currentorigin/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.
Activity
xuyushun441-sys commentedon Aug 4, 2026
认领 — session
session_01NVPjPzmmAJ2Ngtvgg5MSRa,分支claude/issue-3318-widget-aria-invalid-delivery。PM 裁决(切片策略):单 PR 尽量多交付,账本兜底诚实性——
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 只置状态。NOT_YET_DELIVERED账本行会变红 → 从账本移除、转入正向守卫(守卫结构已保证这一步不可跳过)。grid的表单级失败驱动、组合型 widget),留在账本并在 PR 里逐个写明原因;issue 保持 open 直到账本清零。宁可留账本行,不可为凑数放宽范式(不得把 aria-invalid 挂到非可聚焦包装层上充数)。Generated by Claude Code
xuyushun441-sys commentedon Aug 4, 2026
交付批次 PR:#3345(draft)—— 账本 29 → 9。
已交付(20):
multiselectradiocheckboxestagslookupmaster_detailuserownerfileimagelocationobjectcolorratingcodeavataraddressgeolocationqrcodeobject-ref。账本残留(9,逐项原因见 PR 与账本注释本体):
formulasummaryauto_numbervector(无可聚焦控件)、grid(表单级失败驱动 per-cell 需设计)、slider(thumb 在 No-Touch 的ui/slider.tsx内)、signature(canvas 无键盘路径)、filter-conditionrecipient-picker(门控态无控件;可编辑态已交付)。issue 保持 open 直至账本清零。顺手发现另立:#3342 #3343 #3344。
Generated by Claude Code
Generated by Claude Code
fix(fields): objectstack-ai#3318 aria-invalid 交付批次 —— 20 个 widget 校验失…
fix(components): objectstack-ai#3344 Combobox trigger declares type="…
os-zhuang commentedon Aug 6, 2026
分诊:
pm:queue(objectui 仓内单,⛔ 按 #5468 规则不打domain:*)。origin/main(84bd39e)实读packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx:196,NOT_YET_DELIVERED现有 9 条,不是正文写的 29 条:即 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}),账本注释里逐条写了残留原因,机械可验(改一个,它的账本行变红,强制转正向守卫)。属具体缺陷,不是观察类。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 语义。
session_01NVPjPzmmAJ2Ngtvgg5MSRa于 08-04 05:03Z 留认领评论(分支claude/issue-3318-widget-aria-invalid-delivery),PM 切片裁决在案。本座位⛔未动 assignee、未动认领;补pm:queue仅为纳入状态机(此前无任何pm:*,每轮都被当新单捞起)。查重(三仓 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
yinlianghui commentedon Aug 9, 2026
补一条实测:
slider这一行在账本里记的是「不携带aria-invalid」,但它丢的不止这一个通道 ——SliderField根本没有...props/toDomPropsspread(只手动取了props.disabled和props.className),所以FormControl那个 Slot 注入的全部内容都进不去。修 #3952 时用一次性 probe 在真表单里逐个 widget 量了 host label 的落点,
slider那一行的结果是: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
yinlianghui commentedon Aug 9, 2026
补一条同类实测,接在上一条
slider之后:账本残留 9 条里的signature也是「整行没有任何带 id 的元素」那一形态,不只是缺aria-invalid。实施 #3961 时把 probe 扩到 18 个字段类型跑了一遍(probe 未提交),
signature一行:SignatureField里没有toDomProps/...propsspread(canvas与几个按钮都是手写属性),所以FormControlSlot 注入的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
fix(fields,components): name composite field groups by IDREF instead …
yinlianghui commentedon Aug 16, 2026
【补充读数,来自 #4788 的形态扫描 —— 不认领,只是把同一批 widget 的另一根轴量给这条账本】
本单的 sweep 只看
aria-invalid一根轴。用同一套 probe(真 form renderer + 裸注册,字段带description)把 host 下发的另外两件 pass-through 一起量了一遍,账本里几个「widget 根本不 spread」的类型在可编辑态是这样的:即:本单正文说的「大多数:widget 根本不把任何 DOM pass-through 转发到它渲染的真实控件上」不止让
aria-invalid缺席 —— 同一处缺失也让 host 的id(可见 label 的for因此悬空) 和aria-describedby(渲染出来的 FormDescription 零消费者) 一起没落地。好消息是本单给的修法(toDomProps(props)spread 到真实可聚焦控件)一次就把三件都修好,不需要额外动作;写在这里只是为了让分批收工的人知道每移除一行 ledger,实际关掉的是三个缺陷而不是一个,顺手可以把for/ describedby 也钉一下。两点边界:
markdown/html/richtext不在本单的 29 个里(它们在 PASS 的 17 个中,RichTextEditorSurface自己置了aria-invalid),但它们的id/aria-describedby同样不落地 —— 本单的断言维度看不到,照本单收工也不会碰到它。已单独立 [fields] markdown / html / richtext 的**可编辑**分支从不转发 host 下发的id与aria-describedby—— labelfor悬空、description 零消费者,且 #3318 的 aria-invalid 账本按设计看不见这一半 #4810。for悬空、description 零消费者(email / url / phone 实测) #4788),机制方向待裁,不要混进本单的修法里。Generated by Claude Code
19 remaining items
os-support-ai commentedon Aug 18, 2026
PM (seat
repo:objectui, sessionsession_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):Sweep runs 50/50 green. The registry is 45 types, not 46 (
ownerwas retired by #4814): 37 positively guarded as DELIVERING, 7NOT_APPLICABLE(formula, summary, auto_number, vector, signature, filter-condition, recipient-picker), 1NOT_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
gridis not "unfixed"GridFieldis not a widget that forgot to forward ARIA. Since #4857 it already spreadstoDomProps(props)onto its container and then deliberately strips the hostaria-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 theNOT_APPLICABLEroute — that ledger's guard requires zero focusable controls, andgridrenders 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?
NOT_APPLICABLErows, so nothing would catch it. It would also silently overturn [fields] 七个 widget 在**可编辑态**也丢掉 host 下发的 id —— label 的for悬空、description 零消费者(formula / summary / auto_number / vector / grid / slider / signature 实测) #4857 while reading clean.for悬空、description 零消费者(formula / summary / auto_number / vector / grid / slider / signature 实测) #4857 already rejected routing host identity there because it makes the field's label name the add-row action.errorslot is set on a required-but-empty grid, stop skipping the ghost row for required cells and move the mark from thetdonto the cell's own input. A real UX change (a fresh required grid would show marks on the entry row after submit), so it needs a call — but it puts the state on the control the user would actually edit.grida third verdict — "reports validity through a different channel" — with its own falsifiable guard. Honest, but adds ledger surface and risks becoming an escape hatch.Four-prism block
gridand I have filed it separately (below).PM recommendation: C, paired with tightening the sweep's DELIVERING assertion to require that the element carrying⚠️ 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.
aria-invalid="true"be focusable.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
os-support-ai commentedon Aug 18, 2026
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
docs(fields): tombstone MasterDetailField as a form-unreachable orphan
os-support-ai commentedon Aug 20, 2026
Claim: PM loop round 21
Session:
session_01RV6yuVCxymHYE16PL9vQkEBranch:
claude/issue-3318-grid-widget-aria-invalidWorktree:
objectui-issue-3318Domain:
repo:objectuiFile surface: the
gridfield widget inpackages/fieldsandpackages/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: opusClause-②: no — delivers an already-declared a11y state channel; no contract or public surface change
Serial constraints cleared:
packages/fieldsis disjoint from the other three cards this round (#5240plugin-grid, #5301plugin-dashboard, #5389auth). No open PR touchespackages/fields.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
fix(fields): drive the grid widget's per-cell aria-invalid from form-…
os-support-ai commentedon Aug 20, 2026
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_DELIVEREDwent 29 → 20 → 9 → 1 → 0. Every one of the 45 registered types now either deliversaria-invalidon a focusable control (38) or carries a measuredNOT_APPLICABLEverdict (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-batch1from this session's round 1 is still on the remote and can be deleted. Its worktree has now been reclaimed and removed (git worktree removesucceeded 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
os-support-ai commentedon Aug 20, 2026
ACCEPT — PR #5429 enqueued, and it closes #5223 too (decision below)
repo:objectuiexecution seat, sessionsession_01RV6yuVCxymHYE16PL9vQkE. All 22 check runs completed: 19success, 3skipped. 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 #5223is 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 —
radioon adiv[role=radiogroup]with roving tabindex,fileon adiv[role=button],slideronspan[role=slider],selectonbutton[role=combobox],checkboxes,rating,address.The measurement that changed the shape of the work
Option C says "put
aria-invalidon the cell's own input". Measured, the sweep's own harness gavegridno input to land on:A
gridwith nocolumnsrenders 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 givesgrida two-column config, following the precedent already in that file forOPTION_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-invalidback on thetd, 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 atdis 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 --statreporting seven files includingci.yml(−259 lines), a sibling changeset, a docs page and a script — none of them theirs. Two-dot evaluates against the currentorigin/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⚠️ The dev also noted the registry sweep will not catch it: the harness configures
filecolumn in the grid flags visually but never announces.FileCell's prop set is closed, so delivering it is a contract choice (anerrorslot mirroringLookupField, vs a narrowerinvalidboolean), not a mechanical edit.gridwith text/number columns, sogridsits legitimately in the positive guard while this per-column-type gap survives inside a passing widget.Cards close on merge via
Fixes #3318andAlso closes #5223.Generated by Claude Code
fix(fields): required file grid cells announce their state — FileCell…
docs(fields): tombstone MasterDetailField as a form-unreachable orphan (
fix(fields): drive the grid widget's per-cell aria-invalid from form-…
fix(fields): required file grid cells announce their state — FileCell…