Skip to content

fix(plugin-detail): give record:path one stage classification both rows read - #6015

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-5998-record-path-won-classification
Aug 24, 2026
Merged

fix(plugin-detail): give record:path one stage classification both rows read#6015
yinlianghui merged 1 commit into
mainfrom
claude/issue-5998-record-path-won-classification

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5998

record:path draws a desktop row (hidden sm:flex) and a mobile row (flex sm:hidden) from the same stages[], and each derived its ownterminal classification from that array. renderStage hands the same terminal to railClass and — since #5957 — to stageAriaLabel, so any disagreement surfaced in the paint and in the accessible name at once: one stage of one record, described two ways, chosen by nothing but the width of the window.

The fix is not "make the two rows agree". Two independently-derived classifications that happen to agree leave the defect one edit away. Both rows now index onestageTerminals array, computed once.

Premise re-measured on the merge-base

Merge-base 11d3ab999 (after PR #6000, which landed #5956 + #5957 on this exact file today). All four line numbers the dispatch quoted hold with no delta: WON_TOKENS at :107, stageKinds at :115, forwardKinds at :123, desktop isWonTerminus at :264 / terminal: at :269, mobile const kind at :328 / terminal: kind at :336. The terminalrailClass + stageAriaLabel coupling #5957 introduced is intact, so unifying the classification fixes paint and announcement together rather than separately — verified by the accessible-name assertions in the new suite.

Two axes, not one

1. Mid-path goal (the axis the card names).WON_TOKENS matches 完成, an ordinary mid-path word rather than a Salesforce-style closed_won value. 草稿 → 完成 → 已归档 classifies index 1 as won while last is 2. Desktop declined it (idx === last); mobile marked it bg-emerald-500/30 and announced goal stage, not reached.

2. The lost slice (the axis the card does NOT name — measured, and live). Desktop renders stages.slice(firstLostIdx) as a separated alt group and hardcoded terminal: 'lost' on every member of that positionally-defined group, while mobile classified each stage on its own:

fixturestagedesktop beforemobile beforeafter (both)
草稿 → 失败 → 已归档已归档'lost' (destructive, "closed lost")undefined (plain)undefined
草稿 → 失败 → 完成完成'lost''won'undefined

The second row of that table is the sharpest form: the two rows disagreed on the value, not merely on whether one was present. This is the same defect and the same fix, so it is resolved here — flagged rather than folded in silently, per the dispatch.

The rule the unified array encodes

  • lost is a property of the stage itself.
  • won is the goal terminus, so it is the last forward stage or it is nothing.
  • Positional grouping stays a layout concern and no longer overrides what a stage is.

This is the dispatch's lean (reading 1) and it is conservative on both axes: it can only ever stop marking a stage as a terminus, never start. No stage gains a terminal on either row that it did not already carry on that row. Nothing in the repo contradicted the lean — no existing test places a stage after a lost one or pins a mid-path won, so mobile's behaviour was incidental rather than deliberate (checked across every consumer of data-stage-terminal).

Verification

packages/plugin-detail/src/renderers/__tests__/record-path.crossRowClassification.test.tsx (6 cases). The pin is cross-row: for every stage, desktop and mobile must report the same data-stage-terminal, data-stage-state and accessible name.

MID_PATH_WON (草稿 → 完成 → 已归档) and the counter-probe WON_LAST (草稿 → 已归档 → 完成) carry the same three stages and differ only in where 完成 sits. A fixture whose won stage happens to sit last cannot distinguish the two readings and goes green under either, so the load-bearing case puts it mid-path; the counter-probe's green proves the fix is "paint the goal in one place" and not "never paint a goal", and — because it is a permutation of the same labels — proves WON_TOKENS still fires on 完成, so the mid-path result is a positional decision rather than the heuristic failing to match.

Reverse verification, direction predicted before running, run under trap … EXIT INT TERM, each mutation proved on disk by grepping the injected text and separately the removed text (an editor's exit code proves nothing on a zero-hit anchor):

  • Ablation A — mobile back to its own stageKinds[idx]: predicted red on the mid-path case and on won-after-lost. Observed 2 failed | 4 passed, exactly those two.
  • Ablation B — desktop's alt group back to hardcoded terminal: 'lost': predicted red on both lost-axis cases. Observed 2 failed | 4 passed, exactly those two.
  • The counter-probe stayed green under both. git diff HEAD --stat empty after each restore, injected text absent, original text present.

No ablation needed a rebuild: this suite imports ../record-path (this package's own source) and @object-ui/react / @object-ui/i18n through the root vitest.config.mts alias table, so nothing resolves through any dist/.

Gates, at fdc8aecd0

gateresult
pnpm --filter @object-ui/plugin-detail type-checkexit 0 (after pnpm --filter '@object-ui/plugin-detail^...' build — the first run's 17 TS2307 Cannot find module '@object-ui/*' errors were stale dist/*.d.ts, not this change)
pnpm exec vitest run packages/plugin-detail/src (root form)exit 0Test Files 100 passed (100), Tests 938 passed (938)
pnpm exec eslint --no-inline-config on the two changed source filesexit 0 — new test file errors 0 warnings 0; record-path.tsxerrors 0 warnings 15, delta vs merge-base 0/0 (the merge-base copy of the same file lints to the identical {no-explicit-any: 13, exhaustive-deps: 1, preserve-manual-memoization: 1}, all in the untouched preamble and the two schema.aria as any casts)
control-character scan on all three changed filesno hits

No i18n key added, renamed or moved — detail.pathStageWonUpcoming and the rest are unchanged, so packages/i18n/src/locales/** is untouched (#4730 holds it). Changeset: .changeset/5998-record-path-one-classification.md.

One question left open, not implemented

classify() reaches won two ways — the WON_TOKENS heuristic and an explicit terminal: 'won' on the stage — and this PR applies the last-forward-stage restriction to both. An author who wrote terminal: 'won' on a mid-path stage said so deliberately, while the regex guessed; applying the restriction to the heuristic only is a defensible third option. It is not implemented here because it is a decision, not a dev call, and because it would be the one direction that widens rather than narrows. Note that desktop already ignored an explicit mid-path terminal: 'won' before this PR, so nothing new is suppressed relative to the row that was already the stricter of the two. Details in the dev report on #5998.

Generated by Claude Code


Generated by Claude Code

…ws read
The desktop and mobile rows rendered the same stages[] and each derived its
own `terminal` from it, so one stage of one record could paint — and, since
the accessible name is derived from the same value, announce — two different
ways chosen by viewport width alone.
They diverged on two axes. Mid-path goal: WON_TOKENS matches the ordinary
word 完成, so 草稿 → 完成 → 已归档 classified index 1 as won; desktop declined
it (not the last forward stage) while mobile marked it the goal. Lost slice:
desktop hardcoded terminal: 'lost' on every member of its positionally
defined alt group (stages.slice(firstLostIdx)) while mobile classified each
stage on its own, so a plain stage after a lost one announced closed lost on
one row and plain on the other.
Both rows now index a single stageTerminals array computed once: lost is a
property of the stage, won is the goal terminus and so is the last forward
stage or nothing, and positional grouping stays a layout concern. Narrowed on
both axes and never widened — no stage gains a terminal on either row that it
did not already carry there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.0 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-CYhLizOg.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.23KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.12KB61.87KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 24, 2026 13:28
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 63d54ddAug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5998-record-path-won-classification branch August 24, 2026 13:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record:path classifies a won stage differently on desktop and mobile, so one stage paints (and now announces) two ways by viewport

2 participants

@yinlianghui@claude