@terpjs/react-core 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,21 +84,24 @@ const UNREAD_TOKENS: Record<string, string[]> = {
84
84
  "--shell-": [],
85
85
  "--color-sidebar-": [],
86
86
  // The whole semantic colour layer, which the comment above admitted was untracked and then
87
- // left untracked. Seventeen of the forty-eight published `--color-` tokens have no `var()`
88
- // reader anywhere in the sheet, and until now nothing said so — the exact `--color-fg-on-brand`
89
- // shape that comment describes, seventeen times over.
87
+ // left untracked. Booked at seventeen of the forty-eight published `--color-` tokens with no
88
+ // `var()` reader anywhere in the sheet — the exact `--color-fg-on-brand` shape that comment
89
+ // describes, seventeen times over. Fifteen now: the flat-card change wired
90
+ // `--color-interactive-hover` (the table row's hover, which had been spelling that value as a
91
+ // ramp step) and `--color-interactive-selected` (the selected row and the selection band,
92
+ // which had no colour of their own at all).
90
93
  //
91
94
  // Booked as one family rather than four narrower ones on purpose. `--color-bg-`,
92
- // `--color-border-`, `--color-interactive-` and `--color-chart-` are each read by NOTHING, and
93
- // the assertion below requires a tracked family to publish more than it books — a deliberate
94
- // "is the prefix right?" check that a wholly-unread family would trip. The broader prefix
95
- // subsumes them and still catches the eighteenth. `--color-sidebar-` stays above because it
96
- // makes a narrower claim worth keeping: that family is read in full.
95
+ // `--color-border-` and `--color-chart-` are each STILL read by nothing, and the assertion
96
+ // below requires a tracked family to publish more than it books — a deliberate "is the prefix
97
+ // right?" check that a wholly-unread family would trip. The broader prefix subsumes them and
98
+ // still catches the sixteenth. `--color-sidebar-` stays above because it makes a narrower
99
+ // claim worth keeping: that family is read in full.
97
100
  //
98
- // None of these seventeen is a defect on its own. They are a published vocabulary that shipped
99
- // ahead of its consumers — four surface tokens, three borders, three interactive states, a
101
+ // None of these fifteen is a defect on its own. They are a published vocabulary that shipped
102
+ // ahead of its consumers — four surface tokens, three borders, one interactive state, a
100
103
  // five-step chart ramp and two neutrals — and the point of booking them is that the list can
101
- // only shrink from here, so wiring one is visible and adding an eighteenth has to argue.
104
+ // only shrink from here, so wiring one is visible and adding a sixteenth has to argue.
102
105
  "--color-": [
103
106
  "--color-bg-canvas",
104
107
  "--color-bg-inset",
@@ -113,8 +116,6 @@ const UNREAD_TOKENS: Record<string, string[]> = {
113
116
  "--color-chart-4",
114
117
  "--color-chart-5",
115
118
  "--color-interactive-active",
116
- "--color-interactive-hover",
117
- "--color-interactive-selected",
118
119
  "--color-neutral-500",
119
120
  "--color-neutral-800",
120
121
  ],
@@ -228,6 +229,45 @@ describe("design tokens", () => {
228
229
  }
229
230
  });
230
231
 
232
+ it("tightens the shell gutter for a phone rather than remapping it to itself", () => {
233
+ // --shell-gutter is published once and remapped once, on the shell root under
234
+ // data-variant="mobile". Existence is not the property worth holding: a remap that
235
+ // resolved back to the published value would leave every structural check green while
236
+ // the phone silently kept the desktop measure, and the whole reason the token exists is
237
+ // that the header, main, the footer and the page band all read it.
238
+ //
239
+ // Here rather than in styles.test.ts because the control is the PUBLISHED value, which
240
+ // means reading tokens.css — this file's subject, and this file runs in node where
241
+ // that read works.
242
+ const published = /--shell-gutter:\s*([^;]+);/.exec(tokensCss);
243
+ expect(published, "tokens.css must publish --shell-gutter").not.toBeNull();
244
+ const remap = /\[data-terp="appshell"\]\[data-variant="mobile"\] \{([^}]*)\}/.exec(sheet);
245
+ expect(remap, "the sheet must remap the gutter for a phone").not.toBeNull();
246
+ const remapped = /--shell-gutter:\s*([^;]+);/.exec(remap![1]!);
247
+ expect(remapped, "the remap must move --shell-gutter, not something else").not.toBeNull();
248
+ // It must stay on the spacing scale rather than becoming a literal, which is one way a
249
+ // shared measure rots.
250
+ expect(remapped![1]!.trim(), "the phone gutter must name a spacing token").toMatch(
251
+ /^var\(--space-\d+\)$/,
252
+ );
253
+ // RESOLVED, not spelled. Comparing the two strings was the first version of this and it
254
+ // could not fail the way that matters: `var(--space-4)` and `1rem` are different text and
255
+ // the same length, so publishing the gutter at the phone's own value passed while the
256
+ // remap became a no-op. Both sides resolve through tokens.css before they are compared.
257
+ const resolve = (value: string): string => {
258
+ const reference = /^var\((--[a-z0-9-]+)\)$/.exec(value.trim());
259
+ if (reference === null) return value.trim();
260
+ const declared = new RegExp(`${reference[1]!}:\\s*([^;]+);`).exec(tokensCss);
261
+ expect(declared, `${reference[1]!} must be declared in tokens.css`).not.toBeNull();
262
+ return declared![1]!.trim();
263
+ };
264
+ expect(
265
+ resolve(remapped![1]!),
266
+ "a remap that resolves to the published gutter is dead weight, and the phone silently "
267
+ + "keeps the desktop measure",
268
+ ).not.toBe(resolve(published![1]!));
269
+ });
270
+
231
271
  it("holds the bare type literals at their recorded count", () => {
232
272
  // The debt the type scale's arrival did not clear, as a ratchet. See BARE_TYPE_LITERALS:
233
273
  // most of these map onto no step in the published scale, so converting them changes
@@ -36,6 +36,44 @@ describe("Card", () => {
36
36
  expect(screen.getByRole("button", { name: "Nieuw" })).toBeInTheDocument();
37
37
  });
38
38
 
39
+ it("keeps the actions slot a sibling of the heading, description or not", () => {
40
+ // The DOM half of a defect whose visible half was CSS. `actions` is documented as a slot in
41
+ // the header ROW, and it stopped being one the moment `description` was set: the heading was
42
+ // sized from its content, a block holding a title and a sentence is as wide as the sentence,
43
+ // and flex breaks lines on that width before it shrinks anything — so the header wrapped and
44
+ // the control landed underneath. Measured 103px against 48px for the same component one prop
45
+ // apart.
46
+ //
47
+ // What the sheet's fix needs from the markup is exactly this shape: the description inside
48
+ // the heading (so :has() can find it and so the two lines stay one block) and the actions
49
+ // slot OUTSIDE it, as a sibling. Nesting the slot in the heading, or lifting the description
50
+ // out of it, would each render plausibly and defeat the rule silently — which is why this is
51
+ // asserted structurally rather than left to a screenshot.
52
+ for (const description of [undefined, "Wat dit blok beschrijft."]) {
53
+ cleanup();
54
+ render(
55
+ <Card
56
+ title="Projecten"
57
+ description={description}
58
+ actions={<button type="button">Nieuw</button>}
59
+ >
60
+ inhoud
61
+ </Card>,
62
+ );
63
+ const header = screen
64
+ .getByRole("heading", { level: 3, name: "Projecten" })
65
+ .closest('[data-terp="card-header"]') as HTMLElement;
66
+ const heading = header.querySelector('[data-terp="card-heading"]') as HTMLElement;
67
+ const actions = header.querySelector('[data-terp="card-actions"]') as HTMLElement;
68
+ expect(actions.parentElement, "the actions slot is the heading's sibling").toBe(header);
69
+ expect(heading.contains(actions)).toBe(false);
70
+ expect(
71
+ heading.querySelector('[data-terp="card-description"]') !== null,
72
+ "the description lives inside the heading, which is what the :has() rule reads",
73
+ ).toBe(description !== undefined);
74
+ }
75
+ });
76
+
39
77
  it("renders headerless with children only", () => {
40
78
  render(<Card>alleen inhoud</Card>);
41
79
  const card = screen.getByText("alleen inhoud").closest('[data-terp="card"]');
@@ -43,7 +81,7 @@ describe("Card", () => {
43
81
  });
44
82
  it("names the plain variant and leaves the boxed default unmarked", () => {
45
83
  // The chrome-less titled region. A variant rather than a `Section` component of its own,
46
- // because a chrome-less region is this element with three declarations removed — a second
84
+ // because a chrome-less region is this element with two declarations removed — a second
47
85
  // component would have meant six more markers describing the same DOM, and a `Surface` is
48
86
  // a Card with no title, which this already is.
49
87
  const { rerender } = render(<Card variant="plain" title="Plain">body</Card>);
package/src/ui/Card.tsx CHANGED
@@ -21,7 +21,7 @@ export interface CardProps
21
21
  * loses its full width.
22
22
  *
23
23
  * It is a variant rather than a `Section` component of its own, and that is a decision worth
24
- * knowing. A chrome-less titled region is exactly this element with three declarations
24
+ * knowing. A chrome-less titled region is exactly this element with two declarations
25
25
  * removed: `Card` already renders a `<section>` with an `<h3>` and stacks its children on
26
26
  * the token scale. A second component would have meant six more markers describing the same
27
27
  * DOM, and a `Surface` — the third name the diagnosis suggested — is a `Card` with no title,
@@ -32,7 +32,18 @@ export interface CardProps
32
32
  title?: UiText;
33
33
  /** Optional muted one-liner under the title (what this block is about). */
34
34
  description?: UiText;
35
- /** Optional right-hand slot in the header row (filters, a legend, an action). */
35
+ /**
36
+ * Optional right-hand slot in the header row (filters, a legend, an action).
37
+ *
38
+ * It stays on the title's line whether or not there is a {@link CardProps.description}, and
39
+ * that took a rule rather than coming for free: the header wraps, and a heading block sized
40
+ * from its content is as wide as its longest line — a description sentence — so flex broke
41
+ * the line before it shrank anything and the slot landed underneath. Measured at a 103px
42
+ * header against 48px for the same component with the description removed. The heading's
43
+ * flex base is 0 now, so both fit on one line by construction, and the header aligns to
44
+ * `start` once a description is present so the control sits beside the title rather than
45
+ * floating in the middle of the block.
46
+ */
36
47
  actions?: ReactNode;
37
48
  /** The rendered element — `"section"` by default (a titled block of a page). */
38
49
  as?: "section" | "article" | "div" | "aside";
@@ -42,10 +53,11 @@ export interface CardProps
42
53
  }
43
54
 
44
55
  /**
45
- * A token-styled surface that groups one block of a page — the sanctioned way to give
46
- * sections visual separation (border + background + padding) without module CSS, and
56
+ * A token-styled frame that groups one block of a page — the sanctioned way to give
57
+ * sections visual separation (a border, a radius and padding) without module CSS, and
47
58
  * allowed directly in `OverviewPage` / `DetailPage` body slots under the `standard`
48
- * layout contract. An optional header row carries a semantic `<h3>` title, a muted
59
+ * layout contract. It carries no fill: what shows through a card is the page's own
60
+ * canvas, so a card sits on a themed background instead of repainting it. An optional header row carries a semantic `<h3>` title, a muted
49
61
  * description and an `actions` slot; the body stacks its children on the token
50
62
  * spacing scale.
51
63
  */