describe-me 0.1.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.
@@ -0,0 +1,191 @@
1
+ import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
+ import type { ManifestFrame, ManifestTest } from '@describe-me/core/types'
3
+ import { loadSnapshot } from './data.js'
4
+ import { el } from './el.js'
5
+ import { select } from './state.js'
6
+
7
+ /** Bumped on every gallery render, so snapshots that arrive late are dropped. */
8
+ let galleryToken = 0
9
+
10
+ async function paintThumb(thumb: HTMLElement, frame: ManifestFrame, token: number): Promise<void> {
11
+ const node = await loadSnapshot(frame.snapshot)
12
+ if (token !== galleryToken || !thumb.isConnected) {
13
+ return
14
+ }
15
+
16
+ const { iframe } = rebuildIntoSandboxedIframe(node, {
17
+ root: thumb,
18
+ iframeAttributes: { title: frame.label },
19
+ cache: createCache(),
20
+ mirror: createMirror(),
21
+ hackCss: true,
22
+ })
23
+
24
+ // Styles are in place after a frame; only then do the boxes have their final size.
25
+ requestAnimationFrame(() => fitThumb(thumb, iframe))
26
+ }
27
+
28
+ const SKIPPED_TAGS = new Set(['SCRIPT', 'NOSCRIPT', 'STYLE', 'LINK', 'META', 'TITLE'])
29
+
30
+ /**
31
+ * Whether an element draws something itself. Layout wrappers (the test
32
+ * container, a full-width flex row) stretch across the page and would make
33
+ * every thumbnail zoom out to the whole viewport.
34
+ */
35
+ function isVisual(element: Element, view: Window): boolean {
36
+ if (element.children.length === 0) {
37
+ return true
38
+ }
39
+
40
+ const style = view.getComputedStyle(element)
41
+ const hasBackground =
42
+ style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'
43
+
44
+ const hasBorder = parseFloat(style.borderTopWidth) > 0 || parseFloat(style.borderLeftWidth) > 0
45
+ const hasShadow = style.boxShadow !== 'none'
46
+
47
+ return hasBackground || hasBorder || hasShadow
48
+ }
49
+
50
+ /** The box around everything that actually paints in the snapshot, in the iframe's own pixels. */
51
+ function contentBox(body: HTMLElement, view: Window): DOMRect | null {
52
+ let box: DOMRect | null = null
53
+
54
+ for (const element of Array.from(body.querySelectorAll('*'))) {
55
+ if (SKIPPED_TAGS.has(element.tagName) || !isVisual(element, view)) {
56
+ continue
57
+ }
58
+
59
+ const rect = element.getBoundingClientRect()
60
+ if (rect.width === 0 || rect.height === 0) {
61
+ continue
62
+ }
63
+
64
+ if (!box) {
65
+ box = DOMRect.fromRect(rect)
66
+ continue
67
+ }
68
+
69
+ const right = Math.max(box.right, rect.right)
70
+ const bottom = Math.max(box.bottom, rect.bottom)
71
+ box.x = Math.min(box.x, rect.x)
72
+ box.y = Math.min(box.y, rect.y)
73
+ box.width = right - box.x
74
+ box.height = bottom - box.y
75
+ }
76
+
77
+ return box
78
+ }
79
+
80
+ const THUMB_PADDING = 16
81
+
82
+ /**
83
+ * Zoom the snapshot so the component fills the tile instead of sitting in the
84
+ * corner of a full-size page. Never enlarges beyond natural size.
85
+ */
86
+ function fitThumb(thumb: HTMLElement, iframe: HTMLIFrameElement): void {
87
+ const body = iframe.contentDocument?.body
88
+ const view = iframe.contentWindow
89
+ const box = body && view ? contentBox(body, view) : null
90
+ if (!box) {
91
+ return
92
+ }
93
+
94
+ const width = thumb.clientWidth - THUMB_PADDING * 2
95
+ const height = thumb.clientHeight - THUMB_PADDING * 2
96
+ const scale = Math.min(width / box.width, height / box.height, 1)
97
+ const offsetX = THUMB_PADDING + (width - box.width * scale) / 2 - box.x * scale
98
+ const offsetY = THUMB_PADDING + (height - box.height * scale) / 2 - box.y * scale
99
+
100
+ iframe.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`
101
+ }
102
+
103
+ /**
104
+ * The overview column scrolls, not the page, and an observer rooted at the
105
+ * viewport would never see the tiles below its fold.
106
+ */
107
+ function scrollRoot(node: HTMLElement): HTMLElement | null {
108
+ let parent = node.parentElement
109
+ while (parent) {
110
+ const overflow = getComputedStyle(parent).overflowY
111
+ if (overflow === 'auto' || overflow === 'scroll') {
112
+ return parent
113
+ }
114
+
115
+ parent = parent.parentElement
116
+ }
117
+
118
+ return null
119
+ }
120
+
121
+ /** Build each thumbnail once its tile comes near the visible part of the column. */
122
+ function paintWhenVisible(gallery: HTMLElement, pending: Map<Element, ManifestFrame>): void {
123
+ const token = galleryToken
124
+ const observer = new IntersectionObserver(
125
+ (entries) => {
126
+ if (token !== galleryToken) {
127
+ observer.disconnect()
128
+ return
129
+ }
130
+
131
+ for (const entry of entries) {
132
+ const frame = pending.get(entry.target)
133
+ if (entry.isIntersecting && frame) {
134
+ pending.delete(entry.target)
135
+ observer.unobserve(entry.target)
136
+ void paintThumb(entry.target as HTMLElement, frame, token)
137
+ }
138
+ }
139
+ },
140
+ { root: scrollRoot(gallery), rootMargin: '400px' },
141
+ )
142
+
143
+ for (const thumb of pending.keys()) {
144
+ observer.observe(thumb)
145
+ }
146
+ }
147
+
148
+ /** One scaled-down tile per test, showing its last frame and leading to it. */
149
+ export function renderStatesGallery(tests: ManifestTest[]): HTMLElement {
150
+ const token = ++galleryToken
151
+ const gallery = el('div', { class: 'gallery' })
152
+ const pending = new Map<Element, ManifestFrame>()
153
+
154
+ for (const test of tests) {
155
+ const lastIndex = Math.max(0, test.frames.length - 1)
156
+ const last = test.frames[lastIndex]
157
+ const thumb = el('div', { class: 'thumb' })
158
+ if (last) {
159
+ pending.set(thumb, last)
160
+ } else {
161
+ thumb.append(el('div', { class: 'hint' }, 'no frames'))
162
+ }
163
+
164
+ gallery.append(
165
+ el(
166
+ 'button',
167
+ {
168
+ class: 'tile',
169
+ title: test.fullName,
170
+ click: () => select(test.id, lastIndex),
171
+ },
172
+ thumb,
173
+ el(
174
+ 'span',
175
+ { class: 'cap' },
176
+ el('span', { class: `dot ${test.state}` }),
177
+ el('span', { class: 'cap-name' }, test.name),
178
+ ),
179
+ ),
180
+ )
181
+ }
182
+
183
+ // The scroll container only exists once the caller has put the gallery on screen.
184
+ requestAnimationFrame(() => {
185
+ if (token === galleryToken && gallery.isConnected) {
186
+ paintWhenVisible(gallery, pending)
187
+ }
188
+ })
189
+
190
+ return gallery
191
+ }
package/src/style.css ADDED
@@ -0,0 +1,527 @@
1
+ :root {
2
+ --bg: #fafafa;
3
+ --panel: #ffffff;
4
+ --ink: #141414;
5
+ --muted: #7a7a7a;
6
+ --line: #e6e6e6;
7
+ --hover: #f0f0f0;
8
+ --accent: #2b5cff;
9
+ --pass: #1f9d55;
10
+ --fail: #d43f3f;
11
+ --skip: #b5b5b5;
12
+ --stage: #ffffff;
13
+ --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
14
+ color-scheme: light dark;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #101010;
19
+ --panel: #171717;
20
+ --ink: #ececec;
21
+ --muted: #8a8a8a;
22
+ --line: #2a2a2a;
23
+ --hover: #202020;
24
+ --stage: #ffffff;
25
+ }
26
+ }
27
+ * {
28
+ box-sizing: border-box;
29
+ }
30
+ html,
31
+ body {
32
+ height: 100%;
33
+ margin: 0;
34
+ }
35
+ body {
36
+ background: var(--bg);
37
+ color: var(--ink);
38
+ font: 13px/1.45 var(--mono);
39
+ }
40
+ button {
41
+ font: inherit;
42
+ color: inherit;
43
+ background: none;
44
+ border: 0;
45
+ cursor: pointer;
46
+ padding: 0;
47
+ }
48
+ a {
49
+ color: inherit;
50
+ }
51
+
52
+ #app {
53
+ height: 100%;
54
+ display: grid;
55
+ grid-template-rows: 44px 1fr;
56
+ }
57
+ .header {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 16px;
61
+ padding: 0 16px;
62
+ border-bottom: 1px solid var(--line);
63
+ background: var(--panel);
64
+ }
65
+ .wordmark {
66
+ font-weight: 600;
67
+ letter-spacing: -0.01em;
68
+ }
69
+ .wordmark .me {
70
+ color: var(--accent);
71
+ }
72
+ .summary {
73
+ color: var(--muted);
74
+ }
75
+ .summary b {
76
+ color: var(--ink);
77
+ font-weight: 500;
78
+ }
79
+ .summary .fail {
80
+ color: var(--fail);
81
+ }
82
+ .header .right {
83
+ margin-left: auto;
84
+ color: var(--muted);
85
+ }
86
+ .live {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 6px;
90
+ }
91
+ .live::before {
92
+ content: '';
93
+ width: 7px;
94
+ height: 7px;
95
+ border-radius: 50%;
96
+ background: var(--pass);
97
+ }
98
+
99
+ .body {
100
+ display: grid;
101
+ grid-template-columns: 300px 1fr 320px;
102
+ min-height: 0;
103
+ }
104
+ .sidebar {
105
+ border-right: 1px solid var(--line);
106
+ overflow: auto;
107
+ padding: 12px 8px;
108
+ background: var(--panel);
109
+ }
110
+ .module {
111
+ margin-bottom: 14px;
112
+ }
113
+ .module-button,
114
+ .suite-button {
115
+ display: block;
116
+ width: 100%;
117
+ text-align: left;
118
+ border-radius: 6px;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
123
+ .module-button {
124
+ color: var(--muted);
125
+ padding: 4px 8px;
126
+ font-size: 12px;
127
+ }
128
+ .suite {
129
+ padding-left: 12px;
130
+ }
131
+ .suite-button {
132
+ padding: 3px 8px;
133
+ color: var(--ink);
134
+ }
135
+ .module-button:hover,
136
+ .suite-button:hover {
137
+ background: var(--hover);
138
+ }
139
+ .module-button.active,
140
+ .suite-button.active {
141
+ background: var(--ink);
142
+ color: var(--bg);
143
+ }
144
+ .test {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 8px;
148
+ width: 100%;
149
+ text-align: left;
150
+ padding: 4px 8px;
151
+ border-radius: 6px;
152
+ padding-left: 20px;
153
+ }
154
+ .test:hover {
155
+ background: var(--hover);
156
+ }
157
+ .test.active {
158
+ background: var(--ink);
159
+ color: var(--bg);
160
+ }
161
+ .dot {
162
+ width: 7px;
163
+ height: 7px;
164
+ border-radius: 50%;
165
+ flex: none;
166
+ }
167
+ .dot.passed {
168
+ background: var(--pass);
169
+ }
170
+ .dot.failed {
171
+ background: var(--fail);
172
+ }
173
+ .dot.skipped,
174
+ .dot.pending {
175
+ background: var(--skip);
176
+ }
177
+ .test .n {
178
+ color: var(--muted);
179
+ margin-left: auto;
180
+ font-size: 11px;
181
+ }
182
+ .test.active .n {
183
+ color: inherit;
184
+ opacity: 0.7;
185
+ }
186
+
187
+ .main {
188
+ display: grid;
189
+ grid-template-rows: 40px 1fr auto;
190
+ min-width: 0;
191
+ min-height: 0;
192
+ }
193
+ .crumbs {
194
+ display: flex;
195
+ align-items: center;
196
+ gap: 8px;
197
+ padding: 0 16px;
198
+ border-bottom: 1px solid var(--line);
199
+ color: var(--muted);
200
+ overflow: hidden;
201
+ white-space: nowrap;
202
+ }
203
+ .crumbs .sep {
204
+ opacity: 0.5;
205
+ }
206
+ .crumbs .cur {
207
+ color: var(--ink);
208
+ }
209
+ .crumbs .tools {
210
+ margin-left: auto;
211
+ display: flex;
212
+ gap: 4px;
213
+ }
214
+ .crumbs .tools button {
215
+ padding: 2px 8px;
216
+ border-radius: 5px;
217
+ color: var(--muted);
218
+ }
219
+ .crumbs .tools button.on {
220
+ background: var(--hover);
221
+ color: var(--ink);
222
+ }
223
+ .stage {
224
+ position: relative;
225
+ overflow: auto;
226
+ padding: 24px;
227
+ display: flex;
228
+ justify-content: center;
229
+ align-items: flex-start;
230
+ background-image: radial-gradient(var(--line) 1px, transparent 1px);
231
+ background-size: 16px 16px;
232
+ }
233
+ .stage iframe {
234
+ border: 1px solid var(--line);
235
+ border-radius: 8px;
236
+ background: var(--stage);
237
+ width: 100%;
238
+ height: 100%;
239
+ min-height: 320px;
240
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
241
+ transition: width 0.15s ease;
242
+ }
243
+ .stage .empty {
244
+ color: var(--muted);
245
+ align-self: center;
246
+ }
247
+
248
+ .timeline {
249
+ border-top: 1px solid var(--line);
250
+ padding: 10px 16px;
251
+ display: flex;
252
+ gap: 6px;
253
+ overflow-x: auto;
254
+ background: var(--panel);
255
+ }
256
+ .frame {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 8px;
260
+ padding: 6px 10px;
261
+ border: 1px solid var(--line);
262
+ border-radius: 999px;
263
+ white-space: nowrap;
264
+ color: var(--muted);
265
+ }
266
+ .frame:hover {
267
+ background: var(--hover);
268
+ }
269
+ .frame.active {
270
+ border-color: var(--ink);
271
+ color: var(--ink);
272
+ }
273
+ .frame .k {
274
+ font-size: 10px;
275
+ text-transform: uppercase;
276
+ letter-spacing: 0.06em;
277
+ opacity: 0.7;
278
+ }
279
+ .frame .t {
280
+ opacity: 0.6;
281
+ font-size: 11px;
282
+ }
283
+ .frame.render .k {
284
+ color: var(--accent);
285
+ opacity: 1;
286
+ }
287
+ .frame.action .k {
288
+ color: var(--pass);
289
+ opacity: 1;
290
+ }
291
+ .frame.step .k {
292
+ color: #c77d00;
293
+ opacity: 1;
294
+ }
295
+ .frame.end .k {
296
+ color: var(--muted);
297
+ }
298
+
299
+ .inspector {
300
+ border-left: 1px solid var(--line);
301
+ overflow: auto;
302
+ padding: 16px;
303
+ background: var(--panel);
304
+ }
305
+ .inspector h3 {
306
+ margin: 0 0 8px;
307
+ font-size: 11px;
308
+ text-transform: uppercase;
309
+ letter-spacing: 0.08em;
310
+ color: var(--muted);
311
+ font-weight: 500;
312
+ }
313
+ .inspector section {
314
+ margin-bottom: 20px;
315
+ }
316
+ .status {
317
+ display: inline-flex;
318
+ align-items: center;
319
+ gap: 8px;
320
+ }
321
+ .status .dot {
322
+ width: 8px;
323
+ height: 8px;
324
+ }
325
+ .kv {
326
+ width: 100%;
327
+ border-collapse: collapse;
328
+ }
329
+ .kv td {
330
+ padding: 3px 0;
331
+ vertical-align: top;
332
+ border-bottom: 1px solid var(--line);
333
+ }
334
+ .kv td:first-child {
335
+ color: var(--muted);
336
+ padding-right: 12px;
337
+ white-space: nowrap;
338
+ }
339
+ .kv td:last-child {
340
+ word-break: break-word;
341
+ }
342
+ .val-string {
343
+ color: #b0501f;
344
+ }
345
+ .val-number,
346
+ .val-boolean {
347
+ color: var(--accent);
348
+ }
349
+ .val-fn {
350
+ color: var(--muted);
351
+ font-style: italic;
352
+ }
353
+ .err {
354
+ color: var(--fail);
355
+ white-space: pre-wrap;
356
+ font-size: 12px;
357
+ }
358
+ .hint {
359
+ color: var(--muted);
360
+ font-size: 12px;
361
+ }
362
+ kbd {
363
+ border: 1px solid var(--line);
364
+ border-radius: 4px;
365
+ padding: 0 4px;
366
+ font: inherit;
367
+ font-size: 11px;
368
+ }
369
+
370
+ .overview {
371
+ overflow: auto;
372
+ min-width: 0;
373
+ min-height: 0;
374
+ padding: 20px 24px 48px;
375
+ }
376
+ .overview-head {
377
+ display: flex;
378
+ align-items: baseline;
379
+ gap: 16px;
380
+ padding-bottom: 10px;
381
+ border-bottom: 1px solid var(--line);
382
+ }
383
+ .overview-head h1 {
384
+ margin: 0;
385
+ font-size: 17px;
386
+ font-weight: 600;
387
+ letter-spacing: -0.01em;
388
+ }
389
+ .overview-meta {
390
+ margin-left: auto;
391
+ color: var(--muted);
392
+ }
393
+ .overview-section {
394
+ margin-top: 22px;
395
+ }
396
+ .overview-section h2 {
397
+ margin: 0;
398
+ font-size: 14px;
399
+ font-weight: 600;
400
+ }
401
+ .overview h3 {
402
+ margin: 18px 0 8px;
403
+ font-size: 11px;
404
+ text-transform: uppercase;
405
+ letter-spacing: 0.08em;
406
+ color: var(--muted);
407
+ font-weight: 500;
408
+ }
409
+ .overview .empty {
410
+ color: var(--muted);
411
+ padding: 24px 0;
412
+ }
413
+ .head-hint {
414
+ margin-left: 10px;
415
+ color: var(--muted);
416
+ text-transform: none;
417
+ letter-spacing: 0;
418
+ opacity: 0.85;
419
+ }
420
+
421
+ .props th {
422
+ text-align: left;
423
+ padding: 0 12px 5px 0;
424
+ font-size: 11px;
425
+ font-weight: 500;
426
+ text-transform: uppercase;
427
+ letter-spacing: 0.06em;
428
+ color: var(--muted);
429
+ border-bottom: 1px solid var(--line);
430
+ }
431
+ .props td {
432
+ padding: 7px 12px 7px 0;
433
+ }
434
+ .props td.prop-name {
435
+ color: var(--ink);
436
+ }
437
+ .props .req {
438
+ color: var(--fail);
439
+ }
440
+ .props .prop-type {
441
+ color: var(--muted);
442
+ white-space: pre-wrap;
443
+ }
444
+ .props .prop-covered {
445
+ width: 52%;
446
+ }
447
+ .chip {
448
+ display: inline-flex;
449
+ align-items: baseline;
450
+ gap: 4px;
451
+ margin: 0 6px 4px 0;
452
+ padding: 1px 8px;
453
+ border: 1px solid transparent;
454
+ border-radius: 999px;
455
+ font-size: 12px;
456
+ white-space: nowrap;
457
+ }
458
+ .chip.covered {
459
+ color: var(--pass);
460
+ background: color-mix(in srgb, var(--pass) 14%, transparent);
461
+ border-color: color-mix(in srgb, var(--pass) 32%, transparent);
462
+ }
463
+ .chip.missing {
464
+ color: var(--fail);
465
+ background: color-mix(in srgb, var(--fail) 14%, transparent);
466
+ border-color: color-mix(in srgb, var(--fail) 32%, transparent);
467
+ }
468
+ .chip-default {
469
+ color: var(--muted);
470
+ font-size: 11px;
471
+ }
472
+
473
+ .gallery {
474
+ display: grid;
475
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
476
+ gap: 14px;
477
+ }
478
+ .tile {
479
+ display: block;
480
+ width: 100%;
481
+ text-align: left;
482
+ border: 1px solid var(--line);
483
+ border-radius: 10px;
484
+ overflow: hidden;
485
+ background: var(--panel);
486
+ }
487
+ .tile:hover {
488
+ border-color: var(--muted);
489
+ }
490
+ .thumb {
491
+ height: 150px;
492
+ overflow: hidden;
493
+ background: var(--stage);
494
+ border-bottom: 1px solid var(--line);
495
+ }
496
+ .thumb iframe {
497
+ width: 800px;
498
+ height: 500px;
499
+ border: 0;
500
+ transform: scale(0.3);
501
+ transform-origin: 0 0;
502
+ pointer-events: none;
503
+ }
504
+ .thumb .hint {
505
+ padding: 10px;
506
+ }
507
+ .cap {
508
+ display: flex;
509
+ align-items: center;
510
+ gap: 8px;
511
+ padding: 7px 10px;
512
+ min-width: 0;
513
+ }
514
+ .cap-name {
515
+ overflow: hidden;
516
+ text-overflow: ellipsis;
517
+ white-space: nowrap;
518
+ }
519
+
520
+ .missing-line {
521
+ color: var(--fail);
522
+ }
523
+ .component-line {
524
+ display: flex;
525
+ gap: 12px;
526
+ justify-content: space-between;
527
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The identity of a sidebar selection: a module plus a suite path inside it.
3
+ * A module-level selection has an empty path, so its key ends with `::`.
4
+ */
5
+ export function suiteKey(moduleId: string, path: string[]): string {
6
+ return `${moduleId}::${path.join(' > ')}`
7
+ }
8
+
9
+ /** The inverse of `suiteKey`. A key without a separator is read as a whole module. */
10
+ export function parseSuiteKey(key: string): { moduleId: string; path: string[] } {
11
+ const cut = key.indexOf('::')
12
+ if (cut < 0) {
13
+ return { moduleId: key, path: [] }
14
+ }
15
+
16
+ const tail = key.slice(cut + 2)
17
+
18
+ return { moduleId: key.slice(0, cut), path: tail ? tail.split(' > ') : [] }
19
+ }