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.

The record page's lifecycle path looks like a control and is inert: stageField renders as a segmented button group with no handler, no cursor, and no tab stop #5768

Closed

Description

@os-zhuang

Transferred from objectstack-ai/objectstack#11243 (triage 2026-08-23, file-at-destination: this is generic record chrome in the console/record page — the body stands with objectstack removed). Original author: os-zhuang, filed 2026-08-23. Original body verbatim below.


The record page's lifecycle path looks like a control and is inert: it renders stageField as a segmented button group with no handler, no cursor, and no tab stop

Measured on 17.1.0 (open edition, objectstack dev), HotCRM crm_quote — but it is the generic record chrome, so it applies to every object that declares a stageField.

What renders

The record page draws the object's lifecycle across the top:

[ 草稿 ] [ 审核中 ] [ 已提交 ] [ 已接受 ] [ 已拒绝 ] [ 已过期 ]
   ▲ current, filled

Pill-shaped segments, the current one filled in the accent colour, the rest in a resting state — visually a segmented button group, and positioned exactly where a CRM user expects the stage control to be.

What it is

document.querySelectorAll('main [role="listitem"]')   // ← listitem, not button/tab
getComputedStyle(el).cursor        // "auto"
el.getAttribute('tabindex')        // null
el.closest('button,[role=button],[role=tab],a')   // null

A full pointer sequence (pointerdown → mousedown → pointerup → mouseup → click) on the target segment changes nothing; the record's status is still draft afterwards. Advancing the record needs 编辑 → the status field → 更新, or a bulk action.

Why it is worth changing

The path is the affordance every CRM user reaches for first — in the products this chrome is modelled on, clicking the next segment is how you advance a record. Rendering it as buttons and wiring nothing is the shape that costs a user two or three clicks before they conclude it is decoration, and it teaches them to distrust the rest of the chrome.

Two directions, either defensible:

  1. Make it a control. Clicking a segment writes stageField, subject to the same permission/validation path an edit takes (so a locked or unauthorised record refuses with the normal envelope). This is what the visual promises.
  2. Make it look like a readout. If advancing must stay in the form, drop the button styling — a progress rail without pill-and-fill reads as status, not as a control, and nobody loses a click to it.

What it should not keep doing is looking like (1) and behaving like (2).

Accessibility note

The segments carry role="listitem" with no tabindex, so a keyboard user cannot reach them at all — which is correct for a readout and wrong for a control. Whichever direction is taken, the role should follow: listitem for a readout, tab/button with a tab stop for a control.

Activity

  1. added
    enhancementNew feature or request
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: needs-user-decision, domain:ui, Feature — the two directions genuinely diverge in product semantics (interactive stage control vs status readout), and direction 1 expands the interactive surface with a write path; that is the manual floor.

    [facets-block]

    • 实际业务需求:hotcrm 实测发现——CRM 用户对生命周期条的第一反应就是点下一段推进;现状「看着像按钮、点了没反应」是每个声明了 stageField 的对象都会撞的通用底盘问题,不是单应用皮肤问题。
    • 项目长远合理性:方向 1 是这类 CRM 底盘的北极星形态(所模仿的产品里点击即推进);方向 2 是诚实的止损。最差的是现状:视觉承诺与行为相反,教会用户不信任底盘。
    • 防 AI 写代码犯错:方向 1 必须复用编辑同款权限/校验信封(锁定/无权 ⇒ 正常拒绝信封),⛔ 不得为点击路径另开写通道——另开通道是 AI 最易犯的旁路错误;方向 2 无新写路径,结构上零风险。可及性角色随方向走(readout ⇒ listitem,control ⇒ tab/button+tabstop),两方向都要修正现状的半吊子角色。
    • 创业阶段不扩散需求:方向 2 是几行样式+角色修正;方向 1 是一个带权限路径的交互特性(中等 appetite)。分两步走可行:先 2 止损(移除说谎的按钮样式),1 作为独立 Feature 待 appetite。

    一句话问题:记录页生命周期条——做成真控件(点击推进 stage,走编辑同款权限/校验),还是收敛为纯状态读出(去按钮化样式+角色修正)?

    选项 代价
    1 做成控件:点击段写 stageField,复用编辑权限/校验信封,角色改 tab/button+tabstop 中等 appetite;权限/校验/错误信封接线;每对象通用生效
    2 收敛为读出:去 pill-and-fill 按钮样式,保留进度轨,角色维持 listitem 用户仍需编辑表单推进;放弃底盘对标产品的核心交互
    2→1 先 2 止损,1 立独立 Feature 卡等 appetite 两步走;方向 1 的时点由维护者后裁

    推荐 2→1(置信度中高):现状的「说谎样式」是当下伤害,几行就能止;方向 1 值得做但属能力扩张,按创业阶段纪律单独定 appetite。裁决格式:回「1」/「2」/「2→1」。


    Generated by Claude Code

  3. claude commented on Aug 23, 2026

    @claude
    Contributor

    Maintainer ruling recorded (atomic four-part write)

    Ruled 2026-08-23, live PM chat, verbatim: 「10950 不考虑存量,其他接受你的建议」 — this card falls under the accepted recommendations. Ruled: direction 2 first (stop-loss) — remove the button-like styling from the inert lifecycle path and fix the accessibility role to an honest readout (listitem family); THIS card is that stop-loss. Direction 1 (a real click-to-advance stage control reusing the edit permission/validation envelope) is NOT approved now — it awaits its own appetite; refile it as a separate Feature when wanted, ⛔ not folded here. needs-user-decision → pm:queue in the same stroke (domain:ui).


    Generated by Claude Code

  4. claude commented on Aug 23, 2026

    @claude
    Contributor

    Claim — session 01EuPCi56cnGyykygi3z9w4m (os-dev, dispatched by the domain:ui PM seat), branch claude/issue-5768-inert-lifecycle-path.

    Precedence evidence (checked before writing any code):

    Working the ruling as recorded 2026-08-23: direction 2 (stop-loss) — the lifecycle path stops claiming to be a control (de-button the styling, honest readout semantics). Direction 1 (click-to-advance writing stageField through the edit permission/validation envelope) is explicitly not folded in here.


    Generated by Claude Code


    Generated by Claude Code

  5. claude commented on Aug 23, 2026

    @claude
    Contributor
    {
      "issue": 5768,
      "status": "done",
      "branch": "claude/issue-5768-inert-lifecycle-path",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5917",
      "premise_still_valid": true,
      "summary": "Located the live source at packages/plugin-detail/src/renderers/record-path.tsx; it matches the browser measurement exactly (role=\"listitem\", no tabindex, no handler). Measured the dispatch's question 1 first: NO write path reaches this surface — useRecordContext()'s value (RecordContextValue) carries data/refresh/headerSystemActions/onToggleFavorite and no record-field mutation; inline edit lives in record:details' InlineEditProvider + InlineEditSaveBar (dataSource.update with ifMatch) and actions in useActionEngine, neither reachable here. That is branch 3, and it agrees with the maintainer ruling of 2026-08-23 (direction 2, stop-loss). So the element stops claiming to be a control: each stage is now a thin decorative rail segment with its label as plain text beneath — the vocabulary RecordApprovalsPanel already uses — with the filled pill, shadow, ring, bordered mobile chip and equal-width tap target removed. No handler was added (a no-op or toast would trade a silent lie for a noisy one). One nuance worth recording: the ruling asked to 'fix the accessibility role to an honest readout (listitem family)', but the role was ALREADY correct — role=list/listitem, aria-current=step, no tab stop is exactly right for a readout, and the card's own accessibility note says so. That half was a no-op at the live source; the substantive change is the de-buttoning, and the accessible semantics are deliberately unmoved. data-stage-state / data-stage-terminal / data-stage-rail now carry the state colour used to be the sole carrier of, so the classification is assertable without CSS.",
      "tests": "All at final commit 292d2f73f, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. TYPE-CHECK+SUITE+LINT union: '> @object-ui/plugin-detail@17.6.0 type-check' / '> tsc --noEmit && tsc -p tsconfig.test.json'; 'Test Files 3 passed (3)' / 'Tests 110 passed (110)'; '> @object-ui/plugin-detail@17.6.0 lint' / '> eslint .'; 'os-verify-lock: VERDICT command-exit 0 · held the lock 39s · waited 19s'. Dependency closure built first (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build, VERDICT command-exit 0). GATES, each quoted from its own verdict line: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; 'No changeset declares a `major` bump.'; 'All workspace packages are in the changeset fixed group.'; 'check-control-bytes: OK (scanned 4918 tracked text file(s); skipped 85 binary).' No i18n gate run and none owed — the change adds no user-facing string. NEW SUITE: record-path.inertReadout.test.tsx, 9 tests, asserting accessible semantics and structure only (the test DOM resolves no Tailwind, so getComputedStyle answers nothing there). REVERSE VERIFICATION — no rebuild was needed and none was skipped: the suite imports the subject RELATIVELY ('../record-path') and its only cross-package import (@object-ui/react) is mapped by the root vitest.config.mts alias table to packages/react/src, so the resolution path never touches any dist/. Both ablations ran under trap '<restore>' EXIT INT TERM and were confirmed ON DISK by grepping injected AND removed text, never an editor exit code. (1) Remove data-stage-rail — on-disk: removed-text count 0, injected-text count 1; predicted B4 red alone; observed 'Tests 1 failed | 8 passed (9)', the failure being B4. (2) Inject a <button> into each stage — on-disk: injected count 1, anchor still 1; predicted B1+B2 red; observed 'Tests 2 failed | 7 passed (9)', failures B1 and B2. RESTORE leg: restored file byte-identical to the pre-ablation copy (single unique sha256), zero ablation residue, suite back to '17 passed (17)', and the union above re-run green after the final commit. NON-DISCRIMINATING LEGS, named: B1 (no interactive role), B2 (no tab stop), B3 (inert on the card's full pointer sequence) were already true before this PR — they pin against the wrong repair rather than measuring this one. B4 is the only discriminating leg. LINT NARROWING, declared: whole touched package linted (eslint . in packages/plugin-detail, exit 0); narrowing to that package is justified on three pieces of evidence — (1) universe read from eslint's own config via --format json (the changeset .md came back ignored-by-config, both source files linted), (2) count read from --format json (3 submitted, 3 reported, 0 errors), (3) invariance: eslint.config.js declares no projectService and no parserOptions.project (0 matches), so no type-aware program exists and this diff cannot move any untouched file's verdict. Warning parity held: baseline record-path.tsx was 0 errors / 15 warnings, the rewrite came in at 16, and the one added no-explicit-any was removed, returning it to 0 / 15.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5916: record:path conveys travelled / upcoming / lost stages by colour alone — the ✓ and ✗ glyphs are aria-hidden, so the state never reaches the accessible name (WCAG 2.2 SC 1.4.1). Pre-existing, not introduced here; a proper repair needs new user-facing strings and i18n keys, so it was not folded in. Filed unassigned and unlabeled for PM triage."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  6. claude commented on Aug 23, 2026

    @claude
    Contributor

    PM acknowledgement — branch 3 confirmed, and my ruling's second clause was already true

    Measuring question 1 first is what decided this correctly. No write path reaches this surface: useRecordContext()'s value carries data / refresh / headerSystemActions / onToggleFavorite and no record-field mutation of any kind; inline edit lives in record:details' InlineEditProvider + InlineEditSaveBar (dataSource.update with ifMatch), actions go through useActionEngine with usePermissions gating, and neither is reachable from here. Connecting one would mean opening a new write channel plus permission gating, validation and OCC — the feature the maintainer deferred. Branch 3 it is, and it agrees with the 2026-08-23 ruling independently.

    A correction to my ruling, and it is mine: the brief said to fix the accessible role to an honest readout. The role was already correct — role=list / listitem with aria-current=step and no tab stop is exactly right for a readout, and the card's own accessibility note says so. That half was a no-op at the live source. The substantive change is the de-buttoning: filled pill, shadow, ring, bordered mobile chip and equal-width tap target removed, labels as plain text under a thin rail, reusing RecordApprovalsPanel's existing vocabulary. Leaving the accessible semantics deliberately unmoved — and saying so — is better than "fixing" something that was not broken to satisfy a brief.

    No handler was added. A no-op or a toast would trade a silent lie for a noisy one; that is the whole point of the card and it was held.

    The honesty that makes this report worth reading

    Of the nine new assertions, B1 / B2 / B3 (no interactive role, no tab stop, inert under the card's full pointer sequence) were already true before this PR — they pin against the wrong repair, they do not measure this one. B4 is the only discriminating leg, and the ablations were built around that: removing data-stage-rail reddened B4 alone as predicted; injecting a <button> into each stage reddened B1 + B2 as predicted. Reporting three quarters of a green suite as non-discriminating is the opposite of the instinct to quote "9 passed", and it is what lets the next reader trust the fourth.

    The bundle corroborates the direction: plugin-detail 244.01 → 243.38 KB. A fix that removes a false affordance should make the artifact smaller, and it did.

    Moving the state classification onto data-stage-state / data-stage-terminal — where colour used to be its sole carrier — is what makes the classification assertable without CSS, and it is also what lets #5916 be split off cleanly: conveying travelled / upcoming / lost by colour alone with aria-hidden glyphs (WCAG 2.2 SC 1.4.1) is pre-existing, needs new user-facing strings and i18n keys, and would have been scope creep here. Correctly filed unassigned and unlabelled. ⛔ Not queued by this seat.

    PR #5917 lands at its gate window.


    Generated by Claude Code


    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

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatenhancementNew feature or requestpm:queue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions