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

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 own terminal 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 one stageTerminals 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 terminal → railClass + 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:

fixture stage desktop before mobile before after (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

gate result
pnpm --filter @object-ui/plugin-detail type-check exit 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 0 — Test Files 100 passed (100), Tests 938 passed (938)
pnpm exec eslint --no-inline-config on the two changed source files exit 0 — new test file errors 0 warnings 0; record-path.tsx errors 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 files no 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

Metric Value Budget
Eager closure (gzip, 52 chunks) 3233.0 KB 3990.2 KB
Main entry chunk (gzip) 153.6 KB 350 KB
Entry file index-CYhLizOg.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.38KB 3.90KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 505.23KB 114.56KB
core (index.js) 4.92KB 1.97KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 165.30KB 45.79KB
fields (index.js) 238.40KB 59.89KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.44KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 244.12KB 61.87KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.63KB 30.64KB
plugin-gantt (index.js) 164.15KB 39.88KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.86KB 27.22KB
plugin-map (index.js) 20.10KB 6.64KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.49KB 7.59KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.57KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 52.40KB 17.45KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.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 queue Aug 24, 2026
Merged via the queue into main with commit 63d54dd Aug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5998-record-path-won-classification branch August 24, 2026 13:40
This was referenced Sep 24, 2026
This was referenced Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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