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 `

`: `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); + }); }); 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' }];