Skip to content

test(plugin-kanban): characterize the cross-column landing position (objectui#8826 Q3) - #8975

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8826-crosscolumn-landing-position-probe
Sep 10, 2026
Merged

test(plugin-kanban): characterize the cross-column landing position (objectui#8826 Q3)#8975
os-steve merged 1 commit into
mainfrom
claude/issue-8826-crosscolumn-landing-position-probe

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Refs #8826 — answers that card's question 3 only. The card stays OPEN.

What this is

objectui#8826 measured that a SAME-column reorder is persisted nowhere. Its third question — on a CROSS-column move, is the landing POSITION within the target column also lost? — was filed explicitly unmeasured, with the instruction to answer it before touching anything, because the fix surface depends on the answer.

This PR is that measurement, frozen as a characterization test.

Zero behaviour change. No persistence added. No optimistic update removed. KanbanImpl's mirror-sync timing untouched — that belongs to objectui#8534 / PR #8825 and has already landed. The card's questions 1 and 2 are deliberately not pre-empted; they stay with the maintainer.

The answer: the position is lost too — on BOTH data ownerships

KanbanImpl.handleDragEnd's cross-column branch does compute a landing index, does splice the card in at it, and does hand that index to onCardMove as its fourth argument. ObjectKanban.handleCardMove receives it as _newIndex and discards it outright (void _newIndex); persistCardMove writes a body of exactly the groupBy column. The index reaches a parameter and stops there — no sink, no write key, no storage.

⭐ The part the card did not anticipate: the two ownerships answer differently

path new columns snapshot after the drop what the user sees
internal data (board fetches) backlog: OR in_progress:Xray,Yankee,Alpha — record order card snaps out of the dropped slot immediately, inside the same act()
external data (parent owns data, e.g. ListView) none — 0 snapshots dropped slot survives on screen, until the parent's next reflow

The optimistic write that re-renders the board is itself gated on ownership. On the internal path it fires, KanbanRenderer re-buckets, the columns identity changes and the mirror re-syncs — so the loss is a visible snap on the spot, which is worse than the delayed silent revert the card describes for the same-column case. On the external path the optimistic write is skipped, nothing re-renders, and the local move stands until the parent reflows — that is the card's silent-revert signature exactly.

⇒ Position is lost on both. They differ only in when the user sees it happen.

(The table's column-order cells are written with the word OR where a vertical bar would be, because a bar is the markdown table cell separator. The test's own constants use the bar.)

The readings

Fixture is built so the two orders cannot coincide: the moved card is last in the record array and is dropped first in the target column. A fixture where the dropped card also happened to sort first would read green either way.

Records: Xray(in_progress), Yankee(in_progress), Alpha(backlog). Drop Alpha onto Xray = slot 0 of "In Progress".

  • at mount — backlog:Alpha / in_progress:Xray,Yankee
  • what the drop asks for — in_progress:Alpha,Xray,Yankeenever recorded, on either path
  • internal path, after drop — recorded in_progress:Xray,Yankee,Alpha; DOM ['Xray','Yankee','Alpha']
  • external path, after drop — recorded nothing; DOM ['Alpha','Xray','Yankee']
  • the wire, both paths — update('task', 'a', { status: 'in_progress' }), exact body, no positional key

Lit positive control (the card's own): the cross-column drop does produce a new columns snapshot carrying the membership change ⇒ the instrument is connected, so "the drop order never appears" is a reading and not a dead wire.

Dead negative control: a drag that ends on nothing records 0 snapshots and makes 0 writes ⇒ the instrument does not fire spuriously.

Ablation — proving the pin discriminates

A characterization pin is green on main by construction, so its value is that it goes RED when the behaviour moves. Mutation applied at the discard anchor in ObjectKanban.tsx: honour the index by hoisting the record to the front of the source array when the card was dropped into slot 0. Deliberately crude — not a candidate fix, only enough of a behaviour change to prove the pin can see one.

Run against 513fe387d.

leg blob hash of ObjectKanban.tsx discard anchor marker result
before 2a6d42aac9aca111797d4ab4386cf28df46f50ed 1 0
mutated (RED) 33c2b6f2217c407b7565691c7140ad1bff33a2c1 0 1 exit 1 — 2 failed, 4 passed
restored (GREEN) 2a6d42aac9aca111797d4ab4386cf28df46f50ed 1 0 exit 0 — 6 passed

Restore proven two ways, not by an exit code: the on-disk blob hash reproduces the HEAD blob byte for byte, and git diff HEAD is empty. The mutation was proven to reach the disk before the RED arm ran (anchor counts and a changed blob hash), so a no-op edit could not have been mistaken for a passing run.

The two failures are exactly the two measurement assertions; the two controls, the external-path asymmetry and the wire assertion stay green — the mutation only moves internal-path ordering. That failure profile, not just the red, is what says the pin is aimed at the right thing.

Gates

gate exit
pnpm --filter @object-ui/plugin-kanban type-check (both legs: tsc --noEmit + tsconfig.test.json) 0
pnpm --filter @object-ui/plugin-kanban lint 0 — 0 errors; this file contributes 0 warnings
pnpm exec vitest run packages/plugin-kanban/ (from repo ROOT) 0 — 46 files, 265 tests
check-control-bytes.mjs 0
check-changeset-presence.mjs 0
check-governed-queue-guard.mjs --test (both paths) 0 — NOT GOVERNED
check:doc-types 0
check:doc-snippets 0
check:doc-examples 0
check:vi-mock-specifiers / -inherit / -override-shape 0 / 0 / 0

The vi-mock trio is not from the dispatched list — this diff adds two vi.mock call sites, and those three gates read exactly that, so they were run as implicated.

The doc gates first exited 2 (PREREQUISITE NOT MET, packages unbuilt); the scoped build the gate names was run and both were re-run to a real 0. Exit 2 was not read as a pass.

Changeset carries an empty frontmatter: the package publishes dist only, so a file under src/__tests__/ releases nothing. That is the gate's own documented pass for a test-only change, not a workaround.

Acceptance notes

  • A dispatched assumption was falsified, in the direction that matters. The dispatch stated, starred and measured-today, that scripts/check-doc-example-types.mjs has no CI job ("0 hits across all of .github/ and turbo.json"). It has one: .github/workflows/doc-snippet-types.yml runs node scripts/check-doc-example-types.mjs as a real job step, on pull_request, push, merge_group and workflow_dispatch. The gate's own workflow filename does not carry its name, which is the likely reason a filename-shaped search missed it; that same workflow's header even documents the gate as a sibling. Consequence: the "nothing in CI will tell you" warning attached to the UNGATED_EXAMPLES ledger is wrong — CI does tell you. Noted, not filed: this is a correction to a dispatch brief, not a repo defect, and no PR or file carries it. Successor: none.
  • The card's line anchors have drifted. The card cites the discard at ObjectKanban.tsx:1081-1083; on today's main it is at :1224-1226. Re-derived by anchor, as instructed. The mechanism is unchanged: _newIndex is a declared parameter, void _newIndex is the discard, and handleCardMove early-returns on fromColumnId === toColumnId. Noted, not filed — line numbers in an issue body are not a defect.
  • Serial adjacency, re-derived against the 12 currently-open PRs rather than cited from a snapshot: exactly one touches packages/plugin-kanban/ at all, PR chore(deps): lucide-react 1.31.0 -> 1.43.0, with the one retired spelling repaired #8941, and only its package.json (a dependency bump). No open PR touches this package's source or tests. No overlap.
  • The card's own recorder was reproducible in shape but not in file: the existing columnsReachDomSameCommit-8534.test.tsx snapshots the DOM per commit at the KanbanImpl seam, a different instrument, and it never drives a cross-column drop. The columns-prop recorder was therefore built here as a module mock, matching the card's description so the readings are directly comparable.

What the maintainer now needs in order to rule Q1 and Q2

The measurement changes the shape of question 2. The card framed today's behaviour as "let the user think it worked, then silently undo it" and offered a snap-back as the honest-but-dumber alternative. On the internal-data path the snap-back already happens — immediately, unprompted, with no animation or message. So:

  • for the internal path, Q2's "remove the optimistic update" is closer to naming the existing behaviour than to changing it; what is missing is that the snap is unexplained, not that it is absent;
  • for the external path the card's original framing holds unchanged;
  • whichever way Q1 goes, the landing index is available at the boundary today — KanbanImpl already computes and forwards it. Persisting in-column order would need a sink and a storage contract, but it would not need new plumbing to learn where the card was dropped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


Generated by Claude Code

…objectui#8826 Q3)

Answers the third, explicitly unmeasured question on the card: on a
cross-column move, is the landing POSITION within the target column also
lost? It is, on both data ownerships.

`KanbanImpl.handleDragEnd` computes a landing index, splices the card in at
it, and hands the index to `onCardMove`. `ObjectKanban.handleCardMove`
discards it (`void _newIndex`) and `persistCardMove` writes a body of exactly
the groupBy column. The index reaches a parameter and stops there.

The two ownerships differ in WHEN the loss becomes visible, which the card did
not anticipate:

  - internal data: the optimistic write re-renders the board, which re-buckets
    and re-syncs the mirror from the prop, so the card leaves the dropped slot
    immediately -- a visible snap, not a later silent revert.
  - external data: the optimistic write is skipped, nothing re-renders, and the
    dropped slot survives on screen until the parent's next reflow.

Characterization only -- zero behaviour change. The recorder captures the
`columns` prop on every board render, driven through the real `onDragEnd`, and
carries both a lit positive control (the drop does produce a new snapshot,
with the membership change in it) and a dead negative control (a drag that
ends on nothing records neither snapshot nor write).

The changeset declares an empty frontmatter: the package publishes `dist` only,
so a file under `src/__tests__/` releases nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3484.1 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-BTw3GnTG.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) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 11.08KB 4.58KB
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) 500.20KB 114.67KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 28.04KB 9.46KB
data-objectstack (index.js) 205.46KB 56.78KB
fields (index.js) 247.15KB 62.32KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.99KB 0.87KB
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 (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.63KB 19.99KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 132.41KB 34.84KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.19KB 65.62KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 210.86KB 57.28KB
plugin-kanban (index.js) 46.06KB 14.30KB
plugin-list (index.js) 112.54KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
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.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@os-steve
os-steve marked this pull request as ready for review September 10, 2026 13:32
@os-steve
os-steve added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit d81c9c7 Sep 10, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-8826-crosscolumn-landing-position-probe branch September 10, 2026 13:50
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.

2 participants