Skip to content
Merged
16 changes: 12 additions & 4 deletions .changeset/6939-kanban-column-cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
65 changes: 65 additions & 0 deletions .changeset/9045-kanban-empty-state-lane-count.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions .changeset/9170-kanban-empty-state-drops-lane-count.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 103 additions & 4 deletions examples/schema-catalog/test/kanban-column-cards-6939.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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 `<p>`: `DataEmptyState` renders its
* description as `{description && <p …>{description}</p>}`, 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<Reading & { text: string }> {
const { container, unmount } = render(
Expand Down Expand Up @@ -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 `<p>`, 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 `<p>` —
// `DataEmptyState` renders `{description && <p …>{description}</p>}`, 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 <p> this card removed, or the subtraction is imaginary').toBe(1);
expect(after.tags, 'a tag other than the description <p> 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);
});
});
Loading
Loading