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`.
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..c403235543
--- /dev/null
+++ b/.changeset/9045-kanban-empty-state-lane-count.md
@@ -0,0 +1,65 @@
+---
+'@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.
+
+⚠️ 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
+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/.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/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 `
{
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);
+ });
});
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 3dbdb7ff4a..7b505b9032 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
@@ -898,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.
/>
)}
@@ -1076,9 +1104,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__/emptyStateNumberlessDescription-9170.test.tsx b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx
new file mode 100644
index 0000000000..f1e598476e
--- /dev/null
+++ b/packages/plugin-kanban/src/__tests__/emptyStateNumberlessDescription-9170.test.tsx
@@ -0,0 +1,263 @@
+/**
+ * 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 };
+}
+
+/** What a row reads when the board mounted and settled but announced nothing. */
+const SILENT = '(no live region)';
+
+/**
+ * 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());
+ 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);
+ return painted ? announcement() : SILENT;
+}
+
+/** 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);
+
+/**
+ * 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('⭐ 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('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);
+ }
+ });
+});
+
+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 — 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 went silent — that is objectui#9045 undone`).toBe('No cards');
+ cleanup();
+ }
+ });
+});
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' }];