@lovett/ui 0.2.0 → 0.2.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/dist/{chunk-RBYWGBQ2.js → chunk-GP7BKVZC.js} +8 -5
- package/dist/chunk-GP7BKVZC.js.map +1 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/{rich-composer-impl-5NO443A6.js → rich-composer-impl-F5PQVFZT.js} +3 -3
- package/dist/{rich-composer-impl-5NO443A6.js.map → rich-composer-impl-F5PQVFZT.js.map} +1 -1
- package/dist/styles.css +53 -1
- package/dist/theme-v2.css +26 -0
- package/dist/tokens.css +13 -0
- package/package.json +2 -2
- package/src/__tests__/clip-reserve.test.ts +431 -0
- package/src/detail/__tests__/activity-pane.test.tsx +34 -0
- package/src/detail/activity-pane.tsx +10 -0
- package/src/styles.css +53 -1
- package/src/theme-v2.css +26 -0
- package/src/thread/__tests__/thread.test.tsx +188 -0
- package/src/thread/attachments.tsx +1 -1
- package/src/thread/comment.tsx +48 -2
- package/src/thread/composer.tsx +4 -1
- package/src/thread/reactions.tsx +2 -1
- package/src/thread/types.ts +95 -0
- package/src/tokens.css +13 -0
- package/dist/chunk-RBYWGBQ2.js.map +0 -1
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
|
|
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.
|
|
3
|
+
"version": "0.2.3",
|
|
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": "
|
|
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.
|