@lovett/ui 0.0.11 → 0.2.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.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
package/dist/tokens.css CHANGED
@@ -33,6 +33,14 @@
33
33
  * --destructive(-bg, -foreground)
34
34
  * • Folder: --folder-red/amber/emerald/teal/blue/violet/pink/
35
35
  * slate/ink (categorical palette for folder accents)
36
+ * • Series: --series-1…8 (categorical CHART palette; ADR-146 D2.
37
+ * theme-INDEPENDENT, and the SLOT ORDER IS LOAD-BEARING
38
+ * — see the block near the end of this file before
39
+ * "tidying" it)
40
+ * • Chart: --chart-grid, --chart-axis, --chart-band (chart
41
+ * structure — gridlines, the darker baseline rule, and
42
+ * the hover band; ADR-146 D3. Theme-tuned, and all
43
+ * three ALREADY CARRY ALPHA — never add a second one)
36
44
  * • Spacing: --space-1…12 (4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48)
37
45
  * • Radius: --radius-xs/sm/md/lg/xl/2xl/full, --radius (legacy)
38
46
  * • Motion: --ease-out, --ease-spring, --ease-in-out,
@@ -188,6 +196,34 @@
188
196
 
189
197
  --border: 255 255 255 / 0.08;
190
198
  --border-strong: 255 255 255 / 0.14;
199
+
200
+ /* ============================================================
201
+ CHART STRUCTURE — PUBLIC, theme-tuned (ADR-146 D3).
202
+ The greys a chart needs that are NOT a card border, plus the
203
+ hover-band fill. This is the "structure is required, not banned"
204
+ layer of the data-display recipe (CLAUDE.md §2): gridlines and a
205
+ baseline are what make a plot legible, and deleting them is the
206
+ flat/floating defect, not a cleanup.
207
+
208
+ --chart-axis IS DELIBERATELY DARKER THAN --chart-grid. The
209
+ baseline rule must outrank the gridlines. Every chart in the
210
+ Metoric kit draws it that way (#111111 baseline vs #dfe1e6 grid)
211
+ and it is the single detail that does most for chart legibility.
212
+ It lives as its own token precisely so it cannot drift back to
213
+ "same grey as the grid".
214
+
215
+ ALL THREE ALREADY CARRY AN ALPHA — they composite onto whichever
216
+ panel they sit on, so the panel stays opaque and the structure
217
+ stays translucent. Consume as `rgb(var(--chart-grid))` and NEVER
218
+ as `rgb(var(--chart-grid) / 0.5)`: a second alpha expands to
219
+ `rgb(255 255 255 / 0.07 / 0.5)`, which is invalid CSS and is
220
+ SILENTLY DISCARDED. `__tests__/token-shape.test.ts` fails on
221
+ exactly that shape.
222
+ ============================================================ */
223
+ --chart-grid: 255 255 255 / 0.07;
224
+ --chart-axis: 255 255 255 / 0.22;
225
+ --chart-band: 255 255 255 / 0.05;
226
+
191
227
  /* Semantic — PUBLIC */
192
228
  --success: 16 185 129;
193
229
  --success-bg: 16 185 129 / 0.12;
@@ -527,6 +563,14 @@
527
563
 
528
564
  --border: 0 0 0 / 0.12;
529
565
  --border-strong: 0 0 0 / 0.20;
566
+
567
+ /* Chart structure — see the :root block for the reasoning. The same
568
+ two invariants hold in light: --chart-axis stays darker than
569
+ --chart-grid, and all three already carry their alpha. */
570
+ --chart-grid: 0 0 0 / 0.08;
571
+ --chart-axis: 0 0 0 / 0.24;
572
+ --chart-band: 0 0 0 / 0.04;
573
+
530
574
  /* Bumped from 0.16 → 0.22 — inputs on a white --bg-input (above) sitting
531
575
  * inside a Card (--bg-card = 255 / same as input) need a stronger border
532
576
  * to delineate. The 0.16 value worked when --bg-input was slate-tinted; on
@@ -813,3 +857,71 @@
813
857
  --media-on-scrim: 255 255 255 / 0.88;
814
858
  --media-overlay-btn: 255 255 255 / 0.14;
815
859
  }
860
+
861
+ /* ============================================================
862
+ CATEGORICAL SERIES PALETTE (theme-INDEPENDENT) — PUBLIC
863
+ ------------------------------------------------------------
864
+ The chart series palette (ADR-146 D2). Hues carried over from
865
+ `theme-v2.css`, which designed them at constant lightness ~0.62
866
+ and then never shipped a consumer.
867
+
868
+ THEME-INDEPENDENT ON PURPOSE, and verified rather than assumed:
869
+ lightness 0.62 sits inside BOTH the light band (0.43-0.77) and the
870
+ dark band (0.48-0.67), and all eight clear 3:1 against #ffffff,
871
+ against --surface-frame (#f7f7f9) and against dark --surface-card
872
+ (#1b1b1e). Same precedent as --hierarchy-* and --platform-* above:
873
+ one block, no light/dark override.
874
+
875
+ BARE TRIPLES. The call site owns the alpha — a line is
876
+ `rgb(var(--series-1))` and the area under it is
877
+ `rgb(var(--series-1) / 0.12)`. A baked-in alpha would expand to
878
+ `rgb(2 137 234 / 0.3 / 0.12)`, which is invalid and silently
879
+ dropped, and it would make the token un-shareable between the two
880
+ uses.
881
+
882
+ ------------------------------------------------------------
883
+ THE SLOT ORDER IS LOAD-BEARING. DO NOT "TIDY" IT.
884
+ ------------------------------------------------------------
885
+ The order was chosen by measurement (the dataviz skill's
886
+ validate_palette.js, adjacent-pair list, both modes), not by eye.
887
+ The order declared in theme-v2.css FAILS — teal against pink is
888
+ ΔE 1.2 for a deuteranope. This order passes all five checks in
889
+ both themes: worst adjacent pair ΔE 13.2 deutan / 21.0 normal,
890
+ identical light and dark. Re-ordering the slots breaks that, and
891
+ nothing in the build will tell you.
892
+
893
+ Three placements are deliberate:
894
+ • BLUE IS SLOT 1, not red. Slot 1 is the single-series default,
895
+ so a red slot 1 would paint every one-series chart in the
896
+ brand accent and destroy accent scarcity. Blue carries no
897
+ product meaning.
898
+ • RED IS SLOT 8. Red is reserved for --accent and
899
+ --destructive. It only enters a chart at eight series, where
900
+ the reader has bigger problems. It stays in the palette so
901
+ the eight-slot contract holds rather than being deleted.
902
+ • GREEN (slot 2) AND RED (slot 8) ARE FAR APART, so a
903
+ two-series chart never accidentally reads as pass/fail.
904
+
905
+ THE SCATTER / ALL-PAIRS CAP. Forms where any two marks can touch
906
+ — scatter, bubble, choropleth, small multiples — take the harder
907
+ all-pairs test, which no ordering of these eight can pass. Those
908
+ forms use ONLY slots 1, 6 and 7 (blue / amber / teal: verified
909
+ all-pairs in both modes, worst ΔE 14.8, all five checks PASS) and
910
+ CAP AT THREE SERIES. A fourth folds into "Other" or facets into
911
+ small multiples. Never generate a ninth hue.
912
+
913
+ Resolution is a frozen literal lookup in the primitive, never a
914
+ constructed name (ADR-146 D8): `--series-${i}` that misses yields
915
+ an undefined property, which makes rgb() invalid, which the
916
+ browser discards in silence with every gate still green.
917
+ ============================================================ */
918
+ :root {
919
+ --series-1: 2 137 234; /* blue */
920
+ --series-2: 9 160 93; /* green */
921
+ --series-3: 153 100 229; /* violet */
922
+ --series-4: 113 149 3; /* olive */
923
+ --series-5: 212 73 150; /* pink */
924
+ --series-6: 186 116 4; /* amber */
925
+ --series-7: 14 154 148; /* teal */
926
+ --series-8: 226 74 62; /* red */
927
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovett/ui",
3
- "version": "0.0.11",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
@@ -55,6 +55,13 @@
55
55
  "@dnd-kit/core": "^6.3.1",
56
56
  "@dnd-kit/sortable": "^10.0.0",
57
57
  "@dnd-kit/utilities": "^3.2.2",
58
+ "@tiptap/core": "3.31.3",
59
+ "@tiptap/extension-mention": "3.31.3",
60
+ "@tiptap/extension-placeholder": "3.31.3",
61
+ "@tiptap/markdown": "3.31.3",
62
+ "@tiptap/pm": "3.31.3",
63
+ "@tiptap/react": "3.31.3",
64
+ "@tiptap/starter-kit": "3.31.3",
58
65
  "clsx": "^2.1.1",
59
66
  "mammoth": "^1.12.0",
60
67
  "react-markdown": "^10.1.0",
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Avatar / AvatarGroup — ADR-147 D14.
3
+ *
4
+ * The contract worth pinning is not "it renders a circle". It is the four
5
+ * things the consolidated forks got wrong or could not do, plus the two D5
6
+ * degradations that a comment thread will hit in production:
7
+ *
8
+ * • one `initials()`, capped at two words, first + last
9
+ * • one tint derivation, stable per identity, `red` never in the set
10
+ * • the monogram stays in the DOM UNDER the photo, so a broken `src`
11
+ * degrades to a letter rather than a coloured hole
12
+ * • `max` and `total` are separate, so 3 of 40 renders from 3 children
13
+ * • a NULL name does not crash and is not fabricated
14
+ *
15
+ * `css: false` in the vitest config, so nothing here asserts a painted value —
16
+ * these are class and DOM contracts. Colour is verified by the measured note
17
+ * in `styles.css` and an eyeball pass in both themes.
18
+ */
19
+ import { describe, expect, it } from 'vitest'
20
+ import { fireEvent, render, screen } from '@testing-library/react'
21
+ import {
22
+ AVATAR_TINTS,
23
+ Avatar,
24
+ AvatarGroup,
25
+ asAvatarTint,
26
+ avatarTintForKey,
27
+ initials,
28
+ } from '../avatar'
29
+
30
+ describe('initials()', () => {
31
+ it('takes the first and last word, capped at two', () => {
32
+ expect(initials('Marcus Webb')).toBe('MW')
33
+ // The divergence being resolved: community and admin returned 'MJ' here.
34
+ expect(initials('Mary Jane Watson')).toBe('MW')
35
+ })
36
+
37
+ it('takes one letter from a single-word name', () => {
38
+ expect(initials('Cher')).toBe('C')
39
+ })
40
+
41
+ it('uppercases and tolerates ragged whitespace', () => {
42
+ expect(initials(' ada lovelace ')).toBe('AL')
43
+ })
44
+
45
+ it('does not slice a surrogate pair in half', () => {
46
+ // A name whose first character is outside the BMP. `name[0]` returns half a
47
+ // code point and renders as a replacement glyph.
48
+ expect(initials('\u{1D400}lpha Beta')).toBe('\u{1D400}B')
49
+ })
50
+
51
+ it('returns ? for a null, undefined, empty or blank name and never invents one', () => {
52
+ // D5: users.name / given_name / family_name are ALL nullable by policy.
53
+ expect(initials(null)).toBe('?')
54
+ expect(initials(undefined)).toBe('?')
55
+ expect(initials('')).toBe('?')
56
+ expect(initials(' ')).toBe('?')
57
+ })
58
+ })
59
+
60
+ describe('avatarTintForKey()', () => {
61
+ it('is stable for the same seed', () => {
62
+ expect(avatarTintForKey('usr_9f2')).toBe(avatarTintForKey('usr_9f2'))
63
+ })
64
+
65
+ it('never returns red — that is the accent, and an avatar may not spend it', () => {
66
+ expect(AVATAR_TINTS).not.toContain('red')
67
+ const seeds = Array.from({ length: 400 }, (_, i) => `usr_${i}`)
68
+ for (const seed of seeds) {
69
+ expect(AVATAR_TINTS).toContain(avatarTintForKey(seed))
70
+ }
71
+ })
72
+
73
+ it('hashes the whole string, not the first character', () => {
74
+ // The community/admin fork keyed on `name.charCodeAt(0) % 4`, so every
75
+ // name starting with the same letter collided by construction.
76
+ expect(avatarTintForKey('Alice Adams')).not.toBe(avatarTintForKey('Aaron Ash'))
77
+ })
78
+
79
+ it('is not degenerate — it reaches most of the palette over a small sample', () => {
80
+ const seen = new Set(
81
+ Array.from({ length: 200 }, (_, i) => avatarTintForKey(`usr_${i}`)),
82
+ )
83
+ expect(seen.size).toBeGreaterThanOrEqual(AVATAR_TINTS.length - 1)
84
+ })
85
+ })
86
+
87
+ describe('asAvatarTint()', () => {
88
+ it('passes a known key through', () => {
89
+ expect(asAvatarTint('violet')).toBe('violet')
90
+ })
91
+
92
+ it('rejects anything that is not a key, so an untrusted string cannot reach the DOM', () => {
93
+ // `users.avatar_color` is a nullable TEXT column, so its contents are a
94
+ // string from the database, not a domain value.
95
+ expect(asAvatarTint(null)).toBeUndefined()
96
+ expect(asAvatarTint(undefined)).toBeUndefined()
97
+ expect(asAvatarTint('')).toBeUndefined()
98
+ expect(asAvatarTint('rebeccapurple')).toBeUndefined()
99
+ expect(asAvatarTint('var(--accent)')).toBeUndefined()
100
+ })
101
+
102
+ it('rejects red — it is not in the set, so it cannot be stored into one', () => {
103
+ expect(asAvatarTint('red')).toBeUndefined()
104
+ })
105
+ })
106
+
107
+ describe('Avatar', () => {
108
+ it('names itself for a screen reader and shows the monogram', () => {
109
+ render(<Avatar name="Marcus Webb" />)
110
+ const el = screen.getByRole('img', { name: 'Marcus Webb' })
111
+ expect(el).toHaveTextContent('MW')
112
+ })
113
+
114
+ it('takes a tint prop over the hash, and carries it as a class not a style', () => {
115
+ // Identity colour is DATA (D6). A key, so the token boundary holds — an
116
+ // arbitrary colour string out of the database would not.
117
+ const { container } = render(<Avatar name="Marcus Webb" tint="violet" />)
118
+ expect(container.firstChild).toHaveClass('ds-avatar', 'ds-avatar--violet')
119
+ })
120
+
121
+ it('falls back to the hash when no tint is given, seeded by id in preference to name', () => {
122
+ const { container: byName } = render(<Avatar name="Marcus Webb" />)
123
+ const { container: bySeed } = render(<Avatar name="Marcus Webb" seed="usr_9f2" />)
124
+ expect(byName.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('Marcus Webb')}`)
125
+ expect(bySeed.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('usr_9f2')}`)
126
+ })
127
+
128
+ it('gives the monogram its OWN element, not a bare text node', () => {
129
+ // Measured in @lovett/ds: as a bare text node it is an anonymous grid item,
130
+ // `text-box-trim` reaches nothing, and the glyph sat 1.3px high in a 24px
131
+ // avatar. Deleting the span is a silent 1.3px regression, so it is pinned.
132
+ const { container } = render(<Avatar name="Marcus Webb" />)
133
+ const mono = container.querySelector('.ds-avatar__mono')
134
+ expect(mono).not.toBeNull()
135
+ expect(mono).toHaveTextContent('MW')
136
+ })
137
+
138
+ it('renders the photo OVER the monogram, keeping the monogram in the DOM', () => {
139
+ const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/m.png" />)
140
+ expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
141
+ expect(container.querySelector('.ds-avatar__mono')).toHaveTextContent('MW')
142
+ })
143
+
144
+ it('degrades a broken src to the letter rather than a coloured hole', () => {
145
+ const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/404.png" />)
146
+ const img = container.querySelector('.ds-avatar__img')
147
+ expect(img).not.toBeNull()
148
+ if (img) fireEvent.error(img)
149
+ expect(container.querySelector('.ds-avatar__img')).toBeNull()
150
+ expect(screen.getByRole('img', { name: 'Marcus Webb' })).toHaveTextContent('MW')
151
+ })
152
+
153
+ it('re-tries a NEW src after an earlier one failed', () => {
154
+ // A latching boolean would suppress every later photo for the life of the
155
+ // component once any URL 404s. Keyed by the failed src instead.
156
+ const { container, rerender } = render(<Avatar name="M W" src="https://x.test/404.png" />)
157
+ const img = container.querySelector('.ds-avatar__img')
158
+ if (img) fireEvent.error(img)
159
+ expect(container.querySelector('.ds-avatar__img')).toBeNull()
160
+ rerender(<Avatar name="M W" src="https://x.test/ok.png" />)
161
+ expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
162
+ })
163
+
164
+ it('accepts an initial override', () => {
165
+ render(<Avatar name="Edwin Lovett" initial="You" />)
166
+ expect(screen.getByRole('img', { name: 'Edwin Lovett' })).toHaveTextContent('You')
167
+ })
168
+
169
+ describe('D5 — degraded identities', () => {
170
+ it('does not crash on a null name, and does not fabricate one', () => {
171
+ render(<Avatar name={null} />)
172
+ const el = screen.getByRole('img', { name: 'Unknown person' })
173
+ expect(el).toHaveTextContent('?')
174
+ })
175
+
176
+ it('treats a blank name the same as a null one', () => {
177
+ render(<Avatar name=" " />)
178
+ expect(screen.getByRole('img', { name: 'Unknown person' })).toHaveTextContent('?')
179
+ })
180
+
181
+ it('gives a null-named person a stable tint rather than throwing', () => {
182
+ const { container } = render(<Avatar name={null} />)
183
+ expect(container.firstChild).toHaveClass(
184
+ `ds-avatar--${avatarTintForKey('Unknown person')}`,
185
+ )
186
+ })
187
+ })
188
+
189
+ describe('sizing', () => {
190
+ it('is sm by default and carries no size modifier for it', () => {
191
+ const { container } = render(<Avatar name="Marcus Webb" />)
192
+ expect(container.firstChild).not.toHaveClass('ds-avatar--md', 'ds-avatar--lg')
193
+ })
194
+
195
+ it('carries a modifier class for a named size', () => {
196
+ const { container } = render(<Avatar name="Marcus Webb" size="lg" />)
197
+ expect(container.firstChild).toHaveClass('ds-avatar--lg')
198
+ })
199
+
200
+ it('carries a pixel size as custom properties, never as a hue or size class', () => {
201
+ const { container } = render(<Avatar name="Marcus Webb" size={26} />)
202
+ const el = container.firstChild
203
+ expect(el).toHaveStyle({ '--avatar-size': '26px' })
204
+ // 0.36 coefficient — the community fork's, so its call sites keep their
205
+ // rendered type size through the consolidation.
206
+ expect(el).toHaveStyle({ '--avatar-font': '9px' })
207
+ expect(el).not.toHaveClass('ds-avatar--md')
208
+ })
209
+ })
210
+
211
+ describe('accessibility', () => {
212
+ it('takes the ring class only when asked', () => {
213
+ const { container: plain } = render(<Avatar name="A B" />)
214
+ const { container: ringed } = render(<Avatar name="A B" ring />)
215
+ expect(plain.firstChild).not.toHaveClass('ds-avatar--ring')
216
+ expect(ringed.firstChild).toHaveClass('ds-avatar--ring')
217
+ })
218
+
219
+ it('hides itself from the a11y tree when decorative', () => {
220
+ // Every table row and byline renders the name beside the avatar;
221
+ // announcing the same person twice is worse than not announcing them.
222
+ const { container } = render(<Avatar name="Marcus Webb" decorative />)
223
+ expect(container.firstChild).toHaveAttribute('aria-hidden', 'true')
224
+ expect(screen.queryByRole('img')).toBeNull()
225
+ })
226
+ })
227
+ })
228
+
229
+ describe('AvatarGroup', () => {
230
+ const face = (n: string) => <Avatar key={n} name={n} />
231
+
232
+ it('shows every face when there are no more than max', () => {
233
+ render(<AvatarGroup max={3}>{['A One', 'B Two'].map(face)}</AvatarGroup>)
234
+ expect(screen.getAllByRole('img')).toHaveLength(2)
235
+ expect(screen.queryByText(/^\+/)).toBeNull()
236
+ })
237
+
238
+ it('collapses the tail into a +N', () => {
239
+ render(<AvatarGroup max={2}>{['A One', 'B Two', 'C Three'].map(face)}</AvatarGroup>)
240
+ expect(screen.getByRole('img', { name: '1 more' })).toHaveTextContent('+1')
241
+ })
242
+
243
+ it('counts from total, so 3 faces render out of 40 without 40 children', () => {
244
+ // The capability no fork had. `total` is separate from the children on
245
+ // purpose: a thread with 40 participants must not mount 40 avatars to
246
+ // throw 37 away.
247
+ render(
248
+ <AvatarGroup max={3} total={40}>
249
+ {['A One', 'B Two', 'C Three'].map(face)}
250
+ </AvatarGroup>,
251
+ )
252
+ expect(screen.getByRole('img', { name: '37 more' })).toHaveTextContent('+37')
253
+ })
254
+
255
+ it('overlaps by default and lays out spaced when asked', () => {
256
+ const { container: over } = render(<AvatarGroup>{[face('A One')]}</AvatarGroup>)
257
+ const { container: flat } = render(
258
+ <AvatarGroup overlap={false}>{[face('A One')]}</AvatarGroup>,
259
+ )
260
+ expect(over.firstChild).toHaveClass('ds-avatar-group--overlap')
261
+ expect(flat.firstChild).toHaveClass('ds-avatar-group--spaced')
262
+ })
263
+
264
+ it('sizes the +N chip with the faces', () => {
265
+ const { container } = render(
266
+ <AvatarGroup max={1} total={9} size="md">
267
+ {[face('A One')]}
268
+ </AvatarGroup>,
269
+ )
270
+ expect(container.querySelector('.ds-avatar-group__more')).toHaveClass('ds-avatar--md')
271
+ })
272
+ })