origam 2.18.1 → 2.18.3

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.
package/CHANGELOG.md CHANGED
@@ -177,10 +177,52 @@ conclure qu'elle ne l'est plus par défaut.
177
177
 
178
178
  Rejoué sur 30 composants × 8 identités × 2 modes × 2 portées (racine ET
179
179
  sous-arbre), soit **1 664 instances**, Chromium, contrôles positif et négatif
180
- verts : **189 → 11 violations AA**, dont sombre **184 → 6** et clair
181
- **5 → 5** (aucune régression). Les 11 restantes appartiennent toutes aux
182
- palettes de marque de `packages/marketing`, aucune au DS. Harnais :
183
- `pnpm -F @origam/tests audit:dark-contrast`.
180
+ verts : **185 → 7 violations AA**, dont sombre **182 → 4** et clair
181
+ **3 → 3** (aucune régression). Les 7 restantes appartiennent toutes aux
182
+ palettes de marque de `packages/marketing`, aucune au DS, et aucune ne
183
+ descend sous 2:1. Harnais : `pnpm -F @origam/tests audit:dark-contrast`.
184
+
185
+ ⚠️ **Ces bornes ont d'abord été publiées à `189 → 11`, et les deux étaient
186
+ fausses de 4.** Le parseur de couleurs du harnais ne connaissait pas
187
+ `color(srgb …)`, que la palette `apple` émet : le fond translucide du
188
+ tooltip était pris pour « pas de fond », la remontée d'ancêtres sautait
189
+ jusqu'à la surface opaque et rapportait du noir sur noir à 1.00 — quatre
190
+ violations **fabriquées**, présentes des DEUX côtés de la mesure. Le
191
+ **delta de 178 n'a jamais été faux** ; seules les bornes l'étaient. Détail
192
+ et garde de non-récidive plus bas.
193
+
194
+ - **#871 — le harnais de contraste ne savait pas lire `color(srgb …)`, et
195
+ FABRIQUAIT donc des violations.** Son `parse()` ne reconnaissait que
196
+ `rgb()` / `rgba()`. Un fond qu'il ne sait pas lire n'est pas « ignoré » : il
197
+ est traité comme **non peint**, la remontée d'ancêtres saute l'élément et
198
+ composite contre une couche qui n'est pas celle que voit l'œil. Sur le
199
+ tooltip `apple` — `color(srgb 0.898 0.898 0.906 / 0.94)` sur une surface
200
+ noire — cela donnait du noir sur noir à **1.00** là où le rendu réel est
201
+ ~`rgb(215,215,217)` sur noir. 4 instances (root + sous-arbre × clair +
202
+ sombre), des deux côtés de la mesure.
203
+
204
+ `parse()` reconnaît désormais `color(srgb r g b [/ a])`, avec **la même
205
+ expression régulière que `srgbToRgb` dans `contrast.directive.ts`** — la
206
+ vraie directive gérait déjà cette forme, et son commentaire le disait :
207
+ *« design tokens resolve to exactly this form in several themes »*.
208
+
209
+ ⛔ **Un quatrième contrôle, parce que le correctif ponctuel ne suffit pas.**
210
+ Le harnais échoue maintenant (`$? = 1`) dès qu'une chaîne de couleur non
211
+ vide lui est illisible, en listant les formes rencontrées. Sans lui, la
212
+ prochaine syntaxe (`oklch()`, `lab()`, résidu de `color-mix()`) décalerait
213
+ le chiffre en silence exactement de la même façon. Contrôle positif du
214
+ garde lui-même : branche `srgb` désactivée → `$? = 1`, 8 formes listées, et
215
+ le compte réaffiche l'ancien **11** ; branche active → `$? = 0`, tout lu,
216
+ **7**.
217
+
218
+ ⚠️ #871 listait déjà ce risque sous « non vérifié » — *« les couleurs
219
+ `oklch()` / `color(srgb …)` — aucun token actuel n'en emploie, mais un futur
220
+ thème invaliderait l'instrumentation »*. Un thème le faisait **déjà** au
221
+ moment où la phrase a été écrite. Écart signalé en marge de la **PR #882**
222
+ (#869, `v-contrast`) par l'agent qui rejouait ce harnais avec la vraie
223
+ directive au lieu du stub ; reproduit, remesuré et corrigé ici. Aucun ticket
224
+ propre n'a été ouvert : le défaut est dans l'instrumentation de #871, donc
225
+ il se règle sous #871.
184
226
 
185
227
  - **#871 — `system-bar` et `tooltip` illisibles en sombre.** Les deux peignent
186
228
  une surface sombre dans les DEUX modes (`neutral---700` / `neutral---800`)
@@ -220,8 +262,123 @@ conclure qu'elle ne l'est plus par défaut.
220
262
  chacun. L'affirmation décrivait un état antérieur à #794 que plus rien ne
221
263
  remesurait.
222
264
 
265
+ ### Fixed
266
+
267
+ - **#371 (point 3) — le sélecteur « Items per page » du footer
268
+ DataTable n'avait aucun nom accessible**, déjà corrigé par `3f063d727`
269
+ (2026-09-10) — ce commit **citait #371 sans le fermer**, sixième
270
+ occurrence cette semaine du même motif (cf. #570, #682, #537, #371
271
+ point 1, #371 point 2/ADR-005). Re-mesuré ici avant tout travail sur les
272
+ points restants du ticket : le `<span>` visible est désormais associé au
273
+ `<origam-select>` via `aria-labelledby`, et la suite unitaire complète
274
+ reste verte (7120/7120) — aucun code de production retouché sur ce point,
275
+ seule la mesure manquait au tableau.
276
+
277
+ - **`<OrigamDataTableRow>` ne fuit plus `index` / `mobile` en attributs DOM**
278
+ (#371, point 4). `itemSlotProps()` (`OrigamDataTableRows.vue`) construisait
279
+ la ligne d'item avec deux clés qu'`IDataTableRowProps` ne déclare pas —
280
+ `index`, jamais lu par le composant, et `mobile`, un résidu d'avant
281
+ l'ajout du forwarding de `mobileBreakpoint` (chaque ligne calcule déjà son
282
+ propre `mobile` à partir de ce dernier). Une clé non déclarée tombe dans
283
+ `$attrs`, et le `v-bind="$attrs"` du `<tr>` racine la posait en attribut
284
+ DOM littéral sur **chaque** ligne rendue — `index="0" mobile="false"`.
285
+ Même mécanisme que le point 1 de ce ticket (déjà corrigé, `08c30693a`),
286
+ une famille plus loin. `aria-rowindex`, seul attribut voulu de ce canal,
287
+ continue de passer.
288
+
289
+ ### Added
290
+
291
+ - **`mobileBreakpoint` a désormais un contrôle de story sur
292
+ `<OrigamDataTable>`** (#371, point 5). La prop — dont le défaut `'xs'`
293
+ corrige un bug de production documenté en commentaire
294
+ (`OrigamDataTable.vue:190-199` : sans lui, `useDisplay` retombait sur le
295
+ seuil global `'lg'` et forçait le rendu mobile empilé sur tout viewport
296
+ sous 1280px) — n'apparaissait dans aucune des 30 Variants de
297
+ `OrigamDataTable.story.vue`. Ajout de la Variant « Prop —
298
+ mobileBreakpoint » (même jeu d'options que celui déjà utilisé par
299
+ `OrigamDataTableRow.story.vue`) et d'une section « Responsive » +
300
+ ligne de table dans `OrigamDataTable.md`.
301
+
223
302
  ---
224
303
 
304
+ #### `v-contrast` était silencieusement inerte sur fond opaque (#869)
305
+
306
+ La directive `v-contrast`, câblée sur 30 composants, n'émettait **jamais**
307
+ rien — ni classe, ni `color: !important`, ni `console.warn` — dès que fond
308
+ **et** texte étaient tous deux OPAQUES, c'est-à-dire la configuration par
309
+ défaut du DS. Deux ruptures indépendantes dans le code partagé, corrigeant
310
+ l'une ne suffisait pas sans l'autre :
311
+
312
+ 1. **`toRgb()`** faisait **toujours** passer la couleur par un aller-retour
313
+ `canvas.fillStyle`, même quand `getComputedStyle` avait déjà rendu une
314
+ chaîne `rgb()/rgba()` exploitable. Le getter `fillStyle` de Chromium
315
+ sérialise toute couleur OPAQUE en hexadécimal `#rrggbb` (seul l'alpha < 1
316
+ ressort en `rgba(…)`), ce qui transformait une valeur déjà utilisable en
317
+ une valeur morte pour `rgbaParts`/`channelsOf`.
318
+ 2. **`channelsOf()`** extrayait les canaux via `match(/[\d.]+/g)`, taillé
319
+ pour `rgb(r, g, b)` — une suite de chiffres hexadécimaux SANS séparateur
320
+ (ex. `#777777`, six chiffres) s'agrège en UN seul grand nombre au lieu de
321
+ trois, donc `channelsOf` rend `null` sur exactement la forme hex que la
322
+ rupture 1 pouvait encore produire (couleur nommée, `hsl()`, …
323
+ nécessitant légitimement le détour par le canvas).
324
+
325
+ **Correctif** : `toRgb()` court-circuite désormais le canvas quand l'entrée
326
+ est déjà `rgb()/rgba()` (le cas de la quasi-totalité des appels, puisqu'ils
327
+ lisent `getComputedStyle`), et le chemin canvas restant convertit son
328
+ résultat hexadécimal en `rgb()/rgba()` via un nouveau `hexToRgb()` avant de
329
+ le rendre — `toRgb()` ne rend plus jamais de hex. La mécanique de correction
330
+ elle-même (forcer noir/blanc, poser `data-origam-contrast-fixed`, journaliser
331
+ le ratio) n'est pas modifiée : elle fonctionnait déjà, elle n'était
332
+ simplement jamais atteinte.
333
+
334
+ **Preuve** — `packages/tests/e2e/contrast-directive.spec.ts` (Playwright,
335
+ Chromium réel — jsdom n'a pas de `canvas.getContext('2d')` fonctionnel dans
336
+ ce dépôt, donc ne peut ni reproduire ni vérifier ce bug) : contrôle positif
337
+ (`#777777` sur blanc, 4.48:1, opaque des deux côtés) déclenche désormais la
338
+ correction + le `console.warn` ; contrôle négatif (noir sur blanc, 21:1) ne
339
+ déclenche rien ; non-régression du chemin translucide (fond composé via
340
+ `resolvePaintedBackground`) inchangée. **A/B contre le commit parent** : 4
341
+ des 5 tests rougissent sur le code d'avant (le contrôle négatif reste vert
342
+ des deux côtés, comme attendu).
343
+
344
+ ⚠️ **Rayon mesuré séparément, pas hérité de #871 — et le chiffre publié par
345
+ #871 contenait un artefact, corrigé depuis par #883.** Le chiffrage initial
346
+ du ticket (346 violations en mode sombre) datait d'avant #876. #876 avait
347
+ d'abord publié **189 → 11** ; en rejouant `audit/dark-contrast.audit.mjs`
348
+ avec la VRAIE directive (redirect du stub désactivé) au lieu de sa
349
+ reformulation mathématique, seuls **7 `console.warn` réels** se
350
+ déclenchaient sur les 32 configurations (30 composants × 8 identités × 2
351
+ modes, portées racine + sous-arbre) — écart signalé à l'auteur de #876, qui
352
+ l'a reproduit, creusé et corrigé sous #883 : **les bornes AVANT et APRÈS
353
+ étaient toutes les deux fausses (185 → 7, pas 189 → 11)**, la même erreur de
354
+ mesure existant déjà dans le comptage "avant". Le delta réel (178) n'a
355
+ jamais changé.
356
+
357
+ L'écart venait d'`origam-tooltip__content` (identité `apple`, root + subtree,
358
+ light + dark) et, découvert par #883, également d'`origam-bottom-nav` sur la
359
+ même identité. Fond réel : `color(srgb 0.898039 0.898039 0.905882 / 0.94)`
360
+ (translucide) ; `toRgb()` le résout correctement en `rgb(215, 215, 217)` une
361
+ fois composé sur son ancêtre opaque, contre du texte noir : **ratio 14.61:1,
362
+ conforme** — vérifié en appelant directement `toRgb()`/
363
+ `resolvePaintedBackground()` de la vraie directive sur cette page. La
364
+ fonction `parse()` de `dark-contrast.audit.mjs` (une réimplémentation
365
+ distincte, pas la directive) ne reconnaissait que `rgba?\(…\)` — elle
366
+ ignorait silencieusement cette couche `color(srgb …)`, sautait jusqu'à
367
+ l'ancêtre opaque suivant et rapportait à tort un faux "noir sur noir"
368
+ (ratio 1.00). **Ce n'était pas un défaut du DS ni de `v-contrast`** — la
369
+ même classe de lacune que ce ticket corrige, mais dans un autre fichier. Les
370
+ 7 restantes (4 `origam-badge__badge`, 3 `origam-breadcrumb-item`) sont
371
+ confirmées identiques entre les deux mesures, et `sous 2:1` passe à **0** une
372
+ fois l'artefact retiré : plus rien de ce qui reste n'est proche d'invisible.
373
+
374
+ Non corrigé ici : `dark-contrast.audit.mjs` appartenait à #871/#876, pas à ce
375
+ ticket — signalé, reproduit et corrigé séparément sous #883 (ne pas citer
376
+ 189 → 11, c'était faux des deux côtés).
377
+
378
+ **Rupture d'API** : aucune — `toRgb`/`channelsOf`/`hexToRgb` restent des
379
+ fonctions privées du module, non exportées ; la surface publique
380
+ (`setContrastConfig`, `v-contrast` par défaut) est inchangée.
381
+
225
382
  ## [2.18.0] - 2026-09-19
226
383
 
227
384
  80 commits depuis `v2.17.1`. L'essentiel est du **correctif** : des tokens qui
@@ -136,7 +136,6 @@
136
136
  import OrigamDataTableGroupHeaderRow from "./OrigamDataTableGroupHeaderRow.vue";
137
137
  import OrigamDataTableRow from "./OrigamDataTableRow.vue";
138
138
  import OrigamSkeleton from "../Skeleton/OrigamSkeleton.vue";
139
- import { useDisplay } from "../../composables/Commons/display.composable";
140
139
  import { useExpanded } from "../../composables/DataTable/expand.composable";
141
140
  import { useGroupBy } from "../../composables/DataTable/group.composable";
142
141
  import { useHeaders } from "../../composables/DataTable/headers.composable";
@@ -186,7 +185,6 @@ const { columns } = useHeaders();
186
185
  const { expandOnClick, toggleExpand, isExpanded } = useExpanded();
187
186
  const { isSelected, toggleSelect } = useSelection();
188
187
  const { toggleGroup, isGroupOpen } = useGroupBy();
189
- const { mobile } = useDisplay(props);
190
188
  const { startIndex } = usePagination();
191
189
  const slotProps = (item, index) => {
192
190
  return {
@@ -235,20 +233,32 @@ const itemSlotProps = (item, index) => {
235
233
  onClick: expandOnClick.value ? () => {
236
234
  toggleExpand(item);
237
235
  } : void 0,
238
- index,
239
236
  item,
240
237
  cellProps: props.cellProps,
241
- // Forward `mobileBreakpoint` so each row's own
242
- // `useDisplay(props)` resolves to the SAME
243
- // threshold the table-level resolved. Pre-fix
244
- // only `mobile: mobile.value` was passed but the
245
- // row's interface declares `mobileBreakpoint`,
246
- // not `mobile` — so the row fell back to the
247
- // global `'lg'` (1280px), forcing mobile mode
248
- // on every viewport <1280px regardless of what
249
- // the consumer set on the DataTable.
238
+ /*********************************************************
239
+ * Forward `mobileBreakpoint` — never `mobile` (#371)
240
+ *
241
+ * @description
242
+ * `IDataTableRowProps` (extends `IDisplayProps`)
243
+ * declares `mobileBreakpoint`, not `index` or
244
+ * `mobile`. Each row resolves its OWN `mobile` from
245
+ * `mobileBreakpoint` via its own `useDisplay(props)`
246
+ * call — passing a precomputed `mobile` here was
247
+ * always dead weight, and passing `index` was never
248
+ * read by the row at all. Both fell through
249
+ * `<OrigamDataTableRow>`'s undeclared-key path
250
+ * straight into `$attrs`, which its
251
+ * `v-bind="$attrs"` root then serialised as literal
252
+ * DOM attributes on every rendered `<tr>`:
253
+ * `index="0" mobile="false"`.
254
+ *
255
+ * @description
256
+ * A prior fix (mobileBreakpoint forwarding) added
257
+ * the correct key without removing the stale one it
258
+ * replaced — same shape as the point-1 closure leak
259
+ * in this same file's `groupHeaderRowProps()`.
260
+ ********************************************************/
250
261
  mobileBreakpoint: props.mobileBreakpoint,
251
- mobile: mobile.value,
252
262
  "aria-rowindex": startIndex.value + index + 2
253
263
  },
254
264
  getPrefixedEventHandlers(attrs, ":row", () => slotPropsLocal),
@@ -34,10 +34,24 @@ function srgbToRgb(color) {
34
34
  const a = m[4] !== void 0 ? parseFloat(m[4]) : 1;
35
35
  return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
36
36
  }
37
+ function hexToRgb(hex) {
38
+ const m = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
39
+ if (!m) return null;
40
+ const h = m[1].length <= 4 ? [...m[1]].map(c => c + c).join("") : m[1];
41
+ const r = parseInt(h.slice(0, 2), 16);
42
+ const g = parseInt(h.slice(2, 4), 16);
43
+ const b = parseInt(h.slice(4, 6), 16);
44
+ if (h.length === 8) {
45
+ const a = parseInt(h.slice(6, 8), 16) / 255;
46
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
47
+ }
48
+ return `rgb(${r}, ${g}, ${b})`;
49
+ }
37
50
  function toRgb(color) {
38
51
  if (!color) return null;
39
52
  const direct = srgbToRgb(color);
40
53
  if (direct) return direct;
54
+ if (/^rgba?\(/i.test(color)) return color;
41
55
  if (typeof document === "undefined") return color;
42
56
  if (normCtx === void 0) normCtx = document.createElement("canvas").getContext("2d");
43
57
  if (!normCtx) return color;
@@ -48,7 +62,7 @@ function toRgb(color) {
48
62
  normCtx.fillStyle = color;
49
63
  const onWhite = normCtx.fillStyle;
50
64
  if (onBlack !== onWhite) return null;
51
- return srgbToRgb(onBlack) ?? onBlack;
65
+ return srgbToRgb(onBlack) ?? hexToRgb(onBlack) ?? onBlack;
52
66
  }
53
67
  function rgbaParts(rgb) {
54
68
  if (!rgb) return null;
@@ -22,10 +22,24 @@ function srgbToRgb(color) {
22
22
  const a = m[4] !== void 0 ? parseFloat(m[4]) : 1;
23
23
  return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
24
24
  }
25
+ function hexToRgb(hex) {
26
+ const m = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
27
+ if (!m) return null;
28
+ const h = m[1].length <= 4 ? [...m[1]].map((c) => c + c).join("") : m[1];
29
+ const r = parseInt(h.slice(0, 2), 16);
30
+ const g = parseInt(h.slice(2, 4), 16);
31
+ const b = parseInt(h.slice(4, 6), 16);
32
+ if (h.length === 8) {
33
+ const a = parseInt(h.slice(6, 8), 16) / 255;
34
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
35
+ }
36
+ return `rgb(${r}, ${g}, ${b})`;
37
+ }
25
38
  function toRgb(color) {
26
39
  if (!color) return null;
27
40
  const direct = srgbToRgb(color);
28
41
  if (direct) return direct;
42
+ if (/^rgba?\(/i.test(color)) return color;
29
43
  if (typeof document === "undefined") return color;
30
44
  if (normCtx === void 0) normCtx = document.createElement("canvas").getContext("2d");
31
45
  if (!normCtx) return color;
@@ -36,7 +50,7 @@ function toRgb(color) {
36
50
  normCtx.fillStyle = color;
37
51
  const onWhite = normCtx.fillStyle;
38
52
  if (onBlack !== onWhite) return null;
39
- return srgbToRgb(onBlack) ?? onBlack;
53
+ return srgbToRgb(onBlack) ?? hexToRgb(onBlack) ?? onBlack;
40
54
  }
41
55
  function rgbaParts(rgb) {
42
56
  if (!rgb) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "origam",
3
- "version": "2.18.1",
3
+ "version": "2.18.3",
4
4
  "type": "module",
5
5
  "description": "origam — a Vue 3 design system with multi-theme design tokens, CSS-first components and TypeScript types.",
6
6
  "keywords": [