@lovett/ui 0.2.0 → 0.2.4

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/dist/styles.css CHANGED
@@ -956,7 +956,13 @@
956
956
  display: flex;
957
957
  flex-direction: column;
958
958
  gap: var(--space-2);
959
- min-block-size: 96px;
959
+ /* 96px is the OUTER floor, and `min-block-size` is a border-box one — so it
960
+ carries the block-start compensation below, which lives outside the border
961
+ box. Without the `+ --space-1` an empty lane in a content-sized board
962
+ (the only layout where this floor binds at all; a stretched board takes
963
+ its height from the grid row) drew its frame 4px shorter than before.
964
+ Measured: column 154px -> 150px, with the empty message itself unmoved. */
965
+ min-block-size: calc(96px + var(--space-1));
960
966
  /* Overridable by the consuming surface. Capping it is what produces the
961
967
  next-card peek instead of an infinitely tall column. Declared here rather
962
968
  than spelled as a `var(--x, fallback)` so the token-shape guard can still
@@ -968,6 +974,52 @@
968
974
  max-block-size: var(--board-column-max-block-size);
969
975
  flex: 1 1 auto;
970
976
  overflow-y: auto;
977
+
978
+ /* THE LANE IS A CLIP, AND A CARD PAINTS OUTSIDE ITSELF.
979
+ *
980
+ * `overflow-y: auto` here does not clip one axis. A `visible` companion is
981
+ * coerced to `auto` — the same rule `.ds-board-scroller` above declares
982
+ * `overflow-y: hidden` to dodge — so this box clips INLINE as well. That is
983
+ * why the reserve below is four-sided and not a top inset: with zero padding
984
+ * the clip edge WAS the card's own border box, so every card lost shadow
985
+ * left and right, the top one lost its hover lift into the header gap, and
986
+ * the last one lost its shadow at the scroll end. Owner, in a browser, 2026-
987
+ * 09-09: the lift is sliced. Nothing typecheck, lint, build or the suite
988
+ * reads can see a sliced shadow, and jsdom has no layout to measure one.
989
+ *
990
+ * `scroll-padding-block-end` below is NOT this and never was. It positions
991
+ * where `scrollIntoView` lands. It reserves no paint room whatsoever.
992
+ *
993
+ * The reserve is READ OFF the shadow tokens, not guessed. A shadow paints
994
+ * `|offset| + spread + blur/2` past the border box, so the hover step
995
+ * --shadow-md reaches furthest in the theme where it is a plain
996
+ * `0 4px 12px`: 6px inline, 2px block-start, 10px block-end. The hover
997
+ * `translateY(-1px)` lifts all of that one pixel, taking block-start to 3.
998
+ * Rounded up onto the spacing scale: 8 / 4 / 12. A value off the scale would
999
+ * mean the scale is wrong; these are on it.
1000
+ *
1001
+ * Inline and block-start are pulled straight back by an equal NEGATIVE
1002
+ * margin, so this box grows outward into the column's own --space-2 padding
1003
+ * and not one card moves. That is the entire requirement — paint room
1004
+ * without repositioning the lane's resting composition. The side effect is
1005
+ * an improvement: an overlay vertical scrollbar now floats over the reserve
1006
+ * instead of over the cards.
1007
+ *
1008
+ * Block-end takes no such margin, deliberately, and this is where the
1009
+ * scroller's note at ~:839 is respected rather than overruled. 12px exceeds
1010
+ * the column's 8px of bottom padding, so pulling back would drive this box
1011
+ * through the column's border AND consume the 8px that note keeps clear for
1012
+ * an overlay HORIZONTAL scrollbar to float over — the one thing it asks for.
1013
+ * Uncompensated trailing padding on a scroll container costs nothing at
1014
+ * rest, because `flex: 1 1 auto` already makes this box taller than its
1015
+ * content; it surfaces only at the bottom of a scrolled lane, where it reads
1016
+ * as the end of the list rather than as a gap. */
1017
+ padding-inline: var(--space-2);
1018
+ margin-inline: calc(var(--space-2) * -1);
1019
+ padding-block-start: var(--space-1);
1020
+ margin-block-start: calc(var(--space-1) * -1);
1021
+ padding-block-end: var(--space-3);
1022
+
971
1023
  scroll-padding-block-end: var(--space-6);
972
1024
  }
973
1025
 
package/dist/theme-v2.css CHANGED
@@ -158,6 +158,19 @@
158
158
  against its surface, under the 3:1 WCAG 2.2 SC 2.4.11 asks of a focus
159
159
  indicator. See the note on --ring-focus in tokens.css. */
160
160
  --ring-focus: 0 0 0 2px rgb(var(--accent));
161
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
162
+ ancestor with a non-visible overflow, and when that happens the focus
163
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
164
+ rather than a cosmetic one.
165
+ Use this at a clipping boundary: inside a scroll container, inside the
166
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
167
+ default clip. It cannot be clipped by an ancestor because it paints inside
168
+ the element's own border box.
169
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
170
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
171
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
172
+ one. That override is the cost this token removes. */
173
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
161
174
  --ring-error: 0 0 0 2px rgb(var(--destructive));
162
175
  }
163
176
 
@@ -225,4 +238,17 @@
225
238
  --inset-highlight: 255 255 255 / 0.05;
226
239
  --modal-overlay: 0 0 0 / 0.7;
227
240
  --ring-focus: 0 0 0 2px rgb(var(--accent));
241
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
242
+ ancestor with a non-visible overflow, and when that happens the focus
243
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
244
+ rather than a cosmetic one.
245
+ Use this at a clipping boundary: inside a scroll container, inside the
246
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
247
+ default clip. It cannot be clipped by an ancestor because it paints inside
248
+ the element's own border box.
249
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
250
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
251
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
252
+ one. That override is the cost this token removes. */
253
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
228
254
  }
package/dist/tokens.css CHANGED
@@ -402,6 +402,19 @@
402
402
  Both resolve through the theme-aware tokens, so the light block needs no
403
403
  override — var() resolves at use time. */
404
404
  --ring-focus: 0 0 0 2px rgb(var(--accent));
405
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
406
+ ancestor with a non-visible overflow, and when that happens the focus
407
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
408
+ rather than a cosmetic one.
409
+ Use this at a clipping boundary: inside a scroll container, inside the
410
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
411
+ default clip. It cannot be clipped by an ancestor because it paints inside
412
+ the element's own border box.
413
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
414
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
415
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
416
+ one. That override is the cost this token removes. */
417
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
405
418
  --ring-error: 0 0 0 2px rgb(var(--destructive));
406
419
 
407
420
  --radius-xs: 6px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovett/ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
@@ -19,7 +19,7 @@
19
19
  "./theme-v2.css": "./dist/theme-v2.css"
20
20
  },
21
21
  "publishConfig": {
22
- "access": "restricted"
22
+ "access": "public"
23
23
  },
24
24
  "files": [
25
25
  "dist",
@@ -0,0 +1,431 @@
1
+ // @vitest-environment node
2
+ //
3
+ // Pure source scan — no DOM. jsdom has no layout engine, so it cannot
4
+ // measure a clipped shadow even in principle; see the blind-spot note.
5
+ /**
6
+ * Static guard for the "a box that CLIPS holds a box that paints OUTSIDE
7
+ * ITSELF" defect class.
8
+ *
9
+ * THE DEFECT THIS PINS, MEASURED IN A BROWSER
10
+ * -------------------------------------------
11
+ * `.ds-board-column-body` is the lane's scroll box. It declared
12
+ * `overflow-y: auto` and no padding at all. Two things follow.
13
+ *
14
+ * First, it clipped on BOTH axes, not one: when a box sets a non-`visible`
15
+ * overflow on one axis, a `visible` companion computes to `auto`. So the
16
+ * clip rectangle was the lane's border box in the inline direction too —
17
+ * `.ds-board-scroller` above declares `overflow-y: hidden` for exactly this
18
+ * reason, and the lane body had no such companion.
19
+ *
20
+ * Second, the clip rectangle coincided EXACTLY with each card's own border
21
+ * box, and a card paints well outside that: `--shadow-sm` at rest,
22
+ * `--shadow-md` plus `translateY(-1px)` on hover.
23
+ *
24
+ * Measured in Chromium at the real token values, sampling the red/luma
25
+ * profile outward from the top card's edges while it was hovered:
26
+ *
27
+ * before after
28
+ * dark, inline 34 34 34 34 34 … 29 30 31 32 32 33 33 34
29
+ * dark, above 34 34 34 34 34 … 32 33 34 34 34
30
+ * light, inline 248 248 248 248 … 245 246 247 247 248 248
31
+ *
32
+ * A flat run at the surface's own base luma means the shadow was not there
33
+ * at all. Every card lost its shadow left and right; the hovered top card
34
+ * additionally had its lifted first pixel row cut off, which is the sliced
35
+ * lift the owner reported. In light the casualty was `--shadow-md`'s
36
+ * `0 0 0 1px` contact ring — the card's edge definition on hover.
37
+ *
38
+ * Not one of typecheck, lint, build or the suite can see any of that.
39
+ *
40
+ * WHAT THIS GUARD CHECKS, AND WHAT IT CANNOT
41
+ * ------------------------------------------
42
+ * It checks ONE thing, honestly: for each containment pair declared in
43
+ * CONTAINMENT below, the clipping rule reserves padding on every side at
44
+ * least as large as the outset the contained rule paints on that side —
45
+ * with the outset DERIVED from the shadow tokens rather than written down
46
+ * here. Retune `--shadow-md` and this recomputes; add a theme whose
47
+ * `--shadow-md` reaches further and this recomputes for that theme too.
48
+ *
49
+ * PER SIDE is the whole point. "Has padding" is not the invariant and
50
+ * scores a box padded on one axis as safe — which is precisely the shape
51
+ * of the sibling defect in the reply composer, whose clip wrapper carries
52
+ * `paddingBlockStart` and nothing else.
53
+ *
54
+ * The three things it does NOT do, stated plainly:
55
+ *
56
+ * 1. IT DOES NOT DISCOVER CONTAINMENT. CSS does not encode which element
57
+ * ends up inside which; only the rendered tree knows, and it is
58
+ * assembled from `board.tsx` plus whatever the host passes as
59
+ * `children`. So CONTAINMENT is hand-written, one entry per pair
60
+ * somebody has actually observed. A pair nobody adds is a pair
61
+ * nobody checks. This guard is a regression pin on a known
62
+ * relationship, not a search for unknown ones.
63
+ *
64
+ * 2. IT CANNOT CROSS A COMPONENT BOUNDARY, AND THIS IS MEASURED, NOT
65
+ * SUSPECTED. The reply composer has the identical defect: the clip is
66
+ * `comment.tsx:885`, the ring it eats is drawn in `composer.tsx:192`,
67
+ * and `reactions.tsx:304` is the same shape again with the ring
68
+ * string hidden in a module-level `CHIP_BASE` constant. A scan built
69
+ * against exactly that question in `ignite-workspace` produced 15
70
+ * candidates and missed both real bugs. Textual scanning cannot see a
71
+ * clipping ancestor whose victim is inside a child component, and an
72
+ * AST walker would have to resolve the import graph and then
73
+ * over-approximate "this component draws a ring somewhere" — which
74
+ * flags clips whose ring is nested under a second clip that already
75
+ * handles it. So the JSX half is out of scope here rather than
76
+ * half-covered, and the composer bug is tracked as its own decision.
77
+ *
78
+ * 3. IT USES THE SPEC'S REACH, NOT SKIA'S. A shadow's painted extent is
79
+ * taken as `|offset| + spread + blur/2`, per CSS Backgrounds 3.
80
+ * Chromium's Gaussian tail runs slightly longer than that at high
81
+ * alpha. At the tokens' real alphas the difference vanished — the
82
+ * profiles above resolve to the base surface inside the reserve — but
83
+ * a future token at alpha 1.0 could clip a faint tail this arithmetic
84
+ * calls covered. The formula is the floor, not a proof of invisibility.
85
+ *
86
+ * Mutation check (ADR-146 D8 — "a guard never observed failing is not a
87
+ * guard"): delete any one of the three padding declarations on
88
+ * `.ds-board-column-body` and the first test goes red naming that side and
89
+ * the shortfall in px. Verified for all three.
90
+ */
91
+ import { readFileSync } from 'node:fs'
92
+ import { join } from 'node:path'
93
+ import { fileURLToPath } from 'node:url'
94
+ import { describe, expect, it } from 'vitest'
95
+
96
+ const SRC = fileURLToPath(new URL('..', import.meta.url))
97
+ const read = (f: string) => readFileSync(join(SRC, f), 'utf8')
98
+
99
+ type Side = 'top' | 'right' | 'bottom' | 'left'
100
+ const SIDES: readonly Side[] = ['top', 'right', 'bottom', 'left']
101
+ type Box = Record<Side, number>
102
+ const zero = (): Box => ({ top: 0, right: 0, bottom: 0, left: 0 })
103
+
104
+ /** Split on `sep` at paren depth 0 — `rgba(0, 0, 0, 0.2)` is one token. */
105
+ function splitTop(value: string, sep: string): string[] {
106
+ const out: string[] = []
107
+ let depth = 0
108
+ let cur = ''
109
+ for (const ch of value) {
110
+ if (ch === '(') depth += 1
111
+ else if (ch === ')') depth -= 1
112
+ if (ch === sep && depth === 0) {
113
+ out.push(cur)
114
+ cur = ''
115
+ } else cur += ch
116
+ }
117
+ out.push(cur)
118
+ return out
119
+ }
120
+
121
+ const LENGTH = /^-?\d*\.?\d+(px)?$/
122
+ const px = (t: string) => Number.parseFloat(t)
123
+
124
+ /**
125
+ * Flatten a stylesheet to (selector, declarations), descending through
126
+ * at-rules so a rule inside `@media (prefers-reduced-motion)` is still seen.
127
+ */
128
+ function rules(css: string): { selector: string; decls: Map<string, string> }[] {
129
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
130
+ const out: { selector: string; decls: Map<string, string> }[] = []
131
+ const walk = (text: string) => {
132
+ let i = 0
133
+ while (i < text.length) {
134
+ const open = text.indexOf('{', i)
135
+ if (open === -1) return
136
+ let depth = 1
137
+ let j = open + 1
138
+ while (j < text.length && depth > 0) {
139
+ if (text[j] === '{') depth += 1
140
+ else if (text[j] === '}') depth -= 1
141
+ j += 1
142
+ }
143
+ const head = text.slice(i, open).trim()
144
+ const body = text.slice(open + 1, j - 1)
145
+ if (head.startsWith('@')) walk(body)
146
+ else {
147
+ const decls = new Map<string, string>()
148
+ for (const decl of splitTop(body.replace(/\{[\s\S]*?\}/g, ' '), ';')) {
149
+ const colon = decl.indexOf(':')
150
+ if (colon === -1) continue
151
+ const prop = decl.slice(0, colon).trim()
152
+ if (prop === '' || prop.startsWith('--')) continue
153
+ decls.set(prop, decl.slice(colon + 1).trim())
154
+ }
155
+ for (const sel of splitTop(head, ',')) out.push({ selector: sel.trim(), decls })
156
+ }
157
+ i = j
158
+ }
159
+ }
160
+ walk(stripped)
161
+ return out
162
+ }
163
+
164
+ /** `--space-2` -> 8. Only literal px scale values; anything else throws. */
165
+ function readScale(): Map<string, number> {
166
+ const out = new Map<string, number>()
167
+ for (const [, name, value] of read('tokens.css').matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
168
+ const v = value!.trim()
169
+ if (/^-?\d*\.?\d+px$/.test(v)) out.set(name!, px(v))
170
+ }
171
+ return out
172
+ }
173
+ const SCALE = readScale()
174
+
175
+ /** A length that may be a raw px value or `var(--space-n)`. Throws otherwise. */
176
+ function resolveLength(raw: string): number {
177
+ const v = raw.trim()
178
+ if (LENGTH.test(v)) return px(v)
179
+ const varMatch = /^var\((--[a-z0-9-]+)\)$/.exec(v)
180
+ if (varMatch !== null) {
181
+ const found = SCALE.get(varMatch[1]!)
182
+ if (found === undefined) throw new Error(`${v} does not resolve to a px scale token`)
183
+ return found
184
+ }
185
+ const calcMatch = /^calc\(\s*var\((--[a-z0-9-]+)\)\s*\*\s*(-?[\d.]+)\s*\)$/.exec(v)
186
+ if (calcMatch !== null) {
187
+ const found = SCALE.get(calcMatch[1]!)
188
+ if (found === undefined) throw new Error(`${v} does not resolve to a px scale token`)
189
+ return found * Number.parseFloat(calcMatch[2]!)
190
+ }
191
+ // Deliberately loud. Silently reading an unparsed padding as 0 would make
192
+ // the guard fail safe; silently reading it as "covered" would make it lie.
193
+ throw new Error(`unparsed length: ${raw}`)
194
+ }
195
+
196
+ /**
197
+ * The outset a box-shadow list paints beyond the border box, per side.
198
+ * `inset` layers paint inward and contribute nothing.
199
+ */
200
+ function shadowOutset(list: string): Box {
201
+ const out = zero()
202
+ for (const layer of splitTop(list, ',')) {
203
+ const tokens = layer.trim().split(/\s+/).filter(Boolean)
204
+ if (tokens.length === 0) continue
205
+ if (tokens[0] === 'inset') continue
206
+ const lengths: number[] = []
207
+ for (const t of tokens) {
208
+ if (!LENGTH.test(t)) break
209
+ lengths.push(px(t))
210
+ }
211
+ if (lengths.length < 2) throw new Error(`unparsed shadow layer: ${layer}`)
212
+ const [ox = 0, oy = 0, blur = 0, spread = 0] = lengths
213
+ const reach = blur / 2 + spread
214
+ out.left = Math.max(out.left, -ox + reach)
215
+ out.right = Math.max(out.right, ox + reach)
216
+ out.top = Math.max(out.top, -oy + reach)
217
+ out.bottom = Math.max(out.bottom, oy + reach)
218
+ }
219
+ return out
220
+ }
221
+
222
+ /**
223
+ * Every `--shadow-*` token, reduced to its widest reach per side across
224
+ * EVERY theme block that declares it. A theme is not a special case.
225
+ */
226
+ function readShadowTokens(): Map<string, Box> {
227
+ const out = new Map<string, Box>()
228
+ for (const file of ['tokens.css', 'theme-v2.css']) {
229
+ for (const [, name, value] of read(file).matchAll(/^\s*(--shadow-[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
230
+ const box = shadowOutset(value!)
231
+ const prev = out.get(name!) ?? zero()
232
+ out.set(name!, {
233
+ top: Math.max(prev.top, box.top),
234
+ right: Math.max(prev.right, box.right),
235
+ bottom: Math.max(prev.bottom, box.bottom),
236
+ left: Math.max(prev.left, box.left),
237
+ })
238
+ }
239
+ }
240
+ return out
241
+ }
242
+ const SHADOWS = readShadowTokens()
243
+
244
+ /** `translateY(-1px)` on a hover state moves the whole paint region up. */
245
+ function transformShift(decl: string | undefined): { x: number; y: number } {
246
+ if (decl === undefined || decl.includes('none')) return { x: 0, y: 0 }
247
+ const ty = /translateY\(\s*(-?[\d.]+)px\s*\)/.exec(decl)
248
+ const t = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(decl)
249
+ if (t !== null) return { x: Number.parseFloat(t[1]!), y: Number.parseFloat(t[2]!) }
250
+ if (ty !== null) return { x: 0, y: Number.parseFloat(ty[1]!) }
251
+ return { x: 0, y: 0 }
252
+ }
253
+
254
+ const STYLES = rules(read('styles.css'))
255
+
256
+ /**
257
+ * The furthest any state of `base` paints outside its border box, taking
258
+ * each state's own transform into account before the max — a state that
259
+ * lifts needs more room above and less below, and they are different states.
260
+ */
261
+ function paintReach(base: string, exclude: RegExp): { reach: Box; states: string[] } {
262
+ const own = new RegExp(`^\\${base}(?![\\w-])`)
263
+ const reach = zero()
264
+ const states: string[] = []
265
+ for (const rule of STYLES) {
266
+ if (!own.test(rule.selector) || exclude.test(rule.selector)) continue
267
+ const shadow = rule.decls.get('box-shadow')
268
+ if (shadow === undefined) continue
269
+ const token = /^var\((--shadow-[a-z0-9-]+)\)$/.exec(shadow.trim())
270
+ const box = token === null ? shadowOutset(shadow) : SHADOWS.get(token[1]!)
271
+ if (box === undefined) throw new Error(`${rule.selector}: unknown shadow ${shadow}`)
272
+ const { x, y } = transformShift(rule.decls.get('transform'))
273
+ states.push(rule.selector)
274
+ reach.top = Math.max(reach.top, box.top - y)
275
+ reach.bottom = Math.max(reach.bottom, box.bottom + y)
276
+ reach.left = Math.max(reach.left, box.left - x)
277
+ reach.right = Math.max(reach.right, box.right + x)
278
+ }
279
+ return { reach, states }
280
+ }
281
+
282
+ /** Per-side padding of a rule. Longhands and the shorthands actually used. */
283
+ function paddingOf(selector: string): Box {
284
+ const out = zero()
285
+ const apply = (side: Side, v: string) => {
286
+ out[side] = resolveLength(v)
287
+ }
288
+ for (const rule of STYLES) {
289
+ if (rule.selector !== selector) continue
290
+ for (const [prop, value] of rule.decls) {
291
+ if (!prop.startsWith('padding')) continue
292
+ const parts = splitTop(value, ' ')
293
+ .map((p) => p.trim())
294
+ .filter(Boolean)
295
+ const one = parts[0]!
296
+ switch (prop) {
297
+ case 'padding-block-start':
298
+ case 'padding-top':
299
+ apply('top', one)
300
+ break
301
+ case 'padding-block-end':
302
+ case 'padding-bottom':
303
+ apply('bottom', one)
304
+ break
305
+ case 'padding-inline-start':
306
+ case 'padding-left':
307
+ apply('left', one)
308
+ break
309
+ case 'padding-inline-end':
310
+ case 'padding-right':
311
+ apply('right', one)
312
+ break
313
+ case 'padding-inline':
314
+ apply('left', one)
315
+ apply('right', parts[1] ?? one)
316
+ break
317
+ case 'padding-block':
318
+ apply('top', one)
319
+ apply('bottom', parts[1] ?? one)
320
+ break
321
+ default:
322
+ // `padding` shorthand and anything else. Refusing to guess is the
323
+ // point: an unrecognised form must not read as "covered".
324
+ throw new Error(`${selector}: unhandled padding form \`${prop}: ${value}\``)
325
+ }
326
+ }
327
+ }
328
+ return out
329
+ }
330
+
331
+ /** Per-side margin, same rules. Used only for the layout-neutrality check. */
332
+ function marginOf(selector: string): Box {
333
+ const out = zero()
334
+ for (const rule of STYLES) {
335
+ if (rule.selector !== selector) continue
336
+ for (const [prop, value] of rule.decls) {
337
+ if (!prop.startsWith('margin')) continue
338
+ const v = splitTop(value, ' ')[0]!.trim()
339
+ if (prop === 'margin-block-start') out.top = resolveLength(v)
340
+ else if (prop === 'margin-block-end') out.bottom = resolveLength(v)
341
+ else if (prop === 'margin-inline') {
342
+ out.left = resolveLength(v)
343
+ out.right = resolveLength(v)
344
+ } else throw new Error(`${selector}: unhandled margin form \`${prop}: ${value}\``)
345
+ }
346
+ }
347
+ return out
348
+ }
349
+
350
+ /**
351
+ * The containment pairs. Hand-written, because CSS does not encode
352
+ * containment and a scan cannot infer it — see blind spot 1 above.
353
+ */
354
+ const CONTAINMENT = [
355
+ {
356
+ clip: '.ds-board-column-body',
357
+ paints: '.ds-board-card',
358
+ /**
359
+ * The DragOverlay card is NOT in the lane. dnd-kit renders
360
+ * `<DragOverlay>` as a sibling of the board inside `<DndContext>`
361
+ * (`sortable.tsx:616`) and positions it `fixed`, so its heavier
362
+ * `--shadow-lg` never meets this clip. Charging the lane for it would
363
+ * demand 12px of inline reserve for a shadow that is never there.
364
+ */
365
+ exclude: /\[data-overlay/,
366
+ /** Compensated so the reserve costs no layout; see the rule's comment. */
367
+ compensated: ['top', 'left', 'right'] as Side[],
368
+ },
369
+ ]
370
+
371
+ describe('clip reserve', () => {
372
+ it('parsed plausible shadow tokens', () => {
373
+ // Guard the guard. If any of these silently returned zero, every
374
+ // assertion below would pass on a completely unreserved lane.
375
+ expect(SHADOWS.size).toBeGreaterThanOrEqual(5)
376
+ expect(SCALE.get('--space-2')).toBe(8)
377
+ // `0 4px 12px` is --shadow-md's widest theme form: 6 inline, 2 up, 10 down.
378
+ const md = SHADOWS.get('--shadow-md')!
379
+ expect(md.left).toBeCloseTo(6)
380
+ expect(md.right).toBeCloseTo(6)
381
+ expect(md.top).toBeCloseTo(2)
382
+ expect(md.bottom).toBeCloseTo(10)
383
+ // An inset layer paints inward and must contribute nothing.
384
+ expect(shadowOutset('inset 0 1px 0 0 red')).toEqual(zero())
385
+ expect(STYLES.length).toBeGreaterThan(100)
386
+ })
387
+
388
+ it.each(CONTAINMENT)('$clip reserves what $paints paints, on every side', (pair) => {
389
+ const { reach, states } = paintReach(pair.paints, pair.exclude)
390
+ // Guard the guard: if the selector stopped matching, `reach` would be all
391
+ // zeroes and the assertion would be vacuous.
392
+ expect(states.length).toBeGreaterThanOrEqual(2)
393
+ expect(Math.max(...SIDES.map((s) => reach[s]))).toBeGreaterThan(0)
394
+
395
+ const padding = paddingOf(pair.clip)
396
+ const short = SIDES.filter((side) => padding[side] < reach[side]).map(
397
+ (side) =>
398
+ `${pair.clip} reserves ${padding[side]}px on the ${side}, but ${pair.paints} ` +
399
+ `paints ${reach[side]}px past its border box there (states: ${states.join(', ')}). ` +
400
+ `The lane clips on BOTH axes — overflow-y:auto coerces overflow-x to auto — ` +
401
+ `so the shortfall is cut off, and no gate but a browser can see it.`,
402
+ )
403
+ expect(short).toEqual([])
404
+ })
405
+
406
+ it.each(CONTAINMENT)('$clip pays for that reserve with no layout at all', (pair) => {
407
+ // The reserve exists to hold paint, not to move anything. On every
408
+ // compensated side an equal negative margin gives the padding back, so
409
+ // the lane's resting composition is bit-identical: verified in Chromium,
410
+ // where the top card sat at the same x/y before and after the fix.
411
+ //
412
+ // Block-end is deliberately NOT in the list. Its 12px exceeds the
413
+ // column's own 8px of bottom padding, so compensating would drive this
414
+ // box through the column's border and consume the gutter the scroller's
415
+ // note reserves for an overlay horizontal scrollbar. Trailing padding on
416
+ // a scroll container is free at rest anyway. If someone adds that margin,
417
+ // this goes red and they get to re-read that note first.
418
+ const padding = paddingOf(pair.clip)
419
+ const margin = marginOf(pair.clip)
420
+ const wrong = SIDES.filter((side) =>
421
+ pair.compensated.includes(side) ? padding[side] + margin[side] !== 0 : margin[side] !== 0,
422
+ ).map(
423
+ (side) =>
424
+ `${pair.clip}: ${side} padding ${padding[side]}px, margin ${margin[side]}px — ` +
425
+ (pair.compensated.includes(side)
426
+ ? 'a compensated side must sum to 0 or every card moves.'
427
+ : 'this side is deliberately uncompensated; see the rule comment.'),
428
+ )
429
+ expect(wrong).toEqual([])
430
+ })
431
+ })
@@ -275,6 +275,40 @@ describe('ActivityPane — forwarding the thread callbacks', () => {
275
275
  expect(onRetry).toHaveBeenCalledWith('c-failed')
276
276
  })
277
277
 
278
+ it('carries a comment’s canEdit / canDelete through to the menu', async () => {
279
+ // The pane forwards `comments` verbatim, so the capability needs no prop
280
+ // of its own here — but "needs no plumbing" is a claim, and this is the
281
+ // assertion behind it. Wiring the two callbacks is not enough on its own.
282
+ const REFUSED: readonly ThreadComment[] = [
283
+ {
284
+ id: 'c1',
285
+ author: { id: 'u1', name: 'Ada Lovelace' },
286
+ bodyMd: 'Somebody else wrote this.',
287
+ createdAt: NOW - 60_000,
288
+ canEdit: false,
289
+ canDelete: false,
290
+ },
291
+ ]
292
+ const shown = await comments({ comments: REFUSED, onEdit: vi.fn(), onDelete: vi.fn() })
293
+ // Both refused, no clipboard under jsdom and the pane never forwards
294
+ // `onCopyLink`, so there is nothing left to put in a menu and no trigger
295
+ // is drawn. Which is the point: the two items are gone, not greyed.
296
+ expect(
297
+ within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
298
+ ).toBeNull()
299
+ })
300
+
301
+ it('draws both when a comment states no capability — the 0.2.0 render', async () => {
302
+ // The other half of the default, at the control. `WIRED` sets neither
303
+ // flag, so a host that upgrades without mapping them loses nothing.
304
+ const shown = await comments({ onEdit: vi.fn(), onDelete: vi.fn() })
305
+ await userEvent.click(
306
+ within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
307
+ )
308
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument()
309
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument()
310
+ })
311
+
278
312
  it('draws none of them for a host that wires none — the 0.1.0 render', async () => {
279
313
  const shown = await comments()
280
314
  // What FU-0019 measured in a browser: a written reaction is readable and
@@ -135,6 +135,16 @@ export interface ActivityPaneProps {
135
135
  * by opening the surface rather than by reading the source, and the source
136
136
  * understates it.
137
137
  *
138
+ * WIRING `onEdit` OR `onDelete` IS HALF THE JOB. The callback says the host
139
+ * has the mutation; `canEdit` / `canDelete` on each `ThreadComment` say
140
+ * whether this viewer may use it on THAT comment, and the pane forwards
141
+ * `comments` verbatim, so they arrive with no plumbing here. Set them, or
142
+ * every live comment in the feed gets an Edit the server will 404 — which is
143
+ * what forwarding these two without them shipped as, and the same shape as
144
+ * the `onHideLinkPreview` note below. Absent means the item is drawn, so
145
+ * nothing this pane renders today disappears on upgrade; that is a
146
+ * deliberate default and `ThreadComment` argues it.
147
+ *
138
148
  * `onRetry` is the same omission with one sharper edge: the failed send it
139
149
  * recovers was posted by THIS pane's own composer, so dropping it left the
140
150
  * pane manufacturing a state and offering nobody a way out of it.