From a8b1cdfcb5235195296280a35c60c7aeec15c41b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:31:41 +0000 Subject: [PATCH 1/8] fix(plugin-kanban): announce "No cards" by card count, not by lane count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KanbanImpl` derived its board-level empty state from `totalCardCount === 0 && boardColumns.length > 1`. The second conjunct is a LANE COUNT, and it made the announcement unreachable on two shapes: a zero-lane board, where nothing on screen said anything at all, and a one-lane board, where the only "No cards" string was the lane's own dashed placeholder — a plain `span` with no `role` and no `aria-live`. `DataEmptyState` is the board's only `role="status" aria-live="polite"` region, so on both shapes a screen-reader user was told nothing. The predicate now asks whether there are any CARDS: `totalCardCount === 0`. ## Why now A lane-less `object-kanban` document could not pass validation until objectui#9021 made `ObjectKanbanSchema.groupBy` optional, as the protocol declares it. A schema-valid `{ type: 'object-kanban', objectName }` now reaches a board with no lane key. The predicate is older than that widening and this is NOT a defect the widening introduced; it is what made it reachable, and the new `LIVE PREMISE` leg asserts that document still parses green rather than inheriting the claim. ## The lane count was not the loading guard — measured, not assumed The plausible reading is that `> 1` separated "still loading" from "genuinely empty", since a board mid-flight can look lane-less. It did not. That distinction is carried by a SEPARATE conjunct, `recordsSettled` (objectui#8827), untouched here: `showEmptyState = isBoardEmpty && recordsSettled`. Two `STILL LOADING` legs drive a zero-lane and a one-lane board with the query held in flight, assert nothing is announced, then release it and assert the announcement arrives. Both halves are load-bearing — the first alone stays green if the empty state is deleted outright. ## What a one-lane board now renders Exactly what a multi-lane empty board has always rendered: the board-level live region, and no per-lane placeholder. `suppressEmptyPlaceholder` is unchanged; its stated reason is that the board-level state is already saying it, so a per-lane copy would be a duplicate — and on a one-lane empty board that reason is now TRUE where it used to be vacuous. ## Pins, and the two legs that are NOT evidence `emptyStateLaneCountBlind-9045.test.tsx` pins the zero-lane and one-lane rows separately, each with a lit control. A multi-lane board with cards announcing nothing, and a multi-lane board with no cards still announcing, are marked NON-REGRESSION: both were already correct on the parent commit, where they passed while the two subject rows failed. ## Prose this change falsified, corrected in place Three present-tense statements described the removed conjunct as live. Their assertions are untouched — `recordsSettledEmptyState-8827.test.tsx`'s diff is comment-only. `laneLessBoard-8990.test.tsx`'s `laneTitles` helper additionally scooped the empty state's `h3`, which only became reachable on a lane-less board here; it now excludes the live region, restoring what the helper says it returns. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9045-kanban-empty-state-lane-count.md | 59 ++++ packages/plugin-kanban/src/KanbanImpl.tsx | 25 +- .../plugin-kanban/src/KanbanRecordsSettled.ts | 6 +- .../emptyStateLaneCountBlind-9045.test.tsx | 268 ++++++++++++++++++ .../src/__tests__/laneLessBoard-8990.test.tsx | 29 +- .../recordsSettledEmptyState-8827.test.tsx | 18 +- 6 files changed, 388 insertions(+), 17 deletions(-) create mode 100644 .changeset/9045-kanban-empty-state-lane-count.md create mode 100644 packages/plugin-kanban/src/__tests__/emptyStateLaneCountBlind-9045.test.tsx diff --git a/.changeset/9045-kanban-empty-state-lane-count.md b/.changeset/9045-kanban-empty-state-lane-count.md new file mode 100644 index 0000000000..5d29d516f7 --- /dev/null +++ b/.changeset/9045-kanban-empty-state-lane-count.md @@ -0,0 +1,59 @@ +--- +'@object-ui/plugin-kanban': patch +--- + +The kanban board's "No cards" announcement no longer depends on how many lanes +it has (objectui#9045). + +`KanbanImpl` derived its board-level empty state from +`totalCardCount === 0 && boardColumns.length > 1`. The second conjunct is a +**lane count**, and it made the announcement unreachable on two shapes: + +- a **zero-lane** board — no lanes at all, so nothing on screen said anything; +- a **one-lane** board — the board-level live region never painted, and the only + "No cards" string was the lane's own dashed placeholder: a plain `span` with + no `role` and no `aria-live`. + +`DataEmptyState` is the board's only `role="status" aria-live="polite"` region, +so on both shapes a screen-reader user was told nothing at all. + +## Why now + +A lane-less `object-kanban` document could not pass validation until +objectui#9021 made `ObjectKanbanSchema.groupBy` optional, as the protocol +declares it. A schema-valid `{ type: 'object-kanban', objectName }` now reaches +a board with no lane key — zero cards, and a silent blank. The predicate is +older than that card and **this is not a defect #9021 introduced**; the widening +is what made it reachable. + +## What changed + +The predicate asks whether there are any **cards**: +`totalCardCount === 0`. Nothing else moved — no exported symbol, no schema, no +published payload. + +## ⚠️ The lane count was not guarding the loading state + +The plausible reading — that `> 1` separated "still loading" from "genuinely +empty", since a board mid-flight can look lane-less — was measured, not assumed. +It is wrong: that distinction is carried by a **separate** conjunct, +`recordsSettled` (objectui#8827), which this change does not touch. A zero-lane +and a one-lane board are each driven with their query held in flight and +announce nothing, then announce once it settles with no rows. + +## What a one-lane board now renders + +Exactly what a multi-lane empty board has always rendered: the board-level live +region, and no per-lane placeholder. `suppressEmptyPlaceholder` is unchanged — +its stated reason is that the board-level state is already saying it, so a +per-lane copy would be a duplicate, and on a one-lane empty board that reason is +now **true** where it used to be vacuous. The board-level region is a live +region and the placeholder never was, so the visible affordance moves up one +level while the announcement is gained. + +## What did not change + +A multi-lane board **with** cards still announces nothing, and a multi-lane +board with **no** cards still announces — both were already correct and both are +pinned as non-regressions, not as evidence of this fix. Nothing may announce +while the records are in flight, on any lane count. diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 3dbdb7ff4a..a349d33405 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -879,7 +879,19 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu const totalCardCount = boardColumns.reduce((sum, c) => sum + (c.cards?.length || 0), 0); // "This board holds no cards" — a fact about what was HANDED to this // component, true the instant it renders. - const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1; + // + // ⚠️ It reads the CARDS and deliberately not the LANE COUNT + // (objectui#9045). It used to also require more than one lane, which + // made the announcement below unreachable on a zero-lane board and on + // a one-lane board: `DataEmptyState` is the board's only + // `aria-live` region, so on those two shapes assistive technology was + // told nothing at all. A zero-lane board became authorable when + // objectui#9021 made `ObjectKanbanSchema.groupBy` optional, as the + // protocol declares it — which is what moved this from theoretical to + // reachable. ⛔ The lane count never separated "still loading" from + // "genuinely empty"; `recordsSettled` below is the conjunct that does, + // and it is untouched by this. + const isBoardEmpty = totalCardCount === 0; // "This board HAS no cards" — a fact about the DATA, which is only // knowable once the records have settled (objectui#8827). Before // #8827 the two were the same expression, so a board whose lazy chunk @@ -1076,9 +1088,16 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu // means the BOARD-level empty state above is already saying it, // so a per-column copy would be a duplicate. `!recordsSettled` // means nobody may say it yet: the placeholder renders the same - // `kanban.noCards` string, so leaving it ungated would have kept - // the false claim alive on any board with a single lane — where + // `kanban.noCards` string, and leaving it ungated would keep that + // false claim alive on a board that is only PARTLY empty — some + // lanes already holding rows while a refetch is in flight, where // `isBoardEmpty` is false and the board-level gate never runs. + // ⚠️ objectui#9045 widened the first reason rather than adding + // one: now that `isBoardEmpty` is blind to the lane count, a + // one-lane empty board reaches the board-level region and gives + // up its own placeholder to it — the same trade a multi-lane + // empty board has always made, and the reason the duplicate + // clause above is TRUE there instead of merely vacuous. suppressEmptyPlaceholder={isBoardEmpty || !recordsSettled} countsAreWindowed={countsAreWindowed} /> diff --git a/packages/plugin-kanban/src/KanbanRecordsSettled.ts b/packages/plugin-kanban/src/KanbanRecordsSettled.ts index 439f4cc920..eec86c9457 100644 --- a/packages/plugin-kanban/src/KanbanRecordsSettled.ts +++ b/packages/plugin-kanban/src/KanbanRecordsSettled.ts @@ -14,8 +14,10 @@ import { createContext, useContext } from 'react'; * ## What this exists to stop * * `KanbanImpl` paints `DataEmptyState` — a `role="status" aria-live="polite"` - * live region titled "No cards" — whenever the board holds zero cards across - * more than one lane. That predicate is an ASSERTION ABOUT THE DATA, and the + * live region titled "No cards" — whenever the board holds zero cards. (It + * also required more than one lane until objectui#9045 removed that conjunct + * as unreachability rather than a guard; the lane count never had anything to + * do with settling.) That predicate is an ASSERTION ABOUT THE DATA, and the * component was making it before it had the data. * * The production shape is a board whose lanes come from view metadata diff --git a/packages/plugin-kanban/src/__tests__/emptyStateLaneCountBlind-9045.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStateLaneCountBlind-9045.test.tsx new file mode 100644 index 0000000000..25ecc03bef --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/emptyStateLaneCountBlind-9045.test.tsx @@ -0,0 +1,268 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9045 — the board's empty state is about CARDS, not about LANES. + * + * ## The defect, as measured on the parent commit + * + * `KanbanImpl` derived its board-level empty state from + * `totalCardCount === 0 && boardColumns.length > 1`. The second conjunct is a + * LANE COUNT, and it made the announcement unreachable on exactly two shapes: + * + * - a ZERO-lane board — no lanes at all, so nothing on screen says anything; + * - a ONE-lane board — the board-level live region never painted, and the + * only "No cards" string was the lane's own dashed placeholder, a plain + * `span` with no `role` and no `aria-live`. + * + * ⇒ On both, a screen-reader user was told nothing. `DataEmptyState` is the + * only `role="status" aria-live="polite"` region on the board, and the lane + * count decided whether it existed. + * + * ## ⭐ Why the premise is LIVE rather than theoretical + * + * A lane-less `object-kanban` document could not pass validation until + * objectui#9021 made `ObjectKanbanSchema.groupBy` optional, as the protocol + * declares it. The `ZERO LANES` legs below author exactly that document — + * `{ type: 'object-kanban', objectName }` with no lane key and no `columns` — + * and `LIVE PREMISE` asserts it still parses green on this tree, so the shape + * these legs measure is one an author can actually write. + * + * ## ⚠️ What the lane count was NOT doing — measured, not assumed + * + * The obvious reading of `boardColumns.length > 1` is that it separated "still + * loading" from "genuinely empty" — a board mid-flight can look lane-less. It + * did not, and the `STILL LOADING` legs are how that is established rather + * than argued: the loading/settled distinction is carried by a SEPARATE + * conjunct, `recordsSettled` (objectui#8827), which this card does not touch. + * Those legs drive a zero-lane and a one-lane board with their query held in + * flight and assert nothing is announced, then release the query and assert + * the announcement arrives. Both halves are required: the first alone would + * stay green if the empty state were deleted outright. + * + * ## ⚠️ The one-lane board is now treated exactly like a multi-lane one + * + * `suppressEmptyPlaceholder` is deliberately left alone. Its own stated reason + * is that the board-level empty state is already saying it, so a per-lane copy + * would be a duplicate — and on a one-lane empty board that reason is now TRUE + * where it used to be vacuous. So the lane's dashed placeholder gives way to + * the live region, which is the treatment a multi-lane empty board has always + * had. `ONE LANE` asserts the announcement AND that it is not doubled. + * + * ## ⚠️ Which legs are CONTROLS and are ⛔ not evidence of this fix + * + * `NON-REGRESSION` marks the two multi-lane legs. Both were already correct + * before this card and both are unchanged by it; they are here to catch a + * repair that widened the predicate into "always announce" or narrowed it into + * "never announce". ⛔ Do not read them as showing that anything was fixed. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { ObjectKanbanSchema, safeValidateSchema } from '@object-ui/types/zod'; +// Registers `object-kanban`. +import '../index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +const TWO_LANES = [ + { id: 'todo', title: 'To Do' }, + { id: 'in_progress', title: 'In Progress' }, +]; +const ONE_LANE = [{ id: 'todo', title: 'To Do' }]; + +const ROWS = [ + { id: '1', name: 'Alpha', status: 'todo' }, + { id: '2', name: 'Beta', status: 'in_progress' }, +]; + +/** + * The board's only live region — `role="status" aria-live="polite"`, titled + * "No cards". Queried off `document` rather than a render container so a + * portalled subtree could not read as an absence. + */ +const liveRegion = () => document.querySelector('[role="status"][aria-live="polite"]'); + +/** Whether the board ANNOUNCES, as assistive technology would learn of it. */ +const announces = () => { + const el = liveRegion(); + return !!el && (el.textContent ?? '').includes('No cards'); +}; + +/** Every "No cards" string on screen — live region and per-lane placeholder alike. */ +const noCardsTextCount = () => + [...document.querySelectorAll('*')].filter( + (el) => el.children.length === 0 && el.textContent?.trim() === 'No cards', + ).length; + +/** A deferred promise, so `find` can be left in flight for as long as a leg needs. */ +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * Pump the event loop inside `act` until `pred()` holds or the budget expires, + * and REPORT whether it did — a rig that never completed must not read as an + * absence. + */ +async function pumpUntil(pred: () => boolean, budgetMs = 3000): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (pred()) return true; + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } + return pred(); +} + +function renderBoard(schema: Record, rows: unknown) { + const find = vi.fn(() => + rows instanceof Promise ? rows : Promise.resolve({ data: rows, total: (rows as unknown[]).length }), + ); + const dataSource = { find, findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }; + const result = render( + + + , + ); + return { ...result, find }; +} + +/** + * Settle the board and PROVE it settled: the query must have been issued and + * resolved before any "nothing is announced" reading is taken, or the reading + * is about a board that is merely still loading. + */ +async function settle(find: ReturnType) { + await waitFor(() => expect(find).toHaveBeenCalled()); + await pumpUntil(() => true, 50); +} + +afterEach(cleanup); + +describe('objectui#9045 — LIVE PREMISE: a lane-less board is a document an author may write', () => { + it('parses green on both published faces, so the ZERO-lane legs measure a reachable shape', () => { + const laneless = { type: 'object-kanban', objectName: 'deal' }; + expect( + ObjectKanbanSchema.safeParse(laneless).success, + 'objectui#9021 made `groupBy` optional; without that this shape is unauthorable', + ).toBe(true); + expect(safeValidateSchema(laneless).success, 'the union entry path must agree').toBe(true); + }); +}); + +describe('objectui#9045 — ZERO LANES: an empty lane-less board announces', () => { + it('paints the live region once its records have settled with nothing', async () => { + const { find } = renderBoard({ type: 'object-kanban' }, []); + await settle(find); + const announced = await pumpUntil(announces); + expect(announced, 'a zero-lane board holds no cards and must say so').toBe(true); + expect((liveRegion()!.textContent ?? '')).toContain('No cards'); + }); + + it('STILL LOADING — announces NOTHING while its query is in flight, then announces once it lands', async () => { + const rows = deferred<{ data: unknown[] }>(); + const { find, container } = renderBoard({ type: 'object-kanban' }, rows.promise); + + // Lit control for the absence below: the board is really mounted and its + // query is really outstanding, so "nothing announced" is a reading about a + // loading board rather than about a board that never rendered. + const mounted = await pumpUntil( + () => !!container.querySelector('[role="region"][aria-label="Kanban board"]'), + ); + expect(mounted, 'RIG SELF-CHECK: the board must be on screen').toBe(true); + await waitFor(() => expect(find).toHaveBeenCalled()); + expect(announces(), 'nobody may claim the board is empty before the answer arrives').toBe(false); + + // Settling with nothing IS a settled answer, and now it may be said. + await act(async () => { + rows.resolve({ data: [] }); + await rows.promise; + }); + const announced = await pumpUntil(announces); + expect(announced, 'withholding it forever is the regression this must not trade for').toBe(true); + }); +}); + +describe('objectui#9045 — ONE LANE: an empty single-lane board announces, exactly once', () => { + it('paints the live region, and does not also leave the lane placeholder saying it', async () => { + const { find } = renderBoard({ type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, []); + await settle(find); + const announced = await pumpUntil(announces); + expect(announced, 'one lane is still a board that holds no cards').toBe(true); + expect( + noCardsTextCount(), + 'the board-level region says it; a per-lane copy would be a duplicate', + ).toBe(1); + }); + + it('LIT CONTROL — the same single-lane board WITH a card announces nothing', async () => { + const { find, container } = renderBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + [ROWS[0]], + ); + await settle(find); + // The card really landed — this is what makes the silence below a reading + // about a populated board rather than about a board that never got rows. + await waitFor(() => expect(container.textContent).toContain('Alpha')); + expect(announces(), 'a board holding a card is not empty, whatever its lane count').toBe(false); + }); + + it('STILL LOADING — announces NOTHING while its query is in flight, then announces once it lands', async () => { + const rows = deferred<{ data: unknown[] }>(); + const { find } = renderBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + rows.promise, + ); + + // Lit control: the lane itself is on screen and the query is outstanding. + const laneUp = await pumpUntil( + () => !!document.querySelector('[role="list"][aria-label="To Do cards"]'), + ); + expect(laneUp, 'RIG SELF-CHECK: the lane must be on screen').toBe(true); + await waitFor(() => expect(find).toHaveBeenCalled()); + expect(announces(), 'nobody may claim the board is empty before the answer arrives').toBe(false); + + await act(async () => { + rows.resolve({ data: [] }); + await rows.promise; + }); + const announced = await pumpUntil(announces); + expect(announced).toBe(true); + }); +}); + +describe('objectui#9045 — NON-REGRESSION: the multi-lane readings are unchanged by this card', () => { + it('⛔ NOT evidence of this fix — a multi-lane board WITH cards still announces nothing', async () => { + const { find, container } = renderBoard( + { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, + ROWS, + ); + await settle(find); + await waitFor(() => expect(container.textContent).toContain('Alpha')); + expect(announces()).toBe(false); + }); + + it('⛔ NOT evidence of this fix — a multi-lane board with NO cards still announces, as it always did', async () => { + const { find } = renderBoard( + { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, + [], + ); + await settle(find); + const announced = await pumpUntil(announces); + expect(announced).toBe(true); + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx index b1394965fb..c3ea48ff0d 100644 --- a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx +++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx @@ -183,9 +183,21 @@ async function expectCards(container: HTMLElement, name: string) { await waitFor(() => expect(container.textContent).toContain(name)); } -/** Lane headings as drawn, in DOM order. */ +/** + * Lane headings as drawn, in DOM order. + * + * ⚠️ The `h3, h4` arms are a net, not a contract, and the board-level empty + * state renders its "No cards" title as an `h3` — which is NOT a lane heading. + * It only ever landed in this net once objectui#9045 made that region reachable + * on a lane-less board; before then the zero-lane leg below was reading a board + * that had no such region. Excluding the live region restores what this helper + * says it returns. ⛔ Not a loosening: the legs that assert ON lane titles + * compare against lane VALUES and picklist LABELS, neither of which this filter + * can remove. + */ function laneTitles(container: HTMLElement): string[] { return Array.from(container.querySelectorAll('[data-slot="kanban-column-title"], h3, h4')) + .filter((el) => !el.closest('[role="status"][aria-live="polite"]')) .map((el) => (el.textContent ?? '').trim()) .filter(Boolean); } @@ -287,12 +299,15 @@ describe('objectui#8990 — the bare-string `columns` arm FIRES on a lane-less b }); it('a lane-less board with NO `columns` renders an EMPTY board rather than crashing', async () => { - // ⚠️ This leg cannot settle on the objectui#8827 empty state: `KanbanImpl` - // gates it on `boardColumns.length > 1`, so a ZERO-lane board never paints - // it. (Pre-existing and independent of this card — the predicate does not - // read `groupBy`.) It settles on the board region instead, and takes its - // credibility from the paired control below, which shares the whole rig and - // differs only by the lane key. + // ⚠️ When this was written, this leg COULD NOT settle on the objectui#8827 + // empty state: `KanbanImpl` gated it on `boardColumns.length > 1`, so a + // ZERO-lane board never painted it. objectui#9045 removed that conjunct and + // the region is now painted here too — ⛔ that is the very gap this leg's + // own comment recorded, not a change of subject. The settle signal is left + // on the board region so this leg keeps measuring what it always measured + // (lanes and rows, neither of which arrives), and takes its credibility + // from the paired control below, which shares the whole rig and differs + // only by the lane key. const laneLess = await renderBoard({ type: 'object-kanban', objectName: 'task' }); await waitFor(() => expect(laneLess.find).toHaveBeenCalled()); await waitFor(() => diff --git a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx index 5a75f70c92..e305a976bb 100644 --- a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx +++ b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx @@ -301,11 +301,19 @@ describe('objectui#8827 — the per-lane placeholder is the same claim and takes /** * `KanbanColumnView` renders the SAME `kanban.noCards` string inside any lane * with no cards, suppressed only when the board-level empty state is already - * saying it. On a SINGLE-lane board `isBoardEmpty` is false — it requires - * `boardColumns.length > 1` — so the board-level gate never runs there and - * the placeholder was the only thing on screen, still claiming "No cards" - * over rows in flight. Gating only the live region would have left the false - * claim alive on exactly the boards the live region never covered. + * saying it. When this was written, `isBoardEmpty` additionally required + * `boardColumns.length > 1`, so on a SINGLE-lane board the board-level gate + * never ran and the placeholder was the only thing on screen, still claiming + * "No cards" over rows in flight. Gating only the live region would have left + * the false claim alive on exactly the boards the live region never covered. + * + * ⚠️ objectui#9045 has since made `isBoardEmpty` blind to the lane count, so + * a settled single-lane empty board now reaches the BOARD-level region and + * the placeholder gives way to it. ⭐ Both legs below are unchanged and both + * still measure what they always did: nothing may say "No cards" while the + * rows are in flight, and something must say it once they settle with none. + * ⛔ What changed is which element says it, which neither leg reads — + * `emptyStateLaneCountBlind-9045.test.tsx` is where that is pinned. */ const ONE_LANE = [{ id: 'todo', title: 'To Do' }]; From 7c0a3a9c4bbeeaefc158715ac2dc079077d97369 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:49:40 +0000 Subject: [PATCH 2/8] fix(plugin-kanban,i18n): resolve the empty board's lane count through a plural family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KanbanImpl` composed the board-level empty state's description by CONCATENATION — the lane count, a space, then `t('kanban.columns')`, which every pack declared as a bare plural unit word with no singular form. Read from the live region's own `textContent`, a one-lane board announced "No cards1 columns", and `DataEmptyState` there is `role="status" aria-live="polite"`, so it is read aloud rather than merely printed. ## Why the string was correct until this branch The bare plural was not a latent bug. The empty state used to require `boardColumns.length > 1`, so the count in front of `columns` was never 1 and the plural always agreed with it. The previous commit on this branch removed that conjunct so a zero-lane and a one-lane board announce at all — that widening IS the accessibility fix — and the one-lane form became reachable with it. The repair is the plural family, never a retreat to the old predicate. ## What changed Ten locale packs gain a real i18next plural family for `kanban.columns`: the base key now carries the count itself, and `_one` / `_other` are new. The call site resolves it, `t('kanban.columns', { count: boardColumns.length })`. The shape is `detail.repeaterItemCount`'s (base + `_one` + `_other`), not `chatbot.plan.*`'s (base + `_one`). The base key is the slot every CLDR category a pack does not enumerate falls to, and a kanban board's everyday two to four lanes are exactly `ru`'s `few`; spelling `ru`'s base as its numeral plural renders "3 колонок" there, a genitive plural after a numeral that governs the genitive singular. A `_few` key is not available — `en` lacks it and `all-locales-key-parity` fails a key `en` lacks by design — so `ru`'s base is the category-neutral "Колонок: {{count}}" and its `_other` carries the numeral form. `zh` / `ja` / `ko` define all three slots with the SAME value. No singular is manufactured for languages that have none; the `_one` key exists only because the parity gate requires every `en` key in every pack and reads a legitimately-absent half as a lost key. ## The tripwire is re-derived, not deleted `residue-namespaces-3546.test.tsx` pinned the `> 1` guard by source text, with its own comment stating why: the count could never be 1, so no plural family was needed. It fired exactly as designed. Its premise is gone, so it now asserts the NEW state of the world in four legs — the predicate is still lane-count-blind (one lane is REACHABLE), the description is one `t()` call carrying a `count` and nothing concatenates a lane count in front of a unit word again, all ten packs carry the family, and it RENDERS "1 column" at one lane and "2 columns" at two. The sibling pin on the `description={…}` call-site shape moved with it. ## The measurement on the card becomes the test `emptyStatePluralLaneCount-9170.test.tsx` reads the live region's own `textContent` at zero, one and two lanes through a mounted provider. Zero and two are CONTROLS — both grammatical before this change and unchanged by it — and only the one-lane row is evidence. `ru` at one and three lanes shows the family landing in language rather than through `fallbackLng`. The provider-LESS path is pinned as it is, not as it should be: `createSafeTranslation`'s `fallbackT` resolves its defaults table literally and never appends a plural suffix (the mechanism `useDetailTranslation.ts` records for `detail.showEmptyRelated`), so an embedder with no `I18nProvider` reads the same English it read before this change. That is objectui#3865's family, not this one's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9045-kanban-empty-state-lane-count.md | 9 +- .../9170-kanban-columns-plural-family.md | 72 ++++++ .../residue-namespaces-3546.test.tsx | 151 ++++++++++-- packages/i18n/src/locales/ar.ts | 6 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 29 ++- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 7 +- packages/i18n/src/locales/zh.ts | 4 +- packages/plugin-kanban/src/KanbanImpl.tsx | 11 +- .../emptyStatePluralLaneCount-9170.test.tsx | 226 ++++++++++++++++++ 15 files changed, 500 insertions(+), 39 deletions(-) create mode 100644 .changeset/9170-kanban-columns-plural-family.md create mode 100644 packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx diff --git a/.changeset/9045-kanban-empty-state-lane-count.md b/.changeset/9045-kanban-empty-state-lane-count.md index 5d29d516f7..2636133ff0 100644 --- a/.changeset/9045-kanban-empty-state-lane-count.md +++ b/.changeset/9045-kanban-empty-state-lane-count.md @@ -29,8 +29,13 @@ is what made it reachable. ## What changed The predicate asks whether there are any **cards**: -`totalCardCount === 0`. Nothing else moved — no exported symbol, no schema, no -published payload. +`totalCardCount === 0`. No exported symbol moved and no schema moved. + +⚠️ This paragraph used to end "no published payload" as well, and that stopped +being true inside this same pull request: making the region paint at one lane +made `"1 columns"` reachable, so objectui#9170 pluralises `kanban.columns` across +all ten locale packs. That is a published-payload change, and it is declared in +its own changeset beside this one. ## ⚠️ The lane count was not guarding the loading state diff --git a/.changeset/9170-kanban-columns-plural-family.md b/.changeset/9170-kanban-columns-plural-family.md new file mode 100644 index 0000000000..47d437d4c9 --- /dev/null +++ b/.changeset/9170-kanban-columns-plural-family.md @@ -0,0 +1,72 @@ +--- +'@object-ui/i18n': minor +'@object-ui/plugin-kanban': minor +--- + +**BREAKING** — `kanban.columns` is now an i18next plural family, not a bare unit +word (objectui#9170). + +The kanban board's empty state composed its description by **concatenation**: +the lane count, a space, then `t('kanban.columns')`, which every pack declared as +a bare plural with no singular form. Read from the live region's own +`textContent`, a one-lane board announced `"No cards1 columns"` — and +`DataEmptyState` there is `role="status" aria-live="polite"`, so this is read +aloud. + +## Why the old string was correct until it wasn't + +The bare plural was not a latent bug. The empty state used to require +`boardColumns.length > 1`, so the count in front of `columns` could never be 1 +and the plural always agreed with it. objectui#9045 made the region paint at zero +and one lane — that widening **is** the accessibility fix, and a wrong plural is +strictly better than the silence it replaced — and the one-lane form became +reachable with it. + +## What changed on the published payload + +Per pack, in all ten locales: + +- `kanban.columns` now carries the count itself, e.g. `en` `'{{count}} columns'`; +- `kanban.columns_one` and `kanban.columns_other` are new. + +The call site resolves the family (`t('kanban.columns', { count })`) instead of +putting a number in front of a unit word. + +## Why this is **BREAKING** and why it is declared `minor` + +The value of an **existing published key** changed. A consumer outside this +repository that read `kanban.columns` as a bare unit word and concatenated a +count in front of it now renders a literal `{{count}}`, because i18next leaves an +unfilled placeholder verbatim when no `count` is passed. Pass `{ count }` and the +key answers correctly, including the singular. + +`minor`, not `major`: every package in this repository sits in one `fixed` group, +so a level is a property of the whole release rather than of one package, and +this repository aligns its major with `@objectstack`'s — `major` is refused +mechanically by `check-changeset-no-major.mjs`. Breaking changes ship as `minor` +with this carrier, which is the convention `AGENTS.md` states. + +## The shape, and why not the other one + +Both plural shapes have precedent here. This key takes **base + `_one` + +`_other`** (the `detail.repeaterItemCount` shape) rather than **base + `_one`** +(the `chatbot.plan.*` shape), because the base key is the slot every CLDR +category a pack does not enumerate falls to — and a kanban board's everyday two +to four lanes are exactly Russian's `few`. Spelling `ru`'s base as its numeral +plural would render `"3 колонок"` there, a genitive plural after a numeral that +governs the genitive singular; a `_few` key is not available, because `en` lacks +it and `all-locales-key-parity` fails a key `en` lacks by design. So `ru`'s base +is the category-neutral `"Колонок: {{count}}"` and its `_other` carries the +numeral form. + +`zh` / `ja` / `ko` define all three slots with the **same** value. No singular is +invented for languages that have none: the `_one` key exists only because key +parity requires every `en` key in every pack. + +## What did not change + +The predicate stays lane-count-blind — a zero-lane and a one-lane board still +announce, which is the whole point of objectui#9045. The provider-less path is +unchanged: `createSafeTranslation`'s fallback resolves its defaults table +literally and never appends a plural suffix, so an embedder with no +`I18nProvider` mounted reads the same English it read before. diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index 5ba5877fe4..bc91d98d3c 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -285,7 +285,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { } }); - it('exactly one path interpolates, and every pack carries the same hole', () => { + it('exactly two paths interpolate, and every pack carries the same hole', () => { // A translator who drops `{{name}}` renders a sentence with the source name // missing; one who invents a second hole renders braces verbatim. // `all-locales-key-parity` compares placeholder shape too — this states the @@ -295,12 +295,33 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // `lastIndex` (slice five's own bug). const HOLES = /\{\{\w+\}\}/g; const HAS_HOLE = /\{\{\w+\}\}/; - const INTERPOLATED = ['empty.interfacePageSourceMissing']; - expect(KEYS.filter((k) => HAS_HOLE.test(at(builtInLocales.en, k) as string)).sort()).toEqual(INTERPOLATED); + // + // objectui#9170 added the SECOND: `kanban.columns` was a bare unit word its + // call site concatenated a number in front of, and is now a plural family + // whose members carry the count themselves. The expected hole is named PER + // KEY rather than shared, so a pack that swaps `{{count}}` for `{{name}}` — + // or drops either — is still legible here. + const INTERPOLATED: Record = { + 'empty.interfacePageSourceMissing': '{{name}}', + 'kanban.columns': '{{count}}', + }; + expect(KEYS.filter((k) => HAS_HOLE.test(at(builtInLocales.en, k) as string)).sort()).toEqual( + Object.keys(INTERPOLATED).sort(), + ); for (const lang of LANGS) { for (const key of KEYS) { const holes = ((at(builtInLocales[lang], key) as string).match(HOLES) ?? []).join(','); - expect(holes, `${lang}.${key}`).toBe(INTERPOLATED.includes(key) ? '{{name}}' : ''); + expect(holes, `${lang}.${key}`).toBe(INTERPOLATED[key] ?? ''); + } + } + // …and every MEMBER of that family carries the same hole in every pack. + // `KEYS` is the slice-seven residue list — a historical set that deliberately + // does not grow — so the two suffixed leaves are named here instead. + for (const lang of LANGS) { + for (const member of ['kanban.columns_one', 'kanban.columns_other']) { + const value = at(builtInLocales[lang], member); + expect(typeof value, `${lang}.${member}`).toBe('string'); + expect(((value as string).match(HOLES) ?? []).join(','), `${lang}.${member}`).toBe('{{count}}'); } } }); @@ -330,7 +351,10 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { INTERFACE_LIST, 'This interface page references "{{name}}", which is not available.', ], - ['kanban.columns', KANBAN, 'columns'], + // objectui#9170 — the pack value and the inline default moved together, + // which is exactly what this case exists to hold. See the family case + // below for why there is no `defaultValue_one` beside it. + ['kanban.columns', KANBAN, '{{count}} columns'], ['layout.systemNav.administration', UNIFIED_SIDEBAR, 'Administration'], ['layout.systemNav.datasources', APP_SIDEBAR, 'Datasources'], ['layout.systemNav.documentation', UNIFIED_SIDEBAR, 'Documentation'], @@ -678,34 +702,106 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.de, 'workspace.multiOrgDisabled')).toContain('ist auf dieser Instanz deaktiviert'); }); - it('kanban.columns is a bare unit word and follows the repo one precedent for that', () => { - // The call site concatenates: `` `${boardColumns.length} ${t('kanban.columns')}` ``, so - // the pack supplies a UNIT, not a sentence — the same structure as - // `preview.history.items` (slice five, which had to be corrected once for - // exactly this reason). `en` is plural-only and that is safe here: the empty - // state only renders when `boardColumns.length > 1`, so the count is never 1 - // and no plural family is needed. + it('kanban.columns is a plural family the call site resolves with a count', () => { + // ⭐ RE-DERIVED, ⛔ not deleted (objectui#9170). This case used to assert the + // OPPOSITE premise — that `en` may be plural-only here because the empty state + // only rendered when `boardColumns.length > 1`, so the count could never be 1 + // and no plural family was needed. objectui#9169 removed that conjunct, which + // IS the accessibility fix (the region now paints at zero and one lane), and + // the old pin fired exactly as it was written to: the guard it named was the + // whole reason a bare plural was safe. What it guarded is gone, so what it + // asserts is now the NEW state of the world — the bare plural is safe because + // there is no longer a bare plural. + // + // Four legs, chosen so the defect fails here by whichever route it returns: + // + // (1) the predicate is still lane-count-blind, so ONE LANE IS REACHABLE — + // this is what makes the family load-bearing rather than decorative, + // and a later card that restores a `> 1` guard lands on this comment; + // (2) the description is one `t()` call carrying a `count`, and NOTHING + // concatenates a lane count in front of a unit word again; + // (3) all ten packs really carry a family (base + `_one` + `_other`), so + // (2) has something to resolve; + // (4) it RENDERS "1 column" at one lane and "2 columns" at two — the + // behaviour, through the provider, so a family that exists but is never + // reached still fails. + // + // ⚠️ (4) is the leg that cannot be satisfied by an assertion that merely stops + // checking: deleting the family, dropping `_one`, or dropping `count:` from + // the call site each turn one of these red. const src = sourceOf(KANBAN); - expect(src, 'the columns count label moved').toContain( - "description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`}", - ); - expect(src, 'the >1 guard moved — a plural family would now be required').toContain( - 'const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1;', + // (1) + expect( + src, + 'the lane-count blindness moved — the family below may no longer be reachable; re-read objectui#9170', + ).toContain('const isBoardEmpty = totalCardCount === 0;'); + // (2) + expect(src, 'the lane-count description moved').toContain( + "description={t('kanban.columns', { count: boardColumns.length, defaultValue: '{{count}} columns' })}", ); - // The precedent's shape, per pack: unit word only, no counter particle, since - // the call site already inserts the space and the number. + expect( + src, + 'a lane count is concatenated in front of a unit word again — that is the defect objectui#9170 repaired', + ).not.toMatch(/\$\{boardColumns\.length\}\s*\$\{/); + // (3) — the shape is `repeaterItemCount`'s (base + `_one` + `_other`), not + // `chatbot.plan.*`'s (base + `_one`), because the base is the slot every CLDR + // category a pack does not enumerate lands on and a kanban board's everyday + // lane counts 2-4 are exactly `ru`'s `few`. See the note in `en.ts`. + for (const lang of LANGS) { + for (const slot of ['kanban.columns', 'kanban.columns_one', 'kanban.columns_other']) { + const value = at(builtInLocales[lang], slot); + expect(typeof value, `${lang}.${slot}`).toBe('string'); + expect((value as string).trim().length, `${lang}.${slot} is empty`).toBeGreaterThan(0); + } + } + // (4) + const render = (lang: LocaleCode) => { + window.localStorage.clear(); + return renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }).result; + }; + const en = render('en'); + expect(en.current.t('kanban.columns', { count: 0 })).toBe('0 columns'); + expect(en.current.t('kanban.columns', { count: 1 })).toBe('1 column'); + expect(en.current.t('kanban.columns', { count: 2 })).toBe('2 columns'); + // ⚠️ `ru`'s BASE is deliberately NOT its `_other` form. `few` (2-4 lanes) and + // `many` (5+) both land on the base, and spelling the base as the numeral form + // "{{count}} колонок" renders "3 колонок" there — a genitive PLURAL after a + // numeral that governs the genitive singular. A `_few` key is not available: + // `en` lacks it, and `all-locales-key-parity` fails a key `en` lacks by + // design. So the base is category-neutral and `_other` carries the numeral + // form, the same device `repeaterItemCount` uses next door. + const ru = render('ru'); + expect(ru.current.t('kanban.columns', { count: 1 })).toBe('1 колонка'); + expect(ru.current.t('kanban.columns', { count: 3 })).toBe('Колонок: 3'); + expect(at(builtInLocales.ru, 'kanban.columns')).not.toBe(at(builtInLocales.ru, 'kanban.columns_other')); + // ⛔ zh / ja / ko define all three slots with the SAME value. Nothing is + // manufactured there — those languages make no singular/plural distinction, + // and the `_one` key exists only because `all-locales-key-parity` requires + // every `en` key in every pack and reads a legitimately-absent half as a lost + // key. Asserted as an equality so a later "translation" of the singular is + // visible as the invention it would be. + for (const lang of ['zh', 'ja', 'ko'] as const) { + expect(at(builtInLocales[lang], 'kanban.columns_one'), `${lang} invented a singular`).toBe( + at(builtInLocales[lang], 'kanban.columns'), + ); + expect(at(builtInLocales[lang], 'kanban.columns_other')).toBe(at(builtInLocales[lang], 'kanban.columns')); + } + // The precedent this key used to follow — a bare unit word with the number + // concatenated on at the call site — is `preview.history.items`, untouched by + // this card and pinned here so a reader can see which shape was LEFT rather + // than assume the two still agree. expect(at(builtInLocales.en, 'preview.history.items')).toBe('item(s)'); expect(at(builtInLocales.ko, 'preview.history.items')).toBe('항목'); expect(at(builtInLocales.ru, 'preview.history.items')).toBe('элементов'); - // …and the WORD comes from kanban's own column vocabulary, which is not the - // table's: ja says カラム here and 列 in `table.columns`, ru колонка against - // столбец. + // …and the WORD still comes from kanban's own column vocabulary, which is not + // the table's: ja says カラム here and 列 in `table.columns`, ru колонка + // against столбец. expect(at(builtInLocales.ja, 'kanban.addColumn')).toBe('カラムを追加'); expect(at(builtInLocales.ja, 'table.columns')).toBe('列'); - expect(at(builtInLocales.ja, 'kanban.columns')).toBe('カラム'); + expect(at(builtInLocales.ja, 'kanban.columns')).toBe('{{count}} カラム'); expect(at(builtInLocales.ru, 'kanban.addColumn')).toBe('Добавить колонку'); - expect(at(builtInLocales.ru, 'kanban.columns')).toBe('колонок'); - expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열'); + expect(at(builtInLocales.ru, 'kanban.columns_one')).toBe('{{count}} колонка'); + expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열 {{count}}개'); }); it('detail.concurrentUpdateRecordLabel is grammatical in the sentence that embeds it', () => { @@ -908,7 +1004,10 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ); expect(t('layout.systemNav.administration')).toBe('管理'); expect(t('workspace.multiOrgDisabled')).toBe('此实例已禁用创建新组织。'); - expect(t('kanban.columns')).toBe('列'); + // Read WITH a count now that this key is a plural family: without one + // i18next returns the base value with its `{{count}}` unfilled, which is a + // reading about the lookup rather than about the Chinese. + expect(t('kanban.columns', { count: 2 })).toBe('2 列'); expect(t('detail.deleted')).toBe('记录已删除'); }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 259bf7a2bc..e66b28dd88 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -683,7 +683,11 @@ const ar = { }, kanban: { uncategorized: "غير مصنّف", - columns: "أعمدة", + // The base serves `zero`/`two`/`few`/`many`, which Arabic reaches at + // everyday counts; it carries both forms the way `repeaterItemCount` does. + columns: "{{count}} عمود (أعمدة)", + columns_one: "{{count}} عمود", + columns_other: "{{count}} أعمدة", addCard: "إضافة بطاقة", addColumn: "إضافة عمود", moveCard: "نقل بطاقة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 10ed5d5594..2606a79849 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -679,7 +679,9 @@ const de = { }, kanban: { uncategorized: "Nicht kategorisiert", - columns: "Spalten", + columns: "{{count}} Spalten", + columns_one: "{{count}} Spalte", + columns_other: "{{count}} Spalten", addCard: "Karte hinzufügen", addColumn: "Spalte hinzufügen", moveCard: "Karte verschieben", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 6a6314192f..ce1d4a2c1b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -803,7 +803,34 @@ const en = { noCards: 'No cards', cardTitlePlaceholder: 'Enter card title…', uncategorized: 'Uncategorized', - columns: 'columns', + // objectui#9170 — the board-level empty state's description. A REAL i18next + // plural family (base + `_one` + `_other`, the `repeaterItemCount` shape), + // replacing a BARE UNIT WORD the call site concatenated a number in front of. + // + // The bare word was safe only while `KanbanImpl`'s empty state required + // `boardColumns.length > 1`: the count could never be 1, so `columns` always + // agreed with it. objectui#9169 makes the region paint at zero and one lane — + // that widening IS the accessibility fix — and with it "1 columns" became + // reachable in a `role="status" aria-live="polite"` region. + // + // Why `_other` is spelled out rather than left to the base: the base is the + // slot every CLDR category a pack does not enumerate lands on, and on a + // kanban board the everyday lane counts 2-4 are exactly `ru`'s `few`. + // Measured on i18next 26.4.0 — with the base spelled as the `_other` form + // ("{{count}} колонок") `ru` renders "3 колонок" at three lanes, a genitive + // PLURAL after a numeral that governs the genitive singular. Giving `ru` a + // `_few` is not available (a key `en` lacks fails `all-locales-key-parity` + // by design), so `ru`'s base carries a category-neutral "Колонок: {{count}}" + // and its `_other` carries the numeral form — the same device + // `repeaterItemCount` uses, for the same reason. + // + // zh / ja / ko define all three slots with the SAME value. Nothing is + // invented there: those languages make no singular/plural distinction, and + // the `_one` key exists only because `all-locales-key-parity` requires every + // `en` key in every pack and reads a legitimately-absent half as a LOST key. + columns: '{{count}} columns', + columns_one: '{{count}} column', + columns_other: '{{count}} columns', requiredFieldsTitle: 'Complete required fields', requiredFieldsDescription: 'This move makes the fields below required. Fill them in to continue.', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 67ae51006c..b68545fc3c 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -683,7 +683,9 @@ const es = { }, kanban: { uncategorized: "Sin categoría", - columns: "columnas", + columns: "{{count}} columnas", + columns_one: "{{count}} columna", + columns_other: "{{count}} columnas", addCard: "Añadir tarjeta", addColumn: "Añadir columna", moveCard: "Mover tarjeta", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6b3a4c6a24..62036476aa 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -679,7 +679,9 @@ const fr = { }, kanban: { uncategorized: "Non catégorisé", - columns: "colonnes", + columns: "{{count}} colonnes", + columns_one: "{{count}} colonne", + columns_other: "{{count}} colonnes", addCard: "Ajouter une carte", addColumn: "Ajouter une colonne", moveCard: "Déplacer la carte", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d1d27c4854..64d223c115 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -679,7 +679,9 @@ const ja = { }, kanban: { uncategorized: "未分類", - columns: "カラム", + columns: "{{count}} カラム", + columns_one: "{{count}} カラム", + columns_other: "{{count}} カラム", addCard: "カードを追加", addColumn: "カラムを追加", moveCard: "カードを移動", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 7501a8d4ef..18059294f3 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -679,7 +679,9 @@ const ko = { }, kanban: { uncategorized: "미분류", - columns: "열", + columns: "열 {{count}}개", + columns_one: "열 {{count}}개", + columns_other: "열 {{count}}개", addCard: "카드 추가", addColumn: "열 추가", moveCard: "카드 이동", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 95f605c735..3bf4582cf3 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -678,7 +678,9 @@ const pt = { }, kanban: { uncategorized: "Sem categoria", - columns: "colunas", + columns: "{{count}} colunas", + columns_one: "{{count}} coluna", + columns_other: "{{count}} colunas", addCard: "Adicionar cartão", addColumn: "Adicionar coluna", moveCard: "Mover cartão", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b544418e17..0e0d644d3e 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -685,7 +685,12 @@ const ru = { }, kanban: { uncategorized: "Без категории", - columns: "колонок", + // The base is deliberately NOT the `_other` form: it is the slot `few` + // (2-4 lanes) and `many` (5+) land on, and "3 колонок" would be a genitive + // plural after a numeral that governs the genitive singular. See en.ts. + columns: "Колонок: {{count}}", + columns_one: "{{count}} колонка", + columns_other: "{{count}} колонок", addCard: "Добавить карточку", addColumn: "Добавить колонку", moveCard: "Переместить карточку", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a9ce007799..ce367ca878 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -710,7 +710,9 @@ const zh = { noCards: '暂无卡片', cardTitlePlaceholder: '输入卡片标题…', uncategorized: '未分类', - columns: '列', + columns: '{{count}} 列', + columns_one: '{{count}} 列', + columns_other: '{{count}} 列', requiredFieldsTitle: '填写必填字段', requiredFieldsDescription: '此次移动会使以下字段成为必填项。填写后即可继续。', }, diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index a349d33405..2bf1926751 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -910,7 +910,16 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu showIcon={false} className="rounded-lg border border-dashed border-border/60 bg-muted/10 py-8 gap-2 [&>h3]:text-sm [&>h3]:font-medium [&>h3]:text-foreground/80" title={t('kanban.noCards')} - description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`} + // The lane count is RESOLVED through the pack's plural family, never + // concatenated in front of a unit word (objectui#9170). The bare + // plural was safe only while this region required more than one lane; + // widening `isBoardEmpty` above made "1 columns" reachable here, and + // this region is read aloud. `defaultValue` is byte-identical to the + // `en` base value so the two paths cannot be told apart; it carries no + // `defaultValue_one` because nothing would read one — with a provider + // the pack answers, and without one `createSafeTranslation`'s + // `fallbackT` has no plural logic at all (objectui#3865, unchanged). + description={t('kanban.columns', { count: boardColumns.length, defaultValue: '{{count}} columns' })} /> )} diff --git a/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx new file mode 100644 index 0000000000..b7995e54b0 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx @@ -0,0 +1,226 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9170 — the board's empty state agrees with its own lane count. + * + * ## The defect, measured in the DOM on objectui#9169's branch + * + * `KanbanImpl` composed the board-level empty state's description by + * CONCATENATION — the lane count, a space, then `t('kanban.columns')`, which + * every pack declared as a bare plural unit word with no singular form. Reading + * the live region's own `textContent` at three lane counts: + * + * ZERO: "No cards0 columns" ← grammatical (English takes the plural at 0) + * ONE: "No cards1 columns" ← the defect + * TWO: "No cards2 columns" ← CONTROL, grammatical + * + * `DataEmptyState` there is `role="status" aria-live="polite"`, so this is read + * ALOUD, not merely printed. + * + * ## ⭐ Why the string was safe until objectui#9169 and is not any more + * + * The bare plural was not a latent bug that nobody noticed — it was CORRECT for + * every board that could reach it. The empty state used to require + * `boardColumns.length > 1`, so the count in front of `columns` was never 1. + * objectui#9169 removed that conjunct so a zero-lane and a one-lane board + * announce at all — that widening IS the accessibility fix — and the one-lane + * form became reachable with it. The repair is the plural family, ⛔ never a + * retreat to the old predicate. + * + * ## What is a CONTROL here and what is evidence + * + * `TWO LANES` is the control: it is unchanged by this card and it is what makes + * the `ONE LANE` reading a fact about the plural form rather than about a probe + * that reads the wrong node. `ZERO LANES` is a second control for the same + * reason — English takes the plural at zero, so it too must be unchanged. + * ⇒ Only the `ONE LANE` row is evidence of this repair. + * + * ## ⚠️ The provider-LESS path is deliberately pinned as it is, not as we want it + * + * `KanbanImpl` binds `createSafeTranslation`, whose `fallbackT` resolves + * `defaults[key]` literally and NEVER appends a plural suffix — the mechanism + * `packages/plugin-detail/src/useDetailTranslation.ts` records for + * `detail.showEmptyRelated` (objectui#3863). With no `I18nProvider` mounted the + * inline `defaultValue` answers and one lane still reads "1 columns". That is + * unchanged by this card, is no worse than before it, and belongs to + * objectui#3865's family rather than to this one. It is pinned below so the + * boundary of this repair is legible instead of assumed — ⭐ if a later card + * teaches `fallbackT` plural lookup, that pin SHOULD go red and be updated to + * "1 column"; it is not a statement that the fallback is right. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +// Registers `object-kanban`. +import '../index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +const ONE_LANE = [{ id: 'todo', title: 'To Do' }]; +const TWO_LANES = [ + { id: 'todo', title: 'To Do' }, + { id: 'in_progress', title: 'In Progress' }, +]; +const THREE_LANES = [...TWO_LANES, { id: 'done', title: 'Done' }]; + +/** + * The board's only live region — `role="status" aria-live="polite"`. Queried off + * `document` rather than a render container so a portalled subtree could not + * read as an absence. + */ +const liveRegion = () => document.querySelector('[role="status"][aria-live="polite"]'); + +/** What assistive technology is handed: the region's own text, whole. */ +const announcement = () => (liveRegion()?.textContent ?? ''); + +/** + * Pump the event loop inside `act` until `pred()` holds or the budget expires, + * and REPORT whether it did — a rig that never completed must not read as an + * absence. + */ +async function pumpUntil(pred: () => boolean, budgetMs = 3000): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (pred()) return true; + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } + return pred(); +} + +/** + * Render an empty board at a given lane shape. `language` mounts a real + * `I18nProvider`, which is the path the console takes and the only path on which + * a plural family can resolve at all; `null` mounts none, which is the + * `fallbackT` path pinned at the bottom of this file. + */ +function renderEmptyBoard(schema: Record, language: string | null) { + const find = vi.fn(() => Promise.resolve({ data: [], total: 0 })); + const dataSource = { find, findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }; + const board = ( + + + + ); + const result = render( + language === null ? ( + board + ) : ( + {board} + ), + ); + return { ...result, find }; +} + +/** + * Settle the board and PROVE it settled, then wait for the live region to carry + * text — a reading taken before either would be about a board mid-flight. + */ +async function settledAnnouncement(find: ReturnType): Promise { + await waitFor(() => expect(find).toHaveBeenCalled()); + // Non-empty rather than "contains No cards": the title is the pack's, so an + // English needle here would make every non-`en` leg fail as a rig failure + // instead of as the reading it is. The exact text is asserted by the caller. + const painted = await pumpUntil(() => announcement().trim().length > 0); + expect(painted, 'RIG SELF-CHECK: the live region must have painted before it is read').toBe(true); + return announcement(); +} + +afterEach(cleanup); + +describe('objectui#9170 — the live region agrees with its own lane count (en, through the provider)', () => { + it('ZERO LANES — ⛔ CONTROL, unchanged: English takes the plural at zero', async () => { + const { find } = renderEmptyBoard({ type: 'object-kanban' }, 'en'); + expect(await settledAnnouncement(find)).toBe('No cards0 columns'); + }); + + it('ONE LANE — ⭐ THE REPAIR: "1 column", where this branch used to say "1 columns"', async () => { + const { find } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + 'en', + ); + const text = await settledAnnouncement(find); + expect(text).toBe('No cards1 column'); + // Stated the other way round too, so the defect is named rather than merely + // absent: this is the exact string objectui#9170 was filed on. + expect(text, 'the bare plural is back at one lane').not.toBe('No cards1 columns'); + }); + + it('TWO LANES — ⛔ CONTROL, unchanged: this is what makes the ONE row a reading', async () => { + const { find } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, + 'en', + ); + expect(await settledAnnouncement(find)).toBe('No cards2 columns'); + }); +}); + +describe('objectui#9170 — the family lands IN LANGUAGE, not through an English fallback', () => { + // ⭐ `ru` is where the shape decision is visible. Its BASE key is not its + // `_other` form: the base is the slot every CLDR category a pack does not + // enumerate lands on, and a kanban board's everyday 2-4 lanes are exactly + // `ru`'s `few`. Spelling the base as the numeral form ("{{count}} колонок") + // renders "3 колонок" — a genitive plural after a numeral that governs the + // genitive singular — and a `_few` key cannot be added because `en` lacks it + // and `all-locales-key-parity` fails a key `en` lacks by design. + it('ONE LANE in ru resolves the singular', async () => { + const { find } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + 'ru', + ); + expect(await settledAnnouncement(find)).toBe('Нет карточек1 колонка'); + }); + + it('THREE LANES in ru lands on the category-neutral base, not on English', async () => { + const { find } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: THREE_LANES }, + 'ru', + ); + const text = await settledAnnouncement(find); + expect(text).toBe('Нет карточекКолонок: 3'); + expect(text, 'ru fell through fallbackLng to English at `few`').not.toContain('columns'); + }); +}); + +describe('objectui#9170 — ⚠️ the provider-LESS path is objectui#3865/#3863 territory, pinned as-is', () => { + it('one lane without a provider still reads "1 columns" — fallbackT cannot pluralise', async () => { + // ⛔ NOT evidence of this repair, and ⛔ not a claim that this is correct. + // `createSafeTranslation`'s `fallbackT` resolves `defaults[key]` literally and + // never appends a plural suffix, so the inline `defaultValue` answers whole. + // This is byte-for-byte what the branch already did before objectui#9170, so + // the repair costs this path nothing; it simply does not reach it. + const { find } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + null, + ); + expect(await settledAnnouncement(find)).toBe('No cards1 columns'); + }); + + it('…and the inline default is byte-identical to the en pack base, so the two paths agree above 1', async () => { + // The equivalence objectui#3546 slice seven established for this key, still + // true: at any count the family does not special-case, both paths say the + // same thing. Two lanes is that count. + const withProvider = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, + 'en', + ); + expect(await settledAnnouncement(withProvider.find)).toBe('No cards2 columns'); + cleanup(); + const without = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, + null, + ); + expect(await settledAnnouncement(without.find)).toBe('No cards2 columns'); + }); +}); From c3c4ed6042be995cc1b25e8e58f2adf4fda4f064 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:52:02 +0000 Subject: [PATCH 3/8] test(i18n): prove the anti-concatenation matcher can fire before trusting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-derived tripwire forbids the shape it replaced with a `not.toMatch`, and a matcher nobody proved can fire is a green assertion that checks nothing — AGENTS.md's rule for forensic regexes, stated there for the `\w`-vs-`\p{L}` case and the same hazard here. The pattern is now shown to match the exact call-site text objectui#9170 removed and not to match the one that replaced it, so the `not.toMatch` leg is a measurement rather than a hope. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../i18n/src/__tests__/residue-namespaces-3546.test.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index bc91d98d3c..0d22753c7e 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -739,10 +739,17 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(src, 'the lane-count description moved').toContain( "description={t('kanban.columns', { count: boardColumns.length, defaultValue: '{{count}} columns' })}", ); + // ⚠️ Shown to be non-vacuous before it is trusted: a matcher nobody proved + // can fire is a green assertion that checks nothing (AGENTS.md's rule for + // forensic regexes). Positive control is the exact shape this replaced; + // negative control is the shape that replaced it. + const CONCATENATED = /\$\{boardColumns\.length\}\s*\$\{/; + expect(CONCATENATED.test("description={`${boardColumns.length} ${t('kanban.columns')}`}")).toBe(true); + expect(CONCATENATED.test("description={t('kanban.columns', { count: boardColumns.length })}")).toBe(false); expect( src, 'a lane count is concatenated in front of a unit word again — that is the defect objectui#9170 repaired', - ).not.toMatch(/\$\{boardColumns\.length\}\s*\$\{/); + ).not.toMatch(CONCATENATED); // (3) — the shape is `repeaterItemCount`'s (base + `_one` + `_other`), not // `chatbot.plan.*`'s (base + `_one`), because the base is the slot every CLDR // category a pack does not enumerate lands on and a kanban board's everyday From 6e88560660124148387dc81724454d8ee61c588d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:07:15 +0000 Subject: [PATCH 4/8] =?UTF-8?q?revert(i18n,plugin-kanban):=20back=20out=20?= =?UTF-8?q?the=20plural=20family=20=E2=80=94=20the=20maintainer's=20route-?= =?UTF-8?q?3=20ruling=20stands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts c3c4ed604 and 7c0a3a9c4 in full. The tree is byte-identical to a8b1cdfcb again (`git diff a8b1cdfcb5` names zero files). ## Why objectui#9170 listed three routes and the maintainer chose route 3 — drop the lane count from the board-level empty state's description — recorded verbatim on the card at 2026-09-12T02:14:55Z, with route 1 (a plural family across ten locales) named explicitly as a fallback that is NOT needed. A later ruling on the same card at 07:24Z ordered route 1 instead; it was written without reading the card's comments, and the seat that issued it has retracted it in full. ⇒ The plural family, the `t(..., { count })` call site, the re-derived pin and the DOM pins that went with them are all work on a route that was never chosen. They come out whole rather than being adapted, so that what lands is route 3 and not a hybrid of two rulings. ⛔ Not a force-push and not a history rewrite: the two commits stay in this branch's history with a revert on top, so the record of what was tried and why it was withdrawn stays readable. Route 3 itself lands in the commit that follows this one. objectui#9169's own predicate change (`totalCardCount === 0`) is untouched by both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9045-kanban-empty-state-lane-count.md | 9 +- .../9170-kanban-columns-plural-family.md | 72 ------ .../residue-namespaces-3546.test.tsx | 158 ++---------- packages/i18n/src/locales/ar.ts | 6 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 29 +-- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 7 +- packages/i18n/src/locales/zh.ts | 4 +- packages/plugin-kanban/src/KanbanImpl.tsx | 11 +- .../emptyStatePluralLaneCount-9170.test.tsx | 226 ------------------ 15 files changed, 39 insertions(+), 507 deletions(-) delete mode 100644 .changeset/9170-kanban-columns-plural-family.md delete mode 100644 packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx diff --git a/.changeset/9045-kanban-empty-state-lane-count.md b/.changeset/9045-kanban-empty-state-lane-count.md index 2636133ff0..5d29d516f7 100644 --- a/.changeset/9045-kanban-empty-state-lane-count.md +++ b/.changeset/9045-kanban-empty-state-lane-count.md @@ -29,13 +29,8 @@ is what made it reachable. ## What changed The predicate asks whether there are any **cards**: -`totalCardCount === 0`. No exported symbol moved and no schema moved. - -⚠️ This paragraph used to end "no published payload" as well, and that stopped -being true inside this same pull request: making the region paint at one lane -made `"1 columns"` reachable, so objectui#9170 pluralises `kanban.columns` across -all ten locale packs. That is a published-payload change, and it is declared in -its own changeset beside this one. +`totalCardCount === 0`. Nothing else moved — no exported symbol, no schema, no +published payload. ## ⚠️ The lane count was not guarding the loading state diff --git a/.changeset/9170-kanban-columns-plural-family.md b/.changeset/9170-kanban-columns-plural-family.md deleted file mode 100644 index 47d437d4c9..0000000000 --- a/.changeset/9170-kanban-columns-plural-family.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -'@object-ui/i18n': minor -'@object-ui/plugin-kanban': minor ---- - -**BREAKING** — `kanban.columns` is now an i18next plural family, not a bare unit -word (objectui#9170). - -The kanban board's empty state composed its description by **concatenation**: -the lane count, a space, then `t('kanban.columns')`, which every pack declared as -a bare plural with no singular form. Read from the live region's own -`textContent`, a one-lane board announced `"No cards1 columns"` — and -`DataEmptyState` there is `role="status" aria-live="polite"`, so this is read -aloud. - -## Why the old string was correct until it wasn't - -The bare plural was not a latent bug. The empty state used to require -`boardColumns.length > 1`, so the count in front of `columns` could never be 1 -and the plural always agreed with it. objectui#9045 made the region paint at zero -and one lane — that widening **is** the accessibility fix, and a wrong plural is -strictly better than the silence it replaced — and the one-lane form became -reachable with it. - -## What changed on the published payload - -Per pack, in all ten locales: - -- `kanban.columns` now carries the count itself, e.g. `en` `'{{count}} columns'`; -- `kanban.columns_one` and `kanban.columns_other` are new. - -The call site resolves the family (`t('kanban.columns', { count })`) instead of -putting a number in front of a unit word. - -## Why this is **BREAKING** and why it is declared `minor` - -The value of an **existing published key** changed. A consumer outside this -repository that read `kanban.columns` as a bare unit word and concatenated a -count in front of it now renders a literal `{{count}}`, because i18next leaves an -unfilled placeholder verbatim when no `count` is passed. Pass `{ count }` and the -key answers correctly, including the singular. - -`minor`, not `major`: every package in this repository sits in one `fixed` group, -so a level is a property of the whole release rather than of one package, and -this repository aligns its major with `@objectstack`'s — `major` is refused -mechanically by `check-changeset-no-major.mjs`. Breaking changes ship as `minor` -with this carrier, which is the convention `AGENTS.md` states. - -## The shape, and why not the other one - -Both plural shapes have precedent here. This key takes **base + `_one` + -`_other`** (the `detail.repeaterItemCount` shape) rather than **base + `_one`** -(the `chatbot.plan.*` shape), because the base key is the slot every CLDR -category a pack does not enumerate falls to — and a kanban board's everyday two -to four lanes are exactly Russian's `few`. Spelling `ru`'s base as its numeral -plural would render `"3 колонок"` there, a genitive plural after a numeral that -governs the genitive singular; a `_few` key is not available, because `en` lacks -it and `all-locales-key-parity` fails a key `en` lacks by design. So `ru`'s base -is the category-neutral `"Колонок: {{count}}"` and its `_other` carries the -numeral form. - -`zh` / `ja` / `ko` define all three slots with the **same** value. No singular is -invented for languages that have none: the `_one` key exists only because key -parity requires every `en` key in every pack. - -## What did not change - -The predicate stays lane-count-blind — a zero-lane and a one-lane board still -announce, which is the whole point of objectui#9045. The provider-less path is -unchanged: `createSafeTranslation`'s fallback resolves its defaults table -literally and never appends a plural suffix, so an embedder with no -`I18nProvider` mounted reads the same English it read before. diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index 0d22753c7e..5ba5877fe4 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -285,7 +285,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { } }); - it('exactly two paths interpolate, and every pack carries the same hole', () => { + it('exactly one path interpolates, and every pack carries the same hole', () => { // A translator who drops `{{name}}` renders a sentence with the source name // missing; one who invents a second hole renders braces verbatim. // `all-locales-key-parity` compares placeholder shape too — this states the @@ -295,33 +295,12 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // `lastIndex` (slice five's own bug). const HOLES = /\{\{\w+\}\}/g; const HAS_HOLE = /\{\{\w+\}\}/; - // - // objectui#9170 added the SECOND: `kanban.columns` was a bare unit word its - // call site concatenated a number in front of, and is now a plural family - // whose members carry the count themselves. The expected hole is named PER - // KEY rather than shared, so a pack that swaps `{{count}}` for `{{name}}` — - // or drops either — is still legible here. - const INTERPOLATED: Record = { - 'empty.interfacePageSourceMissing': '{{name}}', - 'kanban.columns': '{{count}}', - }; - expect(KEYS.filter((k) => HAS_HOLE.test(at(builtInLocales.en, k) as string)).sort()).toEqual( - Object.keys(INTERPOLATED).sort(), - ); + const INTERPOLATED = ['empty.interfacePageSourceMissing']; + expect(KEYS.filter((k) => HAS_HOLE.test(at(builtInLocales.en, k) as string)).sort()).toEqual(INTERPOLATED); for (const lang of LANGS) { for (const key of KEYS) { const holes = ((at(builtInLocales[lang], key) as string).match(HOLES) ?? []).join(','); - expect(holes, `${lang}.${key}`).toBe(INTERPOLATED[key] ?? ''); - } - } - // …and every MEMBER of that family carries the same hole in every pack. - // `KEYS` is the slice-seven residue list — a historical set that deliberately - // does not grow — so the two suffixed leaves are named here instead. - for (const lang of LANGS) { - for (const member of ['kanban.columns_one', 'kanban.columns_other']) { - const value = at(builtInLocales[lang], member); - expect(typeof value, `${lang}.${member}`).toBe('string'); - expect(((value as string).match(HOLES) ?? []).join(','), `${lang}.${member}`).toBe('{{count}}'); + expect(holes, `${lang}.${key}`).toBe(INTERPOLATED.includes(key) ? '{{name}}' : ''); } } }); @@ -351,10 +330,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { INTERFACE_LIST, 'This interface page references "{{name}}", which is not available.', ], - // objectui#9170 — the pack value and the inline default moved together, - // which is exactly what this case exists to hold. See the family case - // below for why there is no `defaultValue_one` beside it. - ['kanban.columns', KANBAN, '{{count}} columns'], + ['kanban.columns', KANBAN, 'columns'], ['layout.systemNav.administration', UNIFIED_SIDEBAR, 'Administration'], ['layout.systemNav.datasources', APP_SIDEBAR, 'Datasources'], ['layout.systemNav.documentation', UNIFIED_SIDEBAR, 'Documentation'], @@ -702,113 +678,34 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.de, 'workspace.multiOrgDisabled')).toContain('ist auf dieser Instanz deaktiviert'); }); - it('kanban.columns is a plural family the call site resolves with a count', () => { - // ⭐ RE-DERIVED, ⛔ not deleted (objectui#9170). This case used to assert the - // OPPOSITE premise — that `en` may be plural-only here because the empty state - // only rendered when `boardColumns.length > 1`, so the count could never be 1 - // and no plural family was needed. objectui#9169 removed that conjunct, which - // IS the accessibility fix (the region now paints at zero and one lane), and - // the old pin fired exactly as it was written to: the guard it named was the - // whole reason a bare plural was safe. What it guarded is gone, so what it - // asserts is now the NEW state of the world — the bare plural is safe because - // there is no longer a bare plural. - // - // Four legs, chosen so the defect fails here by whichever route it returns: - // - // (1) the predicate is still lane-count-blind, so ONE LANE IS REACHABLE — - // this is what makes the family load-bearing rather than decorative, - // and a later card that restores a `> 1` guard lands on this comment; - // (2) the description is one `t()` call carrying a `count`, and NOTHING - // concatenates a lane count in front of a unit word again; - // (3) all ten packs really carry a family (base + `_one` + `_other`), so - // (2) has something to resolve; - // (4) it RENDERS "1 column" at one lane and "2 columns" at two — the - // behaviour, through the provider, so a family that exists but is never - // reached still fails. - // - // ⚠️ (4) is the leg that cannot be satisfied by an assertion that merely stops - // checking: deleting the family, dropping `_one`, or dropping `count:` from - // the call site each turn one of these red. + it('kanban.columns is a bare unit word and follows the repo one precedent for that', () => { + // The call site concatenates: `` `${boardColumns.length} ${t('kanban.columns')}` ``, so + // the pack supplies a UNIT, not a sentence — the same structure as + // `preview.history.items` (slice five, which had to be corrected once for + // exactly this reason). `en` is plural-only and that is safe here: the empty + // state only renders when `boardColumns.length > 1`, so the count is never 1 + // and no plural family is needed. const src = sourceOf(KANBAN); - // (1) - expect( - src, - 'the lane-count blindness moved — the family below may no longer be reachable; re-read objectui#9170', - ).toContain('const isBoardEmpty = totalCardCount === 0;'); - // (2) - expect(src, 'the lane-count description moved').toContain( - "description={t('kanban.columns', { count: boardColumns.length, defaultValue: '{{count}} columns' })}", + expect(src, 'the columns count label moved').toContain( + "description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`}", ); - // ⚠️ Shown to be non-vacuous before it is trusted: a matcher nobody proved - // can fire is a green assertion that checks nothing (AGENTS.md's rule for - // forensic regexes). Positive control is the exact shape this replaced; - // negative control is the shape that replaced it. - const CONCATENATED = /\$\{boardColumns\.length\}\s*\$\{/; - expect(CONCATENATED.test("description={`${boardColumns.length} ${t('kanban.columns')}`}")).toBe(true); - expect(CONCATENATED.test("description={t('kanban.columns', { count: boardColumns.length })}")).toBe(false); - expect( - src, - 'a lane count is concatenated in front of a unit word again — that is the defect objectui#9170 repaired', - ).not.toMatch(CONCATENATED); - // (3) — the shape is `repeaterItemCount`'s (base + `_one` + `_other`), not - // `chatbot.plan.*`'s (base + `_one`), because the base is the slot every CLDR - // category a pack does not enumerate lands on and a kanban board's everyday - // lane counts 2-4 are exactly `ru`'s `few`. See the note in `en.ts`. - for (const lang of LANGS) { - for (const slot of ['kanban.columns', 'kanban.columns_one', 'kanban.columns_other']) { - const value = at(builtInLocales[lang], slot); - expect(typeof value, `${lang}.${slot}`).toBe('string'); - expect((value as string).trim().length, `${lang}.${slot} is empty`).toBeGreaterThan(0); - } - } - // (4) - const render = (lang: LocaleCode) => { - window.localStorage.clear(); - return renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }).result; - }; - const en = render('en'); - expect(en.current.t('kanban.columns', { count: 0 })).toBe('0 columns'); - expect(en.current.t('kanban.columns', { count: 1 })).toBe('1 column'); - expect(en.current.t('kanban.columns', { count: 2 })).toBe('2 columns'); - // ⚠️ `ru`'s BASE is deliberately NOT its `_other` form. `few` (2-4 lanes) and - // `many` (5+) both land on the base, and spelling the base as the numeral form - // "{{count}} колонок" renders "3 колонок" there — a genitive PLURAL after a - // numeral that governs the genitive singular. A `_few` key is not available: - // `en` lacks it, and `all-locales-key-parity` fails a key `en` lacks by - // design. So the base is category-neutral and `_other` carries the numeral - // form, the same device `repeaterItemCount` uses next door. - const ru = render('ru'); - expect(ru.current.t('kanban.columns', { count: 1 })).toBe('1 колонка'); - expect(ru.current.t('kanban.columns', { count: 3 })).toBe('Колонок: 3'); - expect(at(builtInLocales.ru, 'kanban.columns')).not.toBe(at(builtInLocales.ru, 'kanban.columns_other')); - // ⛔ zh / ja / ko define all three slots with the SAME value. Nothing is - // manufactured there — those languages make no singular/plural distinction, - // and the `_one` key exists only because `all-locales-key-parity` requires - // every `en` key in every pack and reads a legitimately-absent half as a lost - // key. Asserted as an equality so a later "translation" of the singular is - // visible as the invention it would be. - for (const lang of ['zh', 'ja', 'ko'] as const) { - expect(at(builtInLocales[lang], 'kanban.columns_one'), `${lang} invented a singular`).toBe( - at(builtInLocales[lang], 'kanban.columns'), - ); - expect(at(builtInLocales[lang], 'kanban.columns_other')).toBe(at(builtInLocales[lang], 'kanban.columns')); - } - // The precedent this key used to follow — a bare unit word with the number - // concatenated on at the call site — is `preview.history.items`, untouched by - // this card and pinned here so a reader can see which shape was LEFT rather - // than assume the two still agree. + expect(src, 'the >1 guard moved — a plural family would now be required').toContain( + 'const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1;', + ); + // The precedent's shape, per pack: unit word only, no counter particle, since + // the call site already inserts the space and the number. expect(at(builtInLocales.en, 'preview.history.items')).toBe('item(s)'); expect(at(builtInLocales.ko, 'preview.history.items')).toBe('항목'); expect(at(builtInLocales.ru, 'preview.history.items')).toBe('элементов'); - // …and the WORD still comes from kanban's own column vocabulary, which is not - // the table's: ja says カラム here and 列 in `table.columns`, ru колонка - // against столбец. + // …and the WORD comes from kanban's own column vocabulary, which is not the + // table's: ja says カラム here and 列 in `table.columns`, ru колонка against + // столбец. expect(at(builtInLocales.ja, 'kanban.addColumn')).toBe('カラムを追加'); expect(at(builtInLocales.ja, 'table.columns')).toBe('列'); - expect(at(builtInLocales.ja, 'kanban.columns')).toBe('{{count}} カラム'); + expect(at(builtInLocales.ja, 'kanban.columns')).toBe('カラム'); expect(at(builtInLocales.ru, 'kanban.addColumn')).toBe('Добавить колонку'); - expect(at(builtInLocales.ru, 'kanban.columns_one')).toBe('{{count}} колонка'); - expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열 {{count}}개'); + expect(at(builtInLocales.ru, 'kanban.columns')).toBe('колонок'); + expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열'); }); it('detail.concurrentUpdateRecordLabel is grammatical in the sentence that embeds it', () => { @@ -1011,10 +908,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ); expect(t('layout.systemNav.administration')).toBe('管理'); expect(t('workspace.multiOrgDisabled')).toBe('此实例已禁用创建新组织。'); - // Read WITH a count now that this key is a plural family: without one - // i18next returns the base value with its `{{count}}` unfilled, which is a - // reading about the lookup rather than about the Chinese. - expect(t('kanban.columns', { count: 2 })).toBe('2 列'); + expect(t('kanban.columns')).toBe('列'); expect(t('detail.deleted')).toBe('记录已删除'); }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index e66b28dd88..259bf7a2bc 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -683,11 +683,7 @@ const ar = { }, kanban: { uncategorized: "غير مصنّف", - // The base serves `zero`/`two`/`few`/`many`, which Arabic reaches at - // everyday counts; it carries both forms the way `repeaterItemCount` does. - columns: "{{count}} عمود (أعمدة)", - columns_one: "{{count}} عمود", - columns_other: "{{count}} أعمدة", + columns: "أعمدة", addCard: "إضافة بطاقة", addColumn: "إضافة عمود", moveCard: "نقل بطاقة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 2606a79849..10ed5d5594 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -679,9 +679,7 @@ const de = { }, kanban: { uncategorized: "Nicht kategorisiert", - columns: "{{count}} Spalten", - columns_one: "{{count}} Spalte", - columns_other: "{{count}} Spalten", + columns: "Spalten", addCard: "Karte hinzufügen", addColumn: "Spalte hinzufügen", moveCard: "Karte verschieben", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index ce1d4a2c1b..6a6314192f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -803,34 +803,7 @@ const en = { noCards: 'No cards', cardTitlePlaceholder: 'Enter card title…', uncategorized: 'Uncategorized', - // objectui#9170 — the board-level empty state's description. A REAL i18next - // plural family (base + `_one` + `_other`, the `repeaterItemCount` shape), - // replacing a BARE UNIT WORD the call site concatenated a number in front of. - // - // The bare word was safe only while `KanbanImpl`'s empty state required - // `boardColumns.length > 1`: the count could never be 1, so `columns` always - // agreed with it. objectui#9169 makes the region paint at zero and one lane — - // that widening IS the accessibility fix — and with it "1 columns" became - // reachable in a `role="status" aria-live="polite"` region. - // - // Why `_other` is spelled out rather than left to the base: the base is the - // slot every CLDR category a pack does not enumerate lands on, and on a - // kanban board the everyday lane counts 2-4 are exactly `ru`'s `few`. - // Measured on i18next 26.4.0 — with the base spelled as the `_other` form - // ("{{count}} колонок") `ru` renders "3 колонок" at three lanes, a genitive - // PLURAL after a numeral that governs the genitive singular. Giving `ru` a - // `_few` is not available (a key `en` lacks fails `all-locales-key-parity` - // by design), so `ru`'s base carries a category-neutral "Колонок: {{count}}" - // and its `_other` carries the numeral form — the same device - // `repeaterItemCount` uses, for the same reason. - // - // zh / ja / ko define all three slots with the SAME value. Nothing is - // invented there: those languages make no singular/plural distinction, and - // the `_one` key exists only because `all-locales-key-parity` requires every - // `en` key in every pack and reads a legitimately-absent half as a LOST key. - columns: '{{count}} columns', - columns_one: '{{count}} column', - columns_other: '{{count}} columns', + columns: 'columns', requiredFieldsTitle: 'Complete required fields', requiredFieldsDescription: 'This move makes the fields below required. Fill them in to continue.', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b68545fc3c..67ae51006c 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -683,9 +683,7 @@ const es = { }, kanban: { uncategorized: "Sin categoría", - columns: "{{count}} columnas", - columns_one: "{{count}} columna", - columns_other: "{{count}} columnas", + columns: "columnas", addCard: "Añadir tarjeta", addColumn: "Añadir columna", moveCard: "Mover tarjeta", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 62036476aa..6b3a4c6a24 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -679,9 +679,7 @@ const fr = { }, kanban: { uncategorized: "Non catégorisé", - columns: "{{count}} colonnes", - columns_one: "{{count}} colonne", - columns_other: "{{count}} colonnes", + columns: "colonnes", addCard: "Ajouter une carte", addColumn: "Ajouter une colonne", moveCard: "Déplacer la carte", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 64d223c115..d1d27c4854 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -679,9 +679,7 @@ const ja = { }, kanban: { uncategorized: "未分類", - columns: "{{count}} カラム", - columns_one: "{{count}} カラム", - columns_other: "{{count}} カラム", + columns: "カラム", addCard: "カードを追加", addColumn: "カラムを追加", moveCard: "カードを移動", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 18059294f3..7501a8d4ef 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -679,9 +679,7 @@ const ko = { }, kanban: { uncategorized: "미분류", - columns: "열 {{count}}개", - columns_one: "열 {{count}}개", - columns_other: "열 {{count}}개", + columns: "열", addCard: "카드 추가", addColumn: "열 추가", moveCard: "카드 이동", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 3bf4582cf3..95f605c735 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -678,9 +678,7 @@ const pt = { }, kanban: { uncategorized: "Sem categoria", - columns: "{{count}} colunas", - columns_one: "{{count}} coluna", - columns_other: "{{count}} colunas", + columns: "colunas", addCard: "Adicionar cartão", addColumn: "Adicionar coluna", moveCard: "Mover cartão", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 0e0d644d3e..b544418e17 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -685,12 +685,7 @@ const ru = { }, kanban: { uncategorized: "Без категории", - // The base is deliberately NOT the `_other` form: it is the slot `few` - // (2-4 lanes) and `many` (5+) land on, and "3 колонок" would be a genitive - // plural after a numeral that governs the genitive singular. See en.ts. - columns: "Колонок: {{count}}", - columns_one: "{{count}} колонка", - columns_other: "{{count}} колонок", + columns: "колонок", addCard: "Добавить карточку", addColumn: "Добавить колонку", moveCard: "Переместить карточку", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ce367ca878..a9ce007799 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -710,9 +710,7 @@ const zh = { noCards: '暂无卡片', cardTitlePlaceholder: '输入卡片标题…', uncategorized: '未分类', - columns: '{{count}} 列', - columns_one: '{{count}} 列', - columns_other: '{{count}} 列', + columns: '列', requiredFieldsTitle: '填写必填字段', requiredFieldsDescription: '此次移动会使以下字段成为必填项。填写后即可继续。', }, diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 2bf1926751..a349d33405 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -910,16 +910,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu showIcon={false} className="rounded-lg border border-dashed border-border/60 bg-muted/10 py-8 gap-2 [&>h3]:text-sm [&>h3]:font-medium [&>h3]:text-foreground/80" title={t('kanban.noCards')} - // The lane count is RESOLVED through the pack's plural family, never - // concatenated in front of a unit word (objectui#9170). The bare - // plural was safe only while this region required more than one lane; - // widening `isBoardEmpty` above made "1 columns" reachable here, and - // this region is read aloud. `defaultValue` is byte-identical to the - // `en` base value so the two paths cannot be told apart; it carries no - // `defaultValue_one` because nothing would read one — with a provider - // the pack answers, and without one `createSafeTranslation`'s - // `fallbackT` has no plural logic at all (objectui#3865, unchanged). - description={t('kanban.columns', { count: boardColumns.length, defaultValue: '{{count}} columns' })} + description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`} /> )} diff --git a/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx deleted file mode 100644 index b7995e54b0..0000000000 --- a/packages/plugin-kanban/src/__tests__/emptyStatePluralLaneCount-9170.test.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * objectui#9170 — the board's empty state agrees with its own lane count. - * - * ## The defect, measured in the DOM on objectui#9169's branch - * - * `KanbanImpl` composed the board-level empty state's description by - * CONCATENATION — the lane count, a space, then `t('kanban.columns')`, which - * every pack declared as a bare plural unit word with no singular form. Reading - * the live region's own `textContent` at three lane counts: - * - * ZERO: "No cards0 columns" ← grammatical (English takes the plural at 0) - * ONE: "No cards1 columns" ← the defect - * TWO: "No cards2 columns" ← CONTROL, grammatical - * - * `DataEmptyState` there is `role="status" aria-live="polite"`, so this is read - * ALOUD, not merely printed. - * - * ## ⭐ Why the string was safe until objectui#9169 and is not any more - * - * The bare plural was not a latent bug that nobody noticed — it was CORRECT for - * every board that could reach it. The empty state used to require - * `boardColumns.length > 1`, so the count in front of `columns` was never 1. - * objectui#9169 removed that conjunct so a zero-lane and a one-lane board - * announce at all — that widening IS the accessibility fix — and the one-lane - * form became reachable with it. The repair is the plural family, ⛔ never a - * retreat to the old predicate. - * - * ## What is a CONTROL here and what is evidence - * - * `TWO LANES` is the control: it is unchanged by this card and it is what makes - * the `ONE LANE` reading a fact about the plural form rather than about a probe - * that reads the wrong node. `ZERO LANES` is a second control for the same - * reason — English takes the plural at zero, so it too must be unchanged. - * ⇒ Only the `ONE LANE` row is evidence of this repair. - * - * ## ⚠️ The provider-LESS path is deliberately pinned as it is, not as we want it - * - * `KanbanImpl` binds `createSafeTranslation`, whose `fallbackT` resolves - * `defaults[key]` literally and NEVER appends a plural suffix — the mechanism - * `packages/plugin-detail/src/useDetailTranslation.ts` records for - * `detail.showEmptyRelated` (objectui#3863). With no `I18nProvider` mounted the - * inline `defaultValue` answers and one lane still reads "1 columns". That is - * unchanged by this card, is no worse than before it, and belongs to - * objectui#3865's family rather than to this one. It is pinned below so the - * boundary of this repair is legible instead of assumed — ⭐ if a later card - * teaches `fallbackT` plural lookup, that pin SHOULD go red and be updated to - * "1 column"; it is not a statement that the fallback is right. - */ -import React from 'react'; -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, waitFor, act, cleanup } from '@testing-library/react'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { I18nProvider } from '@object-ui/i18n'; -// Registers `object-kanban`. -import '../index'; -// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing -// the chunk at module scope bills the cold transform to the import phase -// instead of racing a `waitFor` budget (objectui#3010). -import '../KanbanImpl'; - -const ONE_LANE = [{ id: 'todo', title: 'To Do' }]; -const TWO_LANES = [ - { id: 'todo', title: 'To Do' }, - { id: 'in_progress', title: 'In Progress' }, -]; -const THREE_LANES = [...TWO_LANES, { id: 'done', title: 'Done' }]; - -/** - * The board's only live region — `role="status" aria-live="polite"`. Queried off - * `document` rather than a render container so a portalled subtree could not - * read as an absence. - */ -const liveRegion = () => document.querySelector('[role="status"][aria-live="polite"]'); - -/** What assistive technology is handed: the region's own text, whole. */ -const announcement = () => (liveRegion()?.textContent ?? ''); - -/** - * Pump the event loop inside `act` until `pred()` holds or the budget expires, - * and REPORT whether it did — a rig that never completed must not read as an - * absence. - */ -async function pumpUntil(pred: () => boolean, budgetMs = 3000): Promise { - const deadline = Date.now() + budgetMs; - while (Date.now() < deadline) { - if (pred()) return true; - await act(async () => { - await new Promise((r) => setTimeout(r, 10)); - }); - } - return pred(); -} - -/** - * Render an empty board at a given lane shape. `language` mounts a real - * `I18nProvider`, which is the path the console takes and the only path on which - * a plural family can resolve at all; `null` mounts none, which is the - * `fallbackT` path pinned at the bottom of this file. - */ -function renderEmptyBoard(schema: Record, language: string | null) { - const find = vi.fn(() => Promise.resolve({ data: [], total: 0 })); - const dataSource = { find, findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }; - const board = ( - - - - ); - const result = render( - language === null ? ( - board - ) : ( - {board} - ), - ); - return { ...result, find }; -} - -/** - * Settle the board and PROVE it settled, then wait for the live region to carry - * text — a reading taken before either would be about a board mid-flight. - */ -async function settledAnnouncement(find: ReturnType): Promise { - await waitFor(() => expect(find).toHaveBeenCalled()); - // Non-empty rather than "contains No cards": the title is the pack's, so an - // English needle here would make every non-`en` leg fail as a rig failure - // instead of as the reading it is. The exact text is asserted by the caller. - const painted = await pumpUntil(() => announcement().trim().length > 0); - expect(painted, 'RIG SELF-CHECK: the live region must have painted before it is read').toBe(true); - return announcement(); -} - -afterEach(cleanup); - -describe('objectui#9170 — the live region agrees with its own lane count (en, through the provider)', () => { - it('ZERO LANES — ⛔ CONTROL, unchanged: English takes the plural at zero', async () => { - const { find } = renderEmptyBoard({ type: 'object-kanban' }, 'en'); - expect(await settledAnnouncement(find)).toBe('No cards0 columns'); - }); - - it('ONE LANE — ⭐ THE REPAIR: "1 column", where this branch used to say "1 columns"', async () => { - const { find } = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, - 'en', - ); - const text = await settledAnnouncement(find); - expect(text).toBe('No cards1 column'); - // Stated the other way round too, so the defect is named rather than merely - // absent: this is the exact string objectui#9170 was filed on. - expect(text, 'the bare plural is back at one lane').not.toBe('No cards1 columns'); - }); - - it('TWO LANES — ⛔ CONTROL, unchanged: this is what makes the ONE row a reading', async () => { - const { find } = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, - 'en', - ); - expect(await settledAnnouncement(find)).toBe('No cards2 columns'); - }); -}); - -describe('objectui#9170 — the family lands IN LANGUAGE, not through an English fallback', () => { - // ⭐ `ru` is where the shape decision is visible. Its BASE key is not its - // `_other` form: the base is the slot every CLDR category a pack does not - // enumerate lands on, and a kanban board's everyday 2-4 lanes are exactly - // `ru`'s `few`. Spelling the base as the numeral form ("{{count}} колонок") - // renders "3 колонок" — a genitive plural after a numeral that governs the - // genitive singular — and a `_few` key cannot be added because `en` lacks it - // and `all-locales-key-parity` fails a key `en` lacks by design. - it('ONE LANE in ru resolves the singular', async () => { - const { find } = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, - 'ru', - ); - expect(await settledAnnouncement(find)).toBe('Нет карточек1 колонка'); - }); - - it('THREE LANES in ru lands on the category-neutral base, not on English', async () => { - const { find } = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: THREE_LANES }, - 'ru', - ); - const text = await settledAnnouncement(find); - expect(text).toBe('Нет карточекКолонок: 3'); - expect(text, 'ru fell through fallbackLng to English at `few`').not.toContain('columns'); - }); -}); - -describe('objectui#9170 — ⚠️ the provider-LESS path is objectui#3865/#3863 territory, pinned as-is', () => { - it('one lane without a provider still reads "1 columns" — fallbackT cannot pluralise', async () => { - // ⛔ NOT evidence of this repair, and ⛔ not a claim that this is correct. - // `createSafeTranslation`'s `fallbackT` resolves `defaults[key]` literally and - // never appends a plural suffix, so the inline `defaultValue` answers whole. - // This is byte-for-byte what the branch already did before objectui#9170, so - // the repair costs this path nothing; it simply does not reach it. - const { find } = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, - null, - ); - expect(await settledAnnouncement(find)).toBe('No cards1 columns'); - }); - - it('…and the inline default is byte-identical to the en pack base, so the two paths agree above 1', async () => { - // The equivalence objectui#3546 slice seven established for this key, still - // true: at any count the family does not special-case, both paths say the - // same thing. Two lanes is that count. - const withProvider = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, - 'en', - ); - expect(await settledAnnouncement(withProvider.find)).toBe('No cards2 columns'); - cleanup(); - const without = renderEmptyBoard( - { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }, - null, - ); - expect(await settledAnnouncement(without.find)).toBe('No cards2 columns'); - }); -}); From dd6672ee8e9e2e0af97106217f1b7bd8d90a373c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:25:40 +0000 Subject: [PATCH 5/8] fix(plugin-kanban): drop the lane count from the empty board's announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 3 of objectui#9170, per the maintainer's ruling recorded on that card on 2026-09-12. The board-level empty state composed its description by CONCATENATION — the lane count, a space, then the pack's `kanban.columns` unit word, a bare plural with no singular form. Read from the live region's own `textContent`, a one-lane board announced "No cards1 columns", and `DataEmptyState` there is `role="status" aria-live="polite"`, so it is read aloud rather than merely printed. The description is removed. The region announces the title only, at every lane count. ## Why the string was correct until this branch The bare plural was not a latent bug. The empty state used to require `boardColumns.length > 1`, so the count in front of `columns` was never 1 and the plural always agreed with it. The first commit on this branch removed that conjunct so a zero-lane and a one-lane board announce at all — that widening IS the accessibility fix — and the one-lane form became reachable with it. ⭐ Removing the number makes "no plural family is needed" true BY CONSTRUCTION rather than by a predicate: there is no number in this region for a plural to have to agree with, in any of the ten languages. The alternative — a plural family for `kanban.columns` across ten packs — was ruled against, and the commit before this one backs out an attempt at it. ## The source-text pin is REWRITTEN, not deleted `residue-namespaces-3546.test.tsx` pinned the `> 1` guard by source text, with its own comment stating why: the count could never be 1, so `en` could be plural-only. That premise is gone, so the case now asserts the new one, in four legs chosen so the guarantee cannot be quietly given up: the predicate is still lane-count-blind (route 3 is NOT a licence to restore `> 1` and silence those boards again); the board-level element declares no `description` at all, read from the element's own slice rather than from the whole file; nothing in that file asks the pack for the columns unit word in any spelling, with the needle proven able to fire before it is trusted; and the region still ANNOUNCES, so a repair that deleted the whole region cannot pass by satisfying the first three vacuously. The byte-identity table lost its `kanban.columns` row — the call site it compared against is gone — and says so, with the count moved from sixteen to fifteen and the departure asserted rather than left as a silent absence. ## The card's three-lane measurement becomes the pin `emptyStateNumberlessDescription-9170.test.tsx` reads the live region's own `textContent` at zero, one and two lanes and asserts three ways: today's copy byte for byte, the EQUALITY across the three rows (the card's actual claim — the same string at every lane count), and the absence of any digit, which is the leg that survives a rewording. It runs through the provider, provider-less, and in `ru`; a lit control shows a board with cards paints no region at all, and one leg asserts objectui#9045 is not undone — one lane still announces, it just says less. ## `kanban.columns` is NOT retired here It stays in all ten packs, byte-identical. It now has no call site, and `scripts/check-i18n-dead-keys.mjs` — report-only by design — classifies it `needs-review`, not `confirmed`: the whole key appears textually elsewhere, but every one of those occurrences is the unrelated SPEC key (the fields shown on each card, as `normalize-list-view.ts` says in as many words) or this suite's own pins. Measured as a delta with the total as its control: 365 candidates before, 366 after; 127 confirmed either side; needs-review 238 to 239. Exactly one key moved and it did not move into the deletable column. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9045-kanban-empty-state-lane-count.md | 6 + ...170-kanban-empty-state-drops-lane-count.md | 44 ++++ .../residue-namespaces-3546.test.tsx | 150 +++++++++-- packages/plugin-kanban/src/KanbanImpl.tsx | 18 +- ...tyStateNumberlessDescription-9170.test.tsx | 234 ++++++++++++++++++ 5 files changed, 424 insertions(+), 28 deletions(-) create mode 100644 .changeset/9170-kanban-empty-state-drops-lane-count.md create mode 100644 packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx diff --git a/.changeset/9045-kanban-empty-state-lane-count.md b/.changeset/9045-kanban-empty-state-lane-count.md index 5d29d516f7..c403235543 100644 --- a/.changeset/9045-kanban-empty-state-lane-count.md +++ b/.changeset/9045-kanban-empty-state-lane-count.md @@ -32,6 +32,12 @@ The predicate asks whether there are any **cards**: `totalCardCount === 0`. Nothing else moved — no exported symbol, no schema, no published payload. +⚠️ One consequence did land in the same pull request, and it has its own +changeset beside this one: making the region paint at one lane made the +description's `"1 columns"` reachable, so objectui#9170 removes the lane count +from that description. That change edits no locale pack either, so the sentence +above still holds for both halves. + ## ⚠️ The lane count was not guarding the loading state The plausible reading — that `> 1` separated "still loading" from "genuinely diff --git a/.changeset/9170-kanban-empty-state-drops-lane-count.md b/.changeset/9170-kanban-empty-state-drops-lane-count.md new file mode 100644 index 0000000000..66e6433e0b --- /dev/null +++ b/.changeset/9170-kanban-empty-state-drops-lane-count.md @@ -0,0 +1,44 @@ +--- +'@object-ui/plugin-kanban': patch +--- + +The kanban board's "No cards" announcement drops the lane count from its +description (objectui#9170). + +The board-level empty state composed its description by **concatenation**: the +lane count, a space, then the pack's `kanban.columns` unit word, a bare plural +with no singular form. Read from the live region's own `textContent`, a one-lane +board announced `"No cards1 columns"` — and `DataEmptyState` there is +`role="status" aria-live="polite"`, so this is read aloud. + +## Why the old string was correct until it wasn't + +The bare plural was not a latent bug. The empty state used to require +`boardColumns.length > 1`, so the count in front of `columns` could never be 1 +and the plural always agreed with it. objectui#9045 made the region paint at zero +and one lane — that widening **is** the accessibility fix — and the one-lane form +became reachable with it. + +## What changed + +The description is gone. The live region announces the title only, at every lane +count: zero, one and two lanes all read exactly `"No cards"`. The lane count is +already visible on the board, and read aloud it is noise; the region's job is to +say that the board holds no cards. + +⭐ That also makes the bare plural safe **by construction** rather than by a +predicate: there is no number in this region for a plural to have to agree with, +in any language. The alternative considered — a plural family for +`kanban.columns` across ten locale packs — was ruled against. + +## What did **not** change + +- objectui#9045's predicate is untouched: a zero-lane and a one-lane board still + announce. Route 3 removes the number, never the announcement, and that is + pinned as its own case rather than assumed. +- **No published payload moved.** No locale pack was edited, no key was added, + renamed or retired. `kanban.columns` stays in all ten packs exactly as it was; + it now has no call site, and `scripts/check-i18n-dead-keys.mjs` — report-only + by design — is what judges its fate, in its own time and not here. +- The provider-less path needs no separate repair for once: a region with no + number needs no plural logic, and `createSafeTranslation`'s fallback has none. diff --git a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index 5ba5877fe4..3a46371163 100644 --- a/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -305,11 +305,17 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { } }); - it('the sixteen literal en values are byte-identical to their inline defaultValue', () => { + it('the fifteen literal en values are byte-identical to their inline defaultValue', () => { // Two paths must not diverge: with the pack present i18next answers, and // before this slice the inline default did — a user must not be able to tell - // which ran. 16 keys here; `dashboard.loading` and the two families are the + // which ran. 15 keys here; `dashboard.loading` and the two families are the // three shapes a byte compare cannot reach, each pinned in its own case. + // + // ⚠️ It was SIXTEEN until objectui#9170. `kanban.columns` left this table + // because its call site left the tree: the board-level empty state no longer + // composes a description at all, so there is no inline default to compare + // against. ⛔ The key itself was NOT retired here — that is the dead-key + // gate's call, not this suite's, and the case below states what it reports. const EXPECTED: Array<[key: string, source: string, value: string]> = [ ['common.done', INVITE_DIALOG, 'Done'], ['common.editInStudio', PAGE_VIEW, 'Edit in studio'], @@ -330,13 +336,17 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { INTERFACE_LIST, 'This interface page references "{{name}}", which is not available.', ], - ['kanban.columns', KANBAN, 'columns'], ['layout.systemNav.administration', UNIFIED_SIDEBAR, 'Administration'], ['layout.systemNav.datasources', APP_SIDEBAR, 'Datasources'], ['layout.systemNav.documentation', UNIFIED_SIDEBAR, 'Documentation'], ['workspace.multiOrgDisabled', CREATE_WORKSPACE, 'Creating new organizations is disabled on this instance.'], ]; - expect(EXPECTED).toHaveLength(16); + expect(EXPECTED).toHaveLength(15); + // …and the sixteenth is accounted for rather than merely absent: a row that + // silently disappears from a table like this is how a slice stops covering + // something without anyone noticing. + expect(MEASURED_KEYS).toContain('kanban.columns'); + expect(EXPECTED.map(([key]) => key)).not.toContain('kanban.columns'); const cache = new Map(); for (const [key, rel, value] of EXPECTED) { if (!cache.has(rel)) cache.set(rel, sourceOf(rel)); @@ -678,34 +688,111 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.de, 'workspace.multiOrgDisabled')).toContain('ist auf dieser Instanz deaktiviert'); }); - it('kanban.columns is a bare unit word and follows the repo one precedent for that', () => { - // The call site concatenates: `` `${boardColumns.length} ${t('kanban.columns')}` ``, so - // the pack supplies a UNIT, not a sentence — the same structure as - // `preview.history.items` (slice five, which had to be corrected once for - // exactly this reason). `en` is plural-only and that is safe here: the empty - // state only renders when `boardColumns.length > 1`, so the count is never 1 - // and no plural family is needed. + it('the board-level empty state carries no lane count, so no plural family is needed', () => { + // ⭐ RE-DERIVED, ⛔ not deleted (objectui#9170). This case used to pin the + // OPPOSITE premise, in two assertions that belong together: + // + // description={\`\${boardColumns.length} \${t()}\`} + // const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1; + // + // …with the reasoning written between them: the pack supplies a UNIT word, + // `en` is plural-only, and that is SAFE because the empty state only renders + // above one lane, so the count can never be 1. + // + // objectui#9169 removed that second conjunct so a zero-lane and a one-lane + // board announce at all — that widening IS the accessibility fix — and this + // pin fired exactly as it was written to, because the premise it named had + // gone: the live region then read "No cards1 columns", announced aloud. + // + // The maintainer's ruling on objectui#9170 (2026-09-12) took the third of the + // card's three routes: the region's job is "no cards", the lane count is + // already visible on the board, and read aloud it is noise. ⇒ the description + // is GONE, and "no plural family is needed" is true BY CONSTRUCTION — there + // is no number in this region for a plural to have to agree with — rather + // than true because a predicate happened to keep the count above one. + // + // ⚠️ That is a stronger guarantee than the one it replaces, and the legs + // below are chosen so it cannot be quietly given up: + // + // (1) the predicate is still lane-count-blind, so the zero- and one-lane + // boards still announce — ⛔ route 3 is NOT a licence to put the `> 1` + // guard back, which would silence them again; + // (2) the board-level region declares no `description` at all; + // (3) nothing in that file asks the pack for the columns unit word any + // more, in any spelling, so no count can be composed with one; + // (4) the region still ANNOUNCES — the title is what carries the message, + // and a repair that deleted the whole region would otherwise pass (1) + // to (3) trivially. + // + // The rendered half — zero, one and two lanes all reading the SAME numberless + // string — is pinned where it can be read from the DOM, in + // `packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx`. const src = sourceOf(KANBAN); - expect(src, 'the columns count label moved').toContain( - "description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`}", - ); - expect(src, 'the >1 guard moved — a plural family would now be required').toContain( - 'const isBoardEmpty = totalCardCount === 0 && boardColumns.length > 1;', + + // (1) + expect( + src, + 'the lane-count blindness moved — the zero- and one-lane boards may be silent again; re-read objectui#9045', + ).toContain('const isBoardEmpty = totalCardCount === 0;'); + + // (2) — read from the element itself rather than from the whole file, so a + // `description` prop on some future sibling cannot make this red by accident, + // and `card.description` (a DATA field, two hundred lines up) cannot either. + const OPEN = '', openAt)); + expect(element.length, 'RIG SELF-CHECK: the element slice must not be empty').toBeGreaterThan(0); + expect(element, 'the board-level empty state grew a description back').not.toContain('description='); + + // (3) — the needle is held in a variable so this file can describe the thing + // it forbids without containing it; it is proven able to fire before it is + // trusted (AGENTS.md's rule for forensic matchers). + const COLUMNS_CALL = "t('kanban.columns'"; + expect( + "description={`${boardColumns.length} ${" + COLUMNS_CALL + ", { defaultValue: 'columns' })}`}", + 'POSITIVE CONTROL: the needle must match the exact call this card removed', + ).toContain(COLUMNS_CALL); + expect( + src, + 'the columns unit word is being asked for again — a count in this region needs a plural family, and that decision went the other way', + ).not.toContain(COLUMNS_CALL); + expect(src, 'a lane count is being concatenated again').not.toMatch(/\$\{boardColumns\.length\}\s*\$\{/); + + // (4) + expect(src, 'the region stopped announcing — that is not route 3, that is silence').toContain( + "title={t('kanban.noCards')}", ); - // The precedent's shape, per pack: unit word only, no counter particle, since - // the call site already inserts the space and the number. - expect(at(builtInLocales.en, 'preview.history.items')).toBe('item(s)'); - expect(at(builtInLocales.ko, 'preview.history.items')).toBe('항목'); - expect(at(builtInLocales.ru, 'preview.history.items')).toBe('элементов'); - // …and the WORD comes from kanban's own column vocabulary, which is not the - // table's: ja says カラム here and 列 in `table.columns`, ru колонка against - // столбец. + expect(typeof at(builtInLocales.en, 'kanban.noCards')).toBe('string'); + + // ⚠️ `kanban.columns` is still DEFINED in all ten packs and is now read by no + // call site in `packages/` or `apps/`. ⛔ It is deliberately NOT retired here: + // `scripts/check-i18n-dead-keys.mjs` owns that judgement, it is report-only by + // design (a reverse sweep over dynamic key construction can produce false + // positives, and a gate that cries wolf gets deleted rather than trusted), and + // this suite is not the place to pre-empt it. What is pinned is the fact that + // makes its verdict readable: the key resolves in every pack, and the slice + // this file owns still covers it. + for (const lang of LANGS) { + expect(typeof at(builtInLocales[lang], 'kanban.columns'), `${lang}.kanban.columns`).toBe('string'); + } + // The vocabulary the key carries, kept so a later retirement can see what it + // would be deleting: kanban's own column word is not the table's — ja says + // カラム here and 列 in `table.columns`, ru колонка against столбец. expect(at(builtInLocales.ja, 'kanban.addColumn')).toBe('カラムを追加'); expect(at(builtInLocales.ja, 'table.columns')).toBe('列'); expect(at(builtInLocales.ja, 'kanban.columns')).toBe('カラム'); expect(at(builtInLocales.ru, 'kanban.addColumn')).toBe('Добавить колонку'); expect(at(builtInLocales.ru, 'kanban.columns')).toBe('колонок'); expect(at(builtInLocales.ko, 'kanban.columns')).toBe('열'); + // `preview.history.items` is the repo's OTHER bare-unit-word call site, and it + // is untouched by this card — pinned so a reader can see that the shape still + // exists elsewhere and that route 3 was a decision about this region, not a + // repo-wide ban. + expect(at(builtInLocales.en, 'preview.history.items')).toBe('item(s)'); + expect(at(builtInLocales.ko, 'preview.history.items')).toBe('항목'); + expect(at(builtInLocales.ru, 'preview.history.items')).toBe('элементов'); }); it('detail.concurrentUpdateRecordLabel is grammatical in the sentence that embeds it', () => { @@ -829,7 +916,12 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ['common.editInStudio', 'PageView (edit affordance title/aria-label)'], ['empty.appNotAvailable', 'AppContent (requested app missing)'], ['detail.historyEmpty', 'DetailView (history tab)'], - ['kanban.columns', 'KanbanImpl (empty board)'], + // ⚠️ No owning surface any more: objectui#9170 removed the only call site + // (the board-level empty state's description). Kept in this sample because + // what this case checks is that the PACK answers for a slice-seven key, and + // that is still true — and because a key with no reader is exactly the one + // whose pack rows stop being exercised anywhere else. + ['kanban.columns', 'no call site since objectui#9170 — pack-only'], ['layout.systemNav.administration', 'UnifiedSidebar (admin cluster)'], ['workspace.multiOrgDisabled', 'CreateWorkspaceDialog (submit guard)'], ['gantt.linkEnd.start', 'GanttView (link drag hint)'], @@ -875,8 +967,12 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(sourceOf(rel), `${rel}`).toContain('const { t } = useDetailTranslation();'); } // …and kanban through its own createSafeTranslation, whose probe key IS in - // the packs, so the provider path wins. Its defaults map does not list - // `kanban.columns`, which is the provider-LESS defect objectui#3865 owns. + // the packs, so the provider path wins. Its defaults map does not list the + // columns unit word — which used to be a live instance of the provider-LESS + // defect objectui#3865 owns, and since objectui#9170 removed that call site + // is merely an absence. The assertion is kept as the guard it now is: a + // defaults row for a key nothing reads would be the first sign the + // description had come back. const kanban = sourceOf(KANBAN); expect(kanban).toContain('const useKanbanT = createSafeTranslation('); expect(kanban).toContain("'kanban.noCards',"); diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index a349d33405..7b505b9032 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -910,7 +910,23 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu showIcon={false} className="rounded-lg border border-dashed border-border/60 bg-muted/10 py-8 gap-2 [&>h3]:text-sm [&>h3]:font-medium [&>h3]:text-foreground/80" title={t('kanban.noCards')} - description={`${boardColumns.length} ${t('kanban.columns', { defaultValue: 'columns' })}`} + // ⛔ NO DESCRIPTION, deliberately (objectui#9170, maintainer ruling + // 2026-09-12). This region's job is "no cards"; the lane count is + // already on screen, and read aloud it is noise. + // + // It used to be composed by CONCATENATION — the lane count, a space, + // then the pack's `kanban.columns` unit word, which is a bare plural + // with no singular form. That was safe only while this region required + // more than one lane, because the count could then never be 1. + // Widening `isBoardEmpty` above is what made "1 columns" reachable + // here, and this region is announced rather than merely printed. + // + // ⭐ Removing the number is what makes the bare plural safe BY + // CONSTRUCTION, rather than by a predicate objectui#9169 had to remove + // for the announcement to exist at all. ⛔ Do not put a count back in + // any spelling: a number in this region needs a plural family across + // ten packs, and that decision was taken the other way. + // `residue-namespaces-3546.test.tsx` fails if one returns. /> )} diff --git a/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx new file mode 100644 index 0000000000..ec4bbdde0a --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx @@ -0,0 +1,234 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9170 — the board's empty state announces "no cards" and nothing else. + * + * ## The defect, measured in the DOM on objectui#9169's branch + * + * `KanbanImpl` composed the board-level empty state's description by + * CONCATENATION — the lane count, a space, then the pack's `kanban.columns` + * unit word, a bare plural with no singular form. Reading the live region's own + * `textContent` at three lane counts: + * + * ZERO: "No cards0 columns" ← grammatical (English takes the plural at 0) + * ONE: "No cards1 columns" ← the defect + * TWO: "No cards2 columns" ← grammatical + * + * `DataEmptyState` there is `role="status" aria-live="polite"`, so this is read + * ALOUD, not merely printed. + * + * ## ⭐ Why the string was safe until objectui#9169 and is not any more + * + * The bare plural was not a latent bug — it was CORRECT for every board that + * could reach it. The empty state used to require `boardColumns.length > 1`, so + * the count in front of `columns` was never 1. objectui#9169 removed that + * conjunct so a zero-lane and a one-lane board announce at all — that widening + * IS the accessibility fix — and the one-lane form became reachable with it. + * + * ## The repair, and what it is NOT + * + * The maintainer's ruling on objectui#9170 (2026-09-12) took the third of the + * card's three routes: the region's job is "no cards", the lane count is already + * visible on the board, and read aloud it is noise. The description is removed, + * so the announcement carries NO NUMBER AT ALL. + * + * ⛔ It is not the plural-family route (that was ruled against), and ⛔ it is not + * a retreat to the `> 1` predicate — the zero- and one-lane boards still + * announce, which is the whole of objectui#9045 and is asserted here as its own + * leg rather than assumed. + * + * ## Why "no digit" and not just "equals No cards" + * + * The three rows below are asserted three ways on purpose. Byte equality pins + * today's copy; the EQUALITY ACROSS the three rows is the claim the card's + * measurement actually makes — the same string at every lane count; and the + * absence of any digit is the one that survives a copy change, catching a count + * that comes back in a spelling nobody predicted. A repair that reworded the + * title would fail the first and still be held by the other two. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +// Registers `object-kanban`. +import '../index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +const ONE_LANE = [{ id: 'todo', title: 'To Do' }]; +const TWO_LANES = [ + { id: 'todo', title: 'To Do' }, + { id: 'in_progress', title: 'In Progress' }, +]; + +/** The lane shapes the card measured, in its own order. */ +const SHAPES: Array<[row: string, schema: Record]> = [ + ['ZERO', { type: 'object-kanban' }], + ['ONE', { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }], + ['TWO', { type: 'object-kanban', groupBy: 'status', columns: TWO_LANES }], +]; + +/** + * The board's only live region — `role="status" aria-live="polite"`. Queried off + * `document` rather than a render container so a portalled subtree could not + * read as an absence. + */ +const liveRegion = () => document.querySelector('[role="status"][aria-live="polite"]'); + +/** What assistive technology is handed: the region's own text, whole. */ +const announcement = () => (liveRegion()?.textContent ?? ''); + +/** + * Pump the event loop inside `act` until `pred()` holds or the budget expires, + * and REPORT whether it did — a rig that never completed must not read as an + * absence. + */ +async function pumpUntil(pred: () => boolean, budgetMs = 3000): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (pred()) return true; + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } + return pred(); +} + +/** + * Render an empty board at a given lane shape. `language` mounts a real + * `I18nProvider` — the path the console takes; `null` mounts none, which is the + * `createSafeTranslation` fallback path an embedder gets, and the path the + * card's original three-lane measurement was taken on. + */ +function renderEmptyBoard(schema: Record, language: string | null, rows: unknown[] = []) { + const find = vi.fn(() => Promise.resolve({ data: rows, total: rows.length })); + const dataSource = { find, findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }; + const board = ( + + + + ); + const result = render( + language === null ? ( + board + ) : ( + {board} + ), + ); + return { ...result, find }; +} + +/** + * Settle the board and PROVE it settled, then wait for the live region to carry + * text — a reading taken before either would be about a board mid-flight. + */ +async function settledAnnouncement(find: ReturnType): Promise { + await waitFor(() => expect(find).toHaveBeenCalled()); + // Non-empty rather than an English needle: the title comes from the pack, so a + // needle would make every non-`en` leg fail as a rig failure instead of as the + // reading it is. The exact text is asserted by the caller. + const painted = await pumpUntil(() => announcement().trim().length > 0); + expect(painted, 'RIG SELF-CHECK: the live region must have painted before it is read').toBe(true); + return announcement(); +} + +/** Read all three lane shapes in one language, one render each. */ +async function readAllThree(language: string | null): Promise> { + const out: Record = {}; + for (const [row, schema] of SHAPES) { + const { find } = renderEmptyBoard(schema, language); + out[row] = await settledAnnouncement(find); + cleanup(); + } + return out; +} + +afterEach(cleanup); + +describe('objectui#9170 — zero, one and two lanes announce the SAME numberless string', () => { + it('en, through the provider', async () => { + const read = await readAllThree('en'); + // 1. today's copy, byte for byte + expect(read).toEqual({ ZERO: 'No cards', ONE: 'No cards', TWO: 'No cards' }); + // 2. ⭐ the card's own claim: the same string at every lane count. Stated as + // an equality rather than three literals, so it keeps holding if the copy + // is reworded and stops holding the moment the rows diverge again. + expect(new Set(Object.values(read)).size, 'the three lane counts no longer read alike').toBe(1); + // 3. the leg that survives a copy change: no digit, in any spelling + for (const [row, text] of Object.entries(read)) { + expect(/\d/.test(text), `${row} put a number back into the live region: ${text}`).toBe(false); + } + // …and the exact string this card was filed on is named, so the defect is + // refused rather than merely absent. + expect(read.ONE).not.toBe('No cards1 columns'); + }); + + it('provider-less — the path the card measured, and an embedder gets', async () => { + // ⛔ NOT a second copy of the case above: this is the `createSafeTranslation` + // fallback, which resolves its own defaults table and never sees the pack. + // Route 3 is the only one of the card's three routes that is correct on BOTH + // paths at once, because a path with no number needs no plural logic — and + // `fallbackT` has none (it reads `defaults[key]` literally and never appends + // a suffix; see `packages/plugin-detail/src/useDetailTranslation.ts`). + const read = await readAllThree(null); + expect(read).toEqual({ ZERO: 'No cards', ONE: 'No cards', TWO: 'No cards' }); + expect(new Set(Object.values(read)).size).toBe(1); + for (const [row, text] of Object.entries(read)) { + expect(/\d/.test(text), `${row}: ${text}`).toBe(false); + } + }); + + it('ru — the numberless claim is language-independent, which is what route 3 buys', async () => { + // The route the ruling refused would have needed a plural family per pack, + // with `ru` reaching `few` at the everyday two-to-four lanes. With no number + // in the region there is nothing for any language's plural rules to act on, + // and that is visible here rather than argued: three lane counts, one string, + // in Russian. + const read = await readAllThree('ru'); + expect(read).toEqual({ ZERO: 'Нет карточек', ONE: 'Нет карточек', TWO: 'Нет карточек' }); + expect(new Set(Object.values(read)).size).toBe(1); + for (const [row, text] of Object.entries(read)) { + expect(/\d/.test(text), `${row}: ${text}`).toBe(false); + } + // …and it really is the pack answering, not an English fallback. + expect(read.ONE).not.toContain('No cards'); + }); +}); + +describe('objectui#9170 — the rows above are readings, not an empty probe', () => { + it('LIT CONTROL — a board WITH cards paints no live region at all', async () => { + // Without this, "no digit in the live region" would also be true of a board + // that never rendered one. ⛔ Not evidence of this card: it is what makes the + // three rows above evidence. + const { find, container } = renderEmptyBoard( + { type: 'object-kanban', groupBy: 'status', columns: ONE_LANE }, + 'en', + [{ id: '1', name: 'Alpha', status: 'todo' }], + ); + await waitFor(() => expect(find).toHaveBeenCalled()); + await waitFor(() => expect(container.textContent).toContain('Alpha')); + expect(liveRegion(), 'a board holding a card is not empty, whatever its lane count').toBeNull(); + }); + + it('⛔ objectui#9045 is NOT undone — one lane still announces, it just says less', async () => { + // Route 3 removes the NUMBER, never the announcement. A repair that restored + // the `> 1` predicate would satisfy every "no digit" leg above by making the + // region vanish on the two shapes objectui#9045 exists to serve. + for (const row of ['ZERO', 'ONE'] as const) { + const schema = SHAPES.find(([name]) => name === row)![1]; + const { find } = renderEmptyBoard(schema, 'en'); + const text = await settledAnnouncement(find); + expect(text, `${row} lanes: the board must still announce`).toBe('No cards'); + cleanup(); + } + }); +}); From b984d5694f2399dc77c86412b09925b81cddcbd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:31:34 +0000 Subject: [PATCH 6/8] test(plugin-kanban): make the three lane-count claims independently measurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two instrumentation defects in the pin added by the previous commit, both found by running the ablation against it rather than by reading it. ## Three assertions in one case cannot be told apart The three claims — today's copy byte for byte, the SAME string at every lane count, and no digit in any row — were three assertions inside one case, so the first to fail aborted the rest. Under a mutation that swaps the description for a different NUMBERLESS string, the byte pin fires and the other two are never evaluated: the ablation could not show they held, and three legs whose independence is unmeasured are one leg wearing three hats. They are separate cases now, sharing one memoised render set per path so the renders still happen once rather than once per claim. Measured against that mutation, the byte pin goes red on all three paths while the equality and no-digit claims stay green on all three — which is the statement that they are three claims and not one. ## A silent live region was a rig failure, and it is a READING The rig self-check waited for the live region itself, so a mutation that silences the board — restoring `boardColumns.length > 1` — reported "RIG SELF-CHECK: the live region must have painted", i.e. it read as a broken probe rather than as the regression it is. The check now waits for the BOARD, which is the thing that must exist for any reading to be possible, and a region that never paints returns `(no live region)`. ⭐ What that buys, measured: under the restored `> 1` guard the readings are `{ZERO: '(no live region)', ONE: '(no live region)', TWO: 'No cards'}` — the two lane counts objectui#9045 exists to serve go silent while the third does not, and the failure message says so. It also shows the no-digit leg is not sufficient on its own: silence carries no digits and passes it. That is exactly why the `objectui#9045 is NOT undone` case is in the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...tyStateNumberlessDescription-9170.test.tsx | 131 +++++++++++------- 1 file changed, 80 insertions(+), 51 deletions(-) diff --git a/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx index ec4bbdde0a..f1e598476e 100644 --- a/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx +++ b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx @@ -127,18 +127,32 @@ function renderEmptyBoard(schema: Record, language: string | nu return { ...result, find }; } +/** What a row reads when the board mounted and settled but announced nothing. */ +const SILENT = '(no live region)'; + /** - * Settle the board and PROVE it settled, then wait for the live region to carry - * text — a reading taken before either would be about a board mid-flight. + * Settle the board, PROVE it settled, then read the live region. + * + * ⚠️ The rig self-check is on the BOARD, not on the live region, and the + * difference is the whole diagnostic value of this helper. A missing live region + * is a READING — it is what a board that stopped announcing looks like, which is + * exactly the regression the `objectui#9045 is NOT undone` case below exists to + * catch — so it returns `SILENT` and lets the caller judge it. A missing BOARD is + * a rig failure and throws, because nothing can be read off a board that never + * mounted. + * + * Read as non-empty text rather than an English needle: the title comes from the + * pack, so a needle would make every non-`en` path fail as a rig failure instead + * of as the reading it is. The exact text is asserted by the caller. */ async function settledAnnouncement(find: ReturnType): Promise { await waitFor(() => expect(find).toHaveBeenCalled()); - // Non-empty rather than an English needle: the title comes from the pack, so a - // needle would make every non-`en` leg fail as a rig failure instead of as the - // reading it is. The exact text is asserted by the caller. + const mounted = await pumpUntil( + () => !!document.querySelector('[role="region"][aria-label="Kanban board"]'), + ); + expect(mounted, 'RIG SELF-CHECK: the board itself must be on screen').toBe(true); const painted = await pumpUntil(() => announcement().trim().length > 0); - expect(painted, 'RIG SELF-CHECK: the live region must have painted before it is read').toBe(true); - return announcement(); + return painted ? announcement() : SILENT; } /** Read all three lane shapes in one language, one render each. */ @@ -154,53 +168,67 @@ async function readAllThree(language: string | null): Promise { - it('en, through the provider', async () => { - const read = await readAllThree('en'); - // 1. today's copy, byte for byte - expect(read).toEqual({ ZERO: 'No cards', ONE: 'No cards', TWO: 'No cards' }); - // 2. ⭐ the card's own claim: the same string at every lane count. Stated as - // an equality rather than three literals, so it keeps holding if the copy - // is reworded and stops holding the moment the rows diverge again. - expect(new Set(Object.values(read)).size, 'the three lane counts no longer read alike').toBe(1); - // 3. the leg that survives a copy change: no digit, in any spelling - for (const [row, text] of Object.entries(read)) { - expect(/\d/.test(text), `${row} put a number back into the live region: ${text}`).toBe(false); - } - // …and the exact string this card was filed on is named, so the defect is - // refused rather than merely absent. - expect(read.ONE).not.toBe('No cards1 columns'); +/** + * One render set per path, reused by the three claims below. + * + * ⭐ The claims are SEPARATE cases on purpose. As one case they were three + * assertions in a row, and the first — byte equality — aborted before the other + * two ran: under a mutation that swaps the description for another NUMBERLESS + * string, the byte pin fires and the equality and no-digit legs are never + * evaluated, so an ablation cannot show that they held. Assertions that share a + * case cannot be measured independently, and three legs whose independence is + * unmeasured are one leg wearing three hats. + * + * The cache is what makes that affordable: the renders happen once per path, not + * once per claim. Nothing is cached unless the read completed, so a rig failure + * re-reads rather than poisoning the later cases with a stale answer. + */ +const READ_CACHE = new Map>(); +async function readings(language: string | null): Promise> { + const cacheKey = language ?? '(no provider)'; + const cached = READ_CACHE.get(cacheKey); + if (cached) return cached; + const fresh = await readAllThree(language); + READ_CACHE.set(cacheKey, fresh); + return fresh; +} + +/** + * The three paths this has to hold on. They are not redundant: + * + * - through the provider is what the console runs; + * - provider-less is `createSafeTranslation`'s fallback, which reads its own + * defaults table and never sees the pack — an embedder's path, and the one + * the card's original three-lane measurement was taken on; + * - `ru` is where the route this card did NOT take would have been hardest: + * a plural family there reaches `few` at the everyday two-to-four lanes. + * With no number in the region there is nothing for any language's plural + * rules to act on, and that is shown rather than argued. + */ +const PATHS: Array<[label: string, language: string | null, expected: string]> = [ + ['en, through the provider', 'en', 'No cards'], + ['provider-less — an embedder, and the path the card measured', null, 'No cards'], + ['ru — the numberless claim is language-independent', 'ru', 'Нет карточек'], +]; + +describe.each(PATHS)('objectui#9170 — %s', (_label, language, expected) => { + it('reads the same copy, byte for byte, at zero / one / two lanes', async () => { + expect(await readings(language)).toEqual({ ZERO: expected, ONE: expected, TWO: expected }); }); - it('provider-less — the path the card measured, and an embedder gets', async () => { - // ⛔ NOT a second copy of the case above: this is the `createSafeTranslation` - // fallback, which resolves its own defaults table and never sees the pack. - // Route 3 is the only one of the card's three routes that is correct on BOTH - // paths at once, because a path with no number needs no plural logic — and - // `fallbackT` has none (it reads `defaults[key]` literally and never appends - // a suffix; see `packages/plugin-detail/src/useDetailTranslation.ts`). - const read = await readAllThree(null); - expect(read).toEqual({ ZERO: 'No cards', ONE: 'No cards', TWO: 'No cards' }); - expect(new Set(Object.values(read)).size).toBe(1); - for (const [row, text] of Object.entries(read)) { - expect(/\d/.test(text), `${row}: ${text}`).toBe(false); - } + it('⭐ the SAME string at every lane count — the card\'s actual claim', async () => { + // Stated as an equality rather than three literals: it keeps holding if the + // copy is reworded, and stops holding the moment the rows diverge again — + // which is precisely what "1 columns" was. + const read = await readings(language); + expect(new Set(Object.values(read)).size, `the three lane counts no longer read alike: ${JSON.stringify(read)}`).toBe(1); }); - it('ru — the numberless claim is language-independent, which is what route 3 buys', async () => { - // The route the ruling refused would have needed a plural family per pack, - // with `ru` reaching `few` at the everyday two-to-four lanes. With no number - // in the region there is nothing for any language's plural rules to act on, - // and that is visible here rather than argued: three lane counts, one string, - // in Russian. - const read = await readAllThree('ru'); - expect(read).toEqual({ ZERO: 'Нет карточек', ONE: 'Нет карточек', TWO: 'Нет карточек' }); - expect(new Set(Object.values(read)).size).toBe(1); - for (const [row, text] of Object.entries(read)) { - expect(/\d/.test(text), `${row}: ${text}`).toBe(false); + it('carries no digit in any row — the leg that survives a rewording', async () => { + // A count that comes back in a spelling nobody predicted is still a count. + for (const [row, text] of Object.entries(await readings(language))) { + expect(/\d/.test(text), `${row} put a number back into the live region: ${text}`).toBe(false); } - // …and it really is the pack answering, not an English fallback. - expect(read.ONE).not.toContain('No cards'); }); }); @@ -222,12 +250,13 @@ describe('objectui#9170 — the rows above are readings, not an empty probe', () it('⛔ objectui#9045 is NOT undone — one lane still announces, it just says less', async () => { // Route 3 removes the NUMBER, never the announcement. A repair that restored // the `> 1` predicate would satisfy every "no digit" leg above by making the - // region vanish on the two shapes objectui#9045 exists to serve. + // region vanish on the two shapes objectui#9045 exists to serve — which is + // the ablation leg this case exists to catch. for (const row of ['ZERO', 'ONE'] as const) { const schema = SHAPES.find(([name]) => name === row)![1]; const { find } = renderEmptyBoard(schema, 'en'); const text = await settledAnnouncement(find); - expect(text, `${row} lanes: the board must still announce`).toBe('No cards'); + expect(text, `${row} lanes: the board went silent — that is objectui#9045 undone`).toBe('No cards'); cleanup(); } }); From 95d818742a266e0ee2adfa29c8c65b512b6c8d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:33:27 +0000 Subject: [PATCH 7/8] test(schema-catalog): re-derive the `items` census against the description route 3 removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was red on `Test (shard 1/4)` at `examples/schema-catalog/test/kanban-column-cards-6939.test.tsx`, two arms, each short by exactly one element: `expected 57 to be 58` and `expected 44 to be 45`. ## Which kind of failure this was Two readings are possible when a census moves, and they have opposite repairs: a census correctly reporting that the change removed something it was counting, or a census reporting that the change removed something it should not have. It is the first, and the evidence is that the subtraction is exactly the node route 3 set out to remove and nothing else. `DataEmptyState` renders its description as `{description &&

{description}

}`, so passing none removes exactly one node. The `items`-spelling arm is where it shows: `bucketCardsIntoColumns` reads `col.cards || []`, so under `items` every column buckets to zero, the board is empty, and the board-level region paints. Its pinned text said so in as many words — `"No cards3 columns…"` and `"No cards4 columns…"`. ⇒ one `

` fewer, the `P: 1` tag entry gone, and the `"N columns"` phrase out of the text. Nothing else. That was established as a PREDICTION rather than by copying the new numbers out of a failure: the element counts, the tag censuses and both visible texts were written from the model of the change and all six passed on the first run, with only the two SHA-256 placeholders left to measure. A census updated by pasting back whatever the run printed cannot tell you that. ## ⛔ Not a decrement The literals are not lowered in place. The pre-9170 reading is kept as `ITEMS_SPELLING_6939` — the measurement objectui#6939 was decided on — and the current one is written beside it as its own literal, with a new case asserting the ONE difference between them: one element fewer, the `P` entry gone and every other tag identical, and the lane-count phrase (derived from the document's own column count, not hard-coded) removed from the text with the remainder equal. A census that absorbs a change into new numbers records THAT something moved and destroys the evidence of WHAT. The next reader of a bare `58 -> 57` cannot tell a removed description from a removed lane; these two literals and the subtraction between them say which. The header prose keeps objectui#6939's four original readings verbatim and now says which two of them the later card moved, rather than reading as current. ## Ablation | leg | mutation | result | |---|---|---| | **E** | lower the new census further (44 -> 43, one `DIV` fewer) — the shape a lazy update takes | 2 red: the live census (`expected 44 to be 43`) and ⭐ the new delta case (`expected 43 to be 44`), which is the case that exists to catch exactly this | | **F** | remove the empty state's `title` as well, i.e. the change taking more than it should | 2 red, and ⭐ on `visibleText`, not on the count: `DataEmptyState` falls back to a default title, so the element count is unmoved and only the text reading sees it. The three-reading census is not three copies of one reading | Both legs proved the mutation on disk in both directions with the blob hash moving, and restored under a `trap` verified by hash against the pre-ablation working tree. ## Why the earlier local runs missed this The change removed a CALL SITE. The blast radius of that is every census keyed on that call site, not merely every test that reads the locale packs — and the filters used were `packages/i18n/`, `packages/plugin-kanban/` and the files matching `builtInLocales`. This census lives in `examples/schema-catalog/` and reads the kanban surface without importing a pack, so no filter reached it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../test/kanban-column-cards-6939.test.tsx | 107 +++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx index e45cc8fd78..f04555e72d 100644 --- a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx +++ b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx @@ -34,6 +34,14 @@ * advanced-…-and-limits `cards` 86 elements, "Backlog2 … User Authentication …" * `items` 58 elements, "No cards4 columnsBacklog0 …" * + * ⚠️ Those four readings are the measurement objectui#6939 was decided on and + * they are left verbatim. Two of them are no longer what the harness returns: + * objectui#9170 removed the lane count from the board-level empty state, so the + * `items` rows lost one element and the "N columns" phrase each. The `cards` + * rows are untouched — those boards hold cards, so the board-level empty state + * never paints on them. The current readings, and the subtraction between the + * two, are below. + * * `column.cards || []` in `bucketCardsIntoColumns` is the mechanism: under the * `items` spelling every column buckets to zero cards. So the accepted spelling * had to move to `cards` — renaming the twelve read sites instead would have @@ -129,11 +137,18 @@ const PRE_REPAIR: Record<(typeof IDS)[number], Reading> = { }; /** - * The `items` spelling, measured in the same run. This is the board the - * declaration was asking authors to write, and it is empty. Pinned so that the - * claim "the rename is toward the shape that ships" stays a measurement. + * The `items` spelling, measured in the same run on `78a3cc238`. This is the + * board the declaration was asking authors to write, and it is empty. Pinned so + * that the claim "the rename is toward the shape that ships" stays a + * measurement. + * + * ⚠️ SUPERSEDED as the expected reading by objectui#9170, and kept for a reason + * rather than out of sentiment: the current reading below is stated as this one + * MINUS one named node, and that subtraction is asserted. A census updated by + * overwriting its own numbers records that something moved and destroys the + * evidence of what; two literals and a delta case keep both. */ -const ITEMS_SPELLING: Record<(typeof IDS)[number], Reading> = { +const ITEMS_SPELLING_6939: Record<(typeof IDS)[number], Reading> = { 'plugin-kanban/basic-kanban-board': { elements: 45, tags: { DIV: 31, SPAN: 6, H3: 4, P: 1, STYLE: 3 }, @@ -148,6 +163,36 @@ const ITEMS_SPELLING: Record<(typeof IDS)[number], Reading> = { }, }; +/** + * The `items` spelling as it reads TODAY, after objectui#9170 removed the lane + * count from the board-level empty state's description. + * + * ⭐ Written as its own literal rather than derived from the baseline above: a + * computed expectation would agree with that baseline by construction, and the + * whole point of keeping both is that the delta case can compare two + * independently written readings. + * + * objectui#6939's own result is UNCHANGED by that card and still visible here — + * the `items` board is still empty, still says "No cards", and still reads zero + * on every column. What left is one `

`: `DataEmptyState` renders its + * description as `{description &&

{description}

}`, and route 3 passes + * none. + */ +const ITEMS_SPELLING: Record<(typeof IDS)[number], Reading> = { + 'plugin-kanban/basic-kanban-board': { + elements: 44, + tags: { DIV: 31, SPAN: 6, H3: 4, STYLE: 3 }, + sha256: '16bf02258c44ba4044d5d1336c0e6d7d9db3b879846d9247d5162fe7ea0b4586', + visibleText: "No cardsTo Do0In Progress0Done0\n To pick up a draggable item, press the space bar.\n While dragging, use the arrow keys to move the item.\n Press space again to drop the item in its new position, or press escape to cancel.\n ", + }, + 'plugin-kanban/advanced-kanban-with-badges-and-limits': { + elements: 57, + tags: { DIV: 39, SPAN: 9, H3: 5, STYLE: 4 }, + sha256: '6adfe4595da25bb8e84f679d3e7e4517258ad5eac8facbd5cc9c6632f8fa8e76', + visibleText: "No cardsBacklog0Work In Progress0 / 3Code Review0Completed0\n To pick up a draggable item, press the space bar.\n While dragging, use the arrow keys to move the item.\n Press space again to drop the item in its new position, or press escape to cancel.\n ", + }, +}; + /** Render one entry through the provider-wrapped bare renderer and measure it. */ async function measure(schema: unknown): Promise { const { container, unmount } = render( @@ -305,4 +350,58 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer for (const card of column.cards) expect(m.visibleText).not.toContain(card.title); } }); + + it.each(IDS)('%s: the `items` census moved by exactly the description objectui#9170 removed', (id) => { + // ⭐ THE RE-DERIVATION. This case did not exist before objectui#9170. The + // `items` readings were absolute numbers measured on `78a3cc238`, and route 3 + // moved two of the four on each arm — CI said `expected 57 to be 58` and + // `expected 44 to be 45`. + // + // ⛔ Decrementing those literals would have recorded THAT something moved + // and destroyed the evidence of WHAT: the next reader would find a census + // one lower than the card that established it, with nothing saying whether a + // description, a lane or a wrapper had gone. So both readings are kept, each + // written as its own literal, and the ONE difference between them is + // asserted here. Anything else that moves this census — a lane that stops + // rendering, a wrapper that appears, a second string that disappears — fails + // this case instead of being absorbed into a new baseline. + // + // ⚠️ This is also the case that decides WHICH KIND of failure the CI red was. + // A census that moves because the change removed something it was counting is + // a correct report; a census that moves because the change removed something + // else is a defect in the change. Legs (1) and (2) below are what tell those + // apart, and they say: exactly the description `

`, exactly the lane-count + // phrase, nothing else. + const before = ITEMS_SPELLING_6939[id]; + const after = ITEMS_SPELLING[id]; + + // (1) one element fewer, and it is the description `

` — + // `DataEmptyState` renders `{description &&

{description}

}`, so + // passing no description removes exactly that node. + expect(after.elements).toBe(before.elements - 1); + const { P, ...everyOtherTag } = before.tags; + expect(P, 'the pre-9170 census must contain the

this card removed, or the subtraction is imaginary').toBe(1); + expect(after.tags, 'a tag other than the description

moved').toEqual(everyOtherTag); + + // (2) exactly the lane-count phrase left the text. The phrase is derived + // from the DOCUMENT's own lane count rather than hard-coded, so the two + // entries are checked against their own shapes and a fixture that gains a + // lane cannot quietly satisfy this with the other one's number. + const laneCount = `${(getExample(id).schema as { columns: unknown[] }).columns.length} columns`; + expect(before.visibleText, `the pre-9170 text must contain "${laneCount}"`).toContain(laneCount); + expect(before.visibleText.replace(laneCount, ''), 'more than the lane count left the text').toBe( + after.visibleText, + ); + expect(after.visibleText, 'a lane count is back in the announcement').not.toMatch(/\d+ columns/); + + // (3) ⛔ objectui#6939's own result is NOT what moved. The `items` board is + // still empty, still announces, and still reads zero on every column — which + // is the finding this whole file exists to hold, and route 3 does not touch + // it. + expect(before.visibleText.startsWith('No cards'), 'the pre-9170 board announced').toBe(true); + expect(after.visibleText.startsWith('No cards'), 'the board stopped announcing — that is not this card').toBe(true); + // …and the two readings really are two, not one constant referenced twice. + expect(after.sha256).not.toBe(before.sha256); + expect(after.elements).not.toBe(before.elements); + }); }); From 826285a8e93df869ba0f32654fa778c8a6e469fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:44:49 +0000 Subject: [PATCH 8/8] docs(changeset): anchor objectui#6939's element reading, which this PR moved by one node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-changeset-claims.mjs` flagged this body as naming a file this change touches, and it was right: the paragraph states that the `items` spelling takes `basic-kanban-board` "to 45 elements reading `No cards3 columnsTo Do0 …`", and route 3 makes that 44 elements reading `No cardsTo Do0 …`. This is the gate's WENT-FALSE class, and it matters because the two changesets publish VERBATIM into the CHANGELOG of the SAME release: readers would find a released note describing a render the released code does not produce. ⛔ Not rewritten — anchored. The measurement is dated to `78a3cc238` where it was taken, and a following note records the one node that left and why. The argument the paragraph exists to make is untouched and still true: the `items` spelling empties the board, which is why the declaration rather than the corpus was the wrong side. The three other pending changesets the gate listed were read and none of their claims is falsified by this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/6939-kanban-column-cards.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.changeset/6939-kanban-column-cards.md b/.changeset/6939-kanban-column-cards.md index 293b4d8519..88808ffb89 100644 --- a/.changeset/6939-kanban-column-cards.md +++ b/.changeset/6939-kanban-column-cards.md @@ -23,10 +23,18 @@ while rendering perfectly, which is how the type sat in objectui#6318's sites to `items` was considered and rejected: `bucketCardsIntoColumns` reads `col.cards || []`, so the `items` spelling buckets every column to zero cards. Measured through the render harness in -`examples/schema-catalog/test/kanban-column-cards-6939.test.tsx`, the -`basic-kanban-board` entry goes from 64 elements reading `To Do2 … Design new -feature …` to 45 elements reading `No cards3 columnsTo Do0 …` — an empty board. -The declaration, not the corpus, was the wrong side. +`examples/schema-catalog/test/kanban-column-cards-6939.test.tsx` on `78a3cc238`, +the `basic-kanban-board` entry goes from 64 elements reading `To Do2 … Design +new feature …` to 45 elements reading `No cards3 columnsTo Do0 …` — an empty +board. The declaration, not the corpus, was the wrong side. + +⚠️ The second of those two readings has since moved by one node, and this +paragraph is anchored rather than rewritten because the finding it supports is +unchanged: objectui#9170 removed the lane count from the board-level empty +state, so the same entry now measures 44 elements and reads `No cardsTo Do0 …`. +The `items` spelling still empties the board, which is the whole of the argument +above. The harness keeps both readings side by side and asserts the subtraction +between them. **Migration.** If you author `KanbanColumn` objects against `@object-ui/types` or validate them through `@object-ui/types/zod`, rename `items` to `cards`.