vite-plugin-nora 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +26 -0
  3. package/README.md +113 -0
  4. package/licenses/geist-OFL.txt +93 -0
  5. package/licenses/lucide-ISC.txt +17 -0
  6. package/package.json +90 -0
  7. package/src/cli.js +101 -0
  8. package/src/client/App.jsx +216 -0
  9. package/src/client/canvas/Canvas.jsx +95 -0
  10. package/src/client/canvas/ErrorBoundary.jsx +56 -0
  11. package/src/client/canvas/Preview.jsx +70 -0
  12. package/src/client/canvas/Viewport.jsx +211 -0
  13. package/src/client/chords.js +38 -0
  14. package/src/client/css.d.ts +5 -0
  15. package/src/client/fonts.css +48 -0
  16. package/src/client/frame.css +107 -0
  17. package/src/client/frame.html +11 -0
  18. package/src/client/frame.jsx +73 -0
  19. package/src/client/index.html +12 -0
  20. package/src/client/main.jsx +10 -0
  21. package/src/client/open-folder.js +84 -0
  22. package/src/client/selection.js +22 -0
  23. package/src/client/styles.css +1264 -0
  24. package/src/client/sweep/SweepPanel.jsx +206 -0
  25. package/src/client/sweep/measure.js +230 -0
  26. package/src/client/sweep/report.js +54 -0
  27. package/src/client/sweep/run-sweep.js +185 -0
  28. package/src/client/toolbar/Picker.jsx +343 -0
  29. package/src/client/toolbar/Toolbar.jsx +231 -0
  30. package/src/client/toolbar/ViewportMenu.jsx +83 -0
  31. package/src/client/toolbar/bar-shape.js +402 -0
  32. package/src/client/toolbar/icons.jsx +129 -0
  33. package/src/client/toolbar/use-draggable-bar.js +204 -0
  34. package/src/client/viewports.js +43 -0
  35. package/src/client/virtual.d.ts +28 -0
  36. package/src/index.js +4 -0
  37. package/src/server/create-server.js +147 -0
  38. package/src/server/plugin.js +207 -0
  39. package/src/server/safe-path.js +42 -0
  40. package/src/server/scan.js +350 -0
  41. package/types/index.d.ts +3 -0
  42. package/types/server/create-server.d.ts +22 -0
  43. package/types/server/plugin.d.ts +20 -0
  44. package/types/server/safe-path.d.ts +25 -0
  45. package/types/server/scan.d.ts +106 -0
@@ -0,0 +1,1264 @@
1
+ /* =========================================================================
2
+ nora — client chrome
3
+
4
+ The canvas honours a light/dark toggle; the bar is always dark, the way a
5
+ piece of hardware sitting on top of the work would be.
6
+ ========================================================================= */
7
+
8
+ @import "./fonts.css";
9
+
10
+ .nora-app {
11
+ --nora-canvas: #f1f2f4;
12
+ --nora-canvas-ink: #14171c;
13
+ --nora-canvas-muted: #5c6672;
14
+ --nora-canvas-line: #d8dce1;
15
+ --nora-accent: #1b7da3;
16
+
17
+ /* The three colours the sweep speaks in: one for "this is wrong", one for
18
+ "this is a fact about your layout", one for "there was nothing to find".
19
+ Defined here because the panel is on the bar, which is always dark, so
20
+ they don't change with the canvas. The green is pitched to sit with the
21
+ other two rather than to announce itself — a clean sweep is the expected
22
+ outcome, not an achievement. */
23
+ --nora-flag: #f0a0a0;
24
+ --nora-flag-solid: #d76a6a;
25
+ --nora-note: #7cc4e2;
26
+ --nora-ok: #8ed3a9;
27
+
28
+ --nora-pill: #16181c;
29
+ --nora-pill-hi: #22262d;
30
+ --nora-pill-ink: #ffffff;
31
+ --nora-pill-soft: rgba(255, 255, 255, 0.1);
32
+ --nora-pill-soft-hi: rgba(255, 255, 255, 0.17);
33
+ --nora-pill-dim: rgba(255, 255, 255, 0.45);
34
+ --nora-pill-line: rgba(255, 255, 255, 0.08);
35
+
36
+ --nora-ease: cubic-bezier(0.19, 1, 0.22, 1);
37
+ --nora-shadow: 0 2px 8px rgba(0, 0, 0, 0.14), 0 12px 32px rgba(0, 0, 0, 0.16);
38
+
39
+ /* Geist and Geist Mono are bundled as inlined subsets — see fonts.css. The
40
+ fallbacks matter anyway: they cover the handful of glyphs the subset does
41
+ not carry, the macOS ⌘ and ⌥ among them. */
42
+ --nora-sans:
43
+ "Nora Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
44
+ --nora-mono: "Nora Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
45
+ }
46
+
47
+ .nora-app[data-theme="dark"] {
48
+ --nora-canvas: #101317;
49
+ --nora-canvas-ink: #e6e9ed;
50
+ --nora-canvas-muted: #8b95a2;
51
+ --nora-canvas-line: #262d36;
52
+ --nora-accent: #57add0;
53
+
54
+ /* The bar is always dark, but on a dark canvas "always dark" stops being a
55
+ silhouette and starts being camouflage — #16181c on #101317 is separated
56
+ by a drop shadow and nothing else. Lift the fill and add a hairline rim so
57
+ the chrome still reads as sitting *on top of* the work. */
58
+ --nora-pill: #1e2127;
59
+ --nora-pill-hi: #2a2e36;
60
+ --nora-pill-rim: rgba(255, 255, 255, 0.11);
61
+ }
62
+
63
+ html,
64
+ body,
65
+ #nora-root {
66
+ height: 100%;
67
+ margin: 0;
68
+ }
69
+
70
+ /* Scoped to our own chrome on purpose. A global `* { box-sizing: border-box }`
71
+ would silently change how a previewed component lays out compared to the
72
+ real app, which is the one thing this tool must never do. */
73
+ .nora-bar-area,
74
+ .nora-bar-area *,
75
+ .nora-bar-area *::before,
76
+ .nora-bar-area *::after,
77
+ .nora-empty,
78
+ .nora-empty * {
79
+ box-sizing: border-box;
80
+ }
81
+
82
+ .nora-app {
83
+ height: 100%;
84
+ display: flex;
85
+ flex-direction: column;
86
+ background: var(--nora-canvas);
87
+ color: var(--nora-canvas-ink);
88
+ font-family: var(--nora-sans);
89
+ transition: background 0.25s ease;
90
+ }
91
+
92
+ /* ---------- canvas ---------- */
93
+
94
+ .nora-canvas {
95
+ flex: 1;
96
+ min-height: 0;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ overflow: hidden;
101
+ }
102
+
103
+ /* ---------- viewport frame ---------- */
104
+
105
+ .nora-viewport {
106
+ position: relative;
107
+ flex: 1;
108
+ min-width: 0;
109
+ min-height: 0;
110
+ height: 100%;
111
+ display: flex;
112
+ flex-direction: column;
113
+ align-items: center;
114
+ }
115
+
116
+ /* At Fit the iframe fills the canvas edge to edge: no padding, no border, no
117
+ shadow. The frame document does its own centring, so a second layer of
118
+ padding out here would only push the component off-centre. */
119
+ .nora-viewport[data-framed="true"] {
120
+ padding: 1.5rem 1.25rem;
121
+ }
122
+
123
+ /* Not inset: the frame is the canvas, so it gets the whole thing. */
124
+ .nora-viewport[data-framed="false"] {
125
+ padding: 0;
126
+ }
127
+
128
+ .nora-frame-holder {
129
+ flex: 1;
130
+ min-height: 0;
131
+ overflow: hidden;
132
+ transition: width 0.28s var(--nora-ease);
133
+ }
134
+
135
+ .nora-viewport[data-framed="true"] .nora-frame-holder {
136
+ border-radius: 10px;
137
+ border: 1px solid var(--nora-canvas-line);
138
+ box-shadow:
139
+ 0 1px 2px rgba(0, 0, 0, 0.05),
140
+ 0 10px 30px rgba(0, 0, 0, 0.07);
141
+ }
142
+
143
+ .nora-frame {
144
+ display: block;
145
+ border: 0;
146
+ background: transparent;
147
+ }
148
+
149
+ /* Covers the frame for the few seconds a sweep runs. The frame underneath
150
+ keeps laying out and painting — that is what the measurements read — but the
151
+ hundred width changes never reach the eye. */
152
+ .nora-sweep-cover {
153
+ position: absolute;
154
+ inset: 0;
155
+ z-index: 2;
156
+ background: var(--nora-canvas);
157
+ display: flex;
158
+ flex-direction: column;
159
+ align-items: center;
160
+ justify-content: center;
161
+ gap: 0.7rem;
162
+ animation: nora-cover-in 0.18s ease both;
163
+ }
164
+
165
+ .nora-sweep-cover-label {
166
+ font-family: var(--nora-mono);
167
+ font-size: 0.74rem;
168
+ letter-spacing: 0.02em;
169
+ color: var(--nora-canvas-muted);
170
+ animation: nora-breathe 1.8s ease-in-out infinite;
171
+ }
172
+
173
+ /* The progress bar lives here, under the label, rather than inside the button
174
+ that started the sweep: once the canvas is covered, the bar is the one place
175
+ nobody is looking. */
176
+ .nora-sweep-cover-track {
177
+ /* A shade wider than the label above it, so the two read as one block. */
178
+ width: 15rem;
179
+ height: 2px;
180
+ border-radius: 1px;
181
+ background: var(--nora-canvas-line, rgb(128 128 128 / 0.25));
182
+ overflow: hidden;
183
+ }
184
+
185
+ .nora-sweep-cover-fill {
186
+ display: block;
187
+ width: 100%;
188
+ height: 100%;
189
+ background: var(--nora-canvas-muted);
190
+ transform-origin: left center;
191
+ transform: scaleX(0);
192
+ transition: transform 0.12s linear;
193
+ }
194
+
195
+ @keyframes nora-cover-in {
196
+ from {
197
+ opacity: 0;
198
+ }
199
+ to {
200
+ opacity: 1;
201
+ }
202
+ }
203
+
204
+ @keyframes nora-breathe {
205
+ 0%,
206
+ 100% {
207
+ opacity: 0.55;
208
+ }
209
+ 50% {
210
+ opacity: 1;
211
+ }
212
+ }
213
+
214
+ /* Width transitions would lag a hundred steps behind the sweep. */
215
+ .nora-viewport[data-sweeping="true"] .nora-frame-holder {
216
+ transition: none;
217
+ }
218
+
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .nora-sweep-cover,
221
+ .nora-sweep-cover-label {
222
+ animation: none;
223
+ }
224
+
225
+ .nora-sweep-cover-fill {
226
+ transition: none;
227
+ }
228
+ }
229
+
230
+ /* Nothing renders an error in *this* document — a component that throws does so
231
+ inside the frame, where the boundary lives, so the error surface is
232
+ `.noraf-error` in frame.css. */
233
+ .nora-empty {
234
+ font-family: var(--nora-sans);
235
+ font-size: 0.84rem;
236
+ color: var(--nora-canvas-muted);
237
+ text-align: center;
238
+ line-height: 1.75;
239
+ display: flex;
240
+ flex-direction: column;
241
+ gap: 0.15rem;
242
+ max-width: 46ch;
243
+ padding: 2.5rem;
244
+ }
245
+
246
+ .nora-empty-title {
247
+ color: var(--nora-canvas-ink);
248
+ font-weight: 500;
249
+ letter-spacing: -0.01em;
250
+ margin-bottom: 0.35rem;
251
+ }
252
+
253
+ .nora-empty-line {
254
+ font-size: 0.79rem;
255
+ }
256
+
257
+ /* =========================================================================
258
+ The bar
259
+
260
+ One silhouette, three clusters, buttons on top.
261
+
262
+ The dark shape is an SVG path drawn by bar-shape.js, not a background on any
263
+ of these elements. That is the load-bearing decision here: as separate
264
+ rounded boxes with painted bridges between them, the junctions showed a
265
+ hairline ridge at every zoom level, because two antialiased edges meeting on
266
+ one pixel never sum back to full opacity. A single filled path has no
267
+ interior edges to seam.
268
+
269
+ Everything below therefore describes only the *contents*. The clusters are
270
+ plain flex rows with padding; the shape wraps whatever boxes they end up
271
+ occupying, which is why changing the buttons here needs no change there.
272
+ ========================================================================= */
273
+
274
+ .nora-bar-area {
275
+ position: fixed;
276
+ bottom: 1.25rem;
277
+ right: 1.25rem;
278
+ z-index: 2147483000;
279
+ display: flex;
280
+ flex-direction: column;
281
+ align-items: flex-end;
282
+ gap: 0.5rem;
283
+ font-family: var(--nora-sans);
284
+ -webkit-font-smoothing: antialiased;
285
+ }
286
+
287
+ /* Anchored to whichever corner the bar was dragged nearest, so its panels
288
+ always open away from the edge rather than off the screen. */
289
+ .nora-bar-area[data-vside="top"] {
290
+ flex-direction: column-reverse;
291
+ }
292
+
293
+ .nora-bar-area[data-hside="left"] {
294
+ align-items: flex-start;
295
+ }
296
+
297
+ .nora-bar-area[data-dragging="true"] {
298
+ cursor: grabbing;
299
+ user-select: none;
300
+ }
301
+
302
+ /* The frame must not swallow the pointer mid-drag. */
303
+ .nora-bar-area[data-dragging="true"] ~ * iframe,
304
+ .nora-app:has(.nora-bar-area[data-dragging="true"]) iframe {
305
+ pointer-events: none;
306
+ }
307
+
308
+ .nora-pill {
309
+ /* The expanded width. One number, one place. */
310
+ --nora-bar-w: 462px;
311
+
312
+ position: relative;
313
+ display: flex;
314
+ align-items: center;
315
+ height: 44px;
316
+ color: var(--nora-pill-ink);
317
+ cursor: grab;
318
+ transition: transform 0.2s var(--nora-ease);
319
+ }
320
+
321
+ /* No `transition: width` anywhere on the pill, deliberately. The silhouette is
322
+ an SVG path, and a path cannot be interpolated by the compositor — so a CSS
323
+ width transition here would animate the box while the shape jumped, and the
324
+ buttons would sit outside their own outline for the length of it. Opening and
325
+ shutting is driven from bar-shape.js instead, which writes this width and the
326
+ path together on the same frame. These two rules are the resting values it
327
+ hands back to. */
328
+ .nora-pill[data-state="collapsed"] {
329
+ width: 44px;
330
+ justify-content: center;
331
+ cursor: pointer;
332
+ }
333
+
334
+ .nora-pill[data-state="expanded"] {
335
+ width: var(--nora-bar-w);
336
+ }
337
+
338
+ /* ---------- the silhouette ---------- */
339
+
340
+ .nora-shape {
341
+ position: absolute;
342
+ left: 0;
343
+ top: 0;
344
+ overflow: visible;
345
+ pointer-events: none;
346
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.16)) drop-shadow(0 12px 32px rgba(0, 0, 0, 0.18));
347
+ transition: filter 0.2s ease;
348
+ }
349
+
350
+ /* The rim rides the same path as the fill, so it is continuous by construction
351
+ — a border drawn round separate cluster elements is the seam problem all
352
+ over again. */
353
+ .nora-shape path {
354
+ fill: var(--nora-pill);
355
+ stroke: var(--nora-pill-rim, transparent);
356
+ stroke-width: 1;
357
+ transition: fill 0.15s ease;
358
+ }
359
+
360
+ .nora-pill[data-state="collapsed"]:hover .nora-shape path {
361
+ fill: var(--nora-pill-hi);
362
+ }
363
+
364
+ .nora-pill[data-state="collapsed"]:active {
365
+ transform: scale(0.95);
366
+ }
367
+
368
+ .nora-bar-area[data-dragging="true"] .nora-shape {
369
+ filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.22)) drop-shadow(0 18px 44px rgba(0, 0, 0, 0.24));
370
+ }
371
+
372
+ /* ---------- the two faces ---------- */
373
+
374
+ /* Pinned to the held edge at exactly the shut bar's width, rather than
375
+ stretched across the pill: while the bar opens, the pill's width is changing
376
+ every frame, and an `inset: 0` face would drift left with it as it faded. */
377
+ .nora-pill-face {
378
+ position: absolute;
379
+ right: 0;
380
+ top: 0;
381
+ bottom: 0;
382
+ width: 44px;
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ color: var(--nora-pill-dim);
387
+ transition:
388
+ opacity 0.14s var(--nora-ease),
389
+ color 0.15s ease;
390
+ }
391
+
392
+ .nora-pill[data-state="collapsed"]:hover .nora-pill-face {
393
+ color: var(--nora-pill-ink);
394
+ }
395
+
396
+ .nora-pill-face.is-out {
397
+ opacity: 0;
398
+ pointer-events: none;
399
+ }
400
+
401
+ /* Absolutely positioned at the full expanded width and pinned to the edge the
402
+ bar opens and shuts against, so it is the same box in every state. That is
403
+ what lets the silhouette animate at all: the boxes feeding the path are
404
+ measured from these clusters, and if the container reflowed as the pill grew,
405
+ the flexible subject cluster would re-solve on every frame of the morph and
406
+ the path would be chasing a layout that was still settling.
407
+ Opacity is the only thing that changes here — composited, no layout, no
408
+ repaint of the subtree. The `filter: blur()` that used to sit on this element
409
+ animated a filter over live text on every frame of the expansion, next to a
410
+ path being rebuilt on those same frames, and was a large part of why opening
411
+ the bar stuttered. */
412
+ .nora-pill-controls {
413
+ position: absolute;
414
+ right: 0;
415
+ top: 0;
416
+ bottom: 0;
417
+ width: var(--nora-bar-w);
418
+ display: flex;
419
+ align-items: center;
420
+ transition: opacity 0.13s var(--nora-ease);
421
+ }
422
+
423
+ /* While the shape is morphing, opacity is written per frame from
424
+ bar-shape.js — on a squared curve, so the text is still nearly invisible
425
+ while the outline is narrow enough to crop it. A transition here would lag
426
+ those writes, and a live button hanging outside a half-open outline would
427
+ be clickable. */
428
+ .nora-pill[data-morphing] .nora-pill-controls,
429
+ .nora-pill[data-morphing] .nora-pill-face {
430
+ transition: none;
431
+ pointer-events: none;
432
+ }
433
+
434
+ .nora-pill-controls.is-out {
435
+ opacity: 0;
436
+ pointer-events: none;
437
+ }
438
+
439
+ .nora-pill-controls.is-in {
440
+ opacity: 1;
441
+ }
442
+
443
+ /* ---------- clusters ---------- */
444
+
445
+ .nora-cluster {
446
+ display: flex;
447
+ align-items: center;
448
+ gap: 2px;
449
+ padding: 5px;
450
+ flex: none;
451
+ min-width: 0;
452
+ }
453
+
454
+ /* The one that stretches: the component name has no natural width, and the
455
+ bar has a fixed one. */
456
+ .nora-cluster-subject {
457
+ flex: 1;
458
+ min-width: 0;
459
+ }
460
+
461
+ /* The gap a bridge spans. Empty on purpose — the bridge is drawn by the path;
462
+ this only reserves the space it curves through. */
463
+ .nora-bridge {
464
+ flex: none;
465
+ width: 6px;
466
+ }
467
+
468
+ /* ---------- buttons ---------- */
469
+
470
+ .nora-icon-btn,
471
+ .nora-name-btn,
472
+ .nora-vp-btn {
473
+ display: inline-flex;
474
+ align-items: center;
475
+ height: 34px;
476
+ border: none;
477
+ background: transparent;
478
+ color: var(--nora-pill-ink);
479
+ font-family: var(--nora-sans);
480
+ cursor: pointer;
481
+ border-radius: 999px;
482
+ padding: 0;
483
+ transition:
484
+ background 0.15s ease,
485
+ opacity 0.15s ease;
486
+ }
487
+
488
+ .nora-icon-btn {
489
+ width: 34px;
490
+ justify-content: center;
491
+ flex: none;
492
+ }
493
+
494
+ .nora-icon-btn:hover:not(:disabled),
495
+ .nora-name-btn:hover:not(:disabled),
496
+ .nora-vp-btn:hover:not(:disabled) {
497
+ background: var(--nora-pill-soft);
498
+ }
499
+
500
+ /* `:hover:not(:disabled)` is three simple selectors and would otherwise
501
+ out-specify a bare `.is-active`, so a button whose panel is open loses its
502
+ accent the moment the pointer is over it — which is exactly when you are
503
+ looking at it. Matching the hover state here is what keeps that from
504
+ happening; the brightness bump below is the hover feedback instead. */
505
+ .nora-icon-btn.is-active,
506
+ .nora-icon-btn.is-active:hover,
507
+ .nora-vp-btn.is-active,
508
+ .nora-vp-btn.is-active:hover {
509
+ background: var(--nora-accent);
510
+ }
511
+
512
+ .nora-icon-btn.is-active:hover,
513
+ .nora-vp-btn.is-active:hover {
514
+ filter: brightness(1.14);
515
+ }
516
+
517
+ /* The name field is the widest thing on the bar, so accent-filling it while its
518
+ palette is open turns a third of the bar into a slab of blue and buries the
519
+ name it is meant to be showing. A held-down look is enough. */
520
+ .nora-name-btn.is-active,
521
+ .nora-name-btn.is-active:hover {
522
+ background: var(--nora-pill-soft-hi);
523
+ }
524
+
525
+ .nora-icon-btn:disabled,
526
+ .nora-name-btn:disabled,
527
+ .nora-vp-btn:disabled {
528
+ opacity: 0.4;
529
+ cursor: default;
530
+ }
531
+
532
+ .nora-name-btn {
533
+ flex: 1;
534
+ min-width: 0;
535
+ gap: 0.45rem;
536
+ padding: 0 0.7rem;
537
+ font-size: 0.78rem;
538
+ font-weight: 500;
539
+ letter-spacing: -0.005em;
540
+ }
541
+
542
+ .nora-name-search {
543
+ flex: none;
544
+ opacity: 0.5;
545
+ }
546
+
547
+ .nora-name-label {
548
+ flex: 1;
549
+ min-width: 0;
550
+ overflow: hidden;
551
+ text-overflow: ellipsis;
552
+ white-space: nowrap;
553
+ text-align: left;
554
+ }
555
+
556
+ /* Numbers are the one place the mono face earns its keep: tabular figures mean
557
+ the width doesn't jitter as it counts through a sweep. */
558
+ .nora-vp-btn {
559
+ flex: none;
560
+ justify-content: center;
561
+ gap: 0.3rem;
562
+ min-width: 44px;
563
+ padding: 0 0.65rem;
564
+ font-family: var(--nora-mono);
565
+ font-size: 0.72rem;
566
+ font-weight: 500;
567
+ font-variant-numeric: tabular-nums;
568
+ }
569
+
570
+ /* Shown only when a preset is wider than the window and the frame is scaled
571
+ down — the one piece of viewport state the width alone can't tell you. */
572
+ .nora-vp-scale {
573
+ font-size: 0.62rem;
574
+ color: var(--nora-pill-dim);
575
+ }
576
+
577
+ .nora-vp-btn.is-active .nora-vp-scale {
578
+ color: rgba(255, 255, 255, 0.75);
579
+ }
580
+
581
+ .nora-viewport-menu {
582
+ width: 220px;
583
+ }
584
+
585
+ .nora-row.is-current {
586
+ color: var(--nora-accent);
587
+ }
588
+
589
+ /* Where the keyboard cursor is, as distinct from which width is in use. The
590
+ panel takes focus itself so the arrows have somewhere to land, and this
591
+ highlight is the affordance, so the panel's own ring would only be noise. */
592
+ .nora-row.is-at {
593
+ background: var(--nora-pill-soft);
594
+ }
595
+
596
+ .nora-viewport-menu:focus {
597
+ outline: none;
598
+ }
599
+
600
+ .nora-icon-btn:focus-visible,
601
+ .nora-name-btn:focus-visible,
602
+ .nora-vp-btn:focus-visible,
603
+ .nora-pill:focus-visible,
604
+ .nora-row:focus-visible,
605
+ .nora-row-pick:focus-visible,
606
+ .nora-palette-row:focus-visible,
607
+ .nora-panel-close:focus-visible,
608
+ .nora-panel-up:focus-visible {
609
+ outline: 2px solid var(--nora-accent);
610
+ outline-offset: 2px;
611
+ }
612
+
613
+ /* Drawn inside: the palette input spans the full popover, so an outset ring
614
+ would be clipped by the viewport edge the bar is anchored to. */
615
+ .nora-palette-input:focus-visible {
616
+ outline: 2px solid var(--nora-accent);
617
+ outline-offset: -2px;
618
+ }
619
+
620
+ /* ---------- shared popover surface ---------- */
621
+
622
+ .nora-panel,
623
+ .nora-palette {
624
+ width: 384px;
625
+ max-width: calc(100vw - 2.5rem);
626
+ background: var(--nora-pill);
627
+ color: var(--nora-pill-ink);
628
+ border-radius: 18px;
629
+ box-shadow: var(--nora-shadow);
630
+ padding: 0.4rem;
631
+ display: flex;
632
+ flex-direction: column;
633
+ gap: 0.2rem;
634
+ animation: nora-rise 0.24s var(--nora-ease);
635
+ transform-origin: bottom right;
636
+ }
637
+
638
+ /* Same reason as the bar's rim: on a dark canvas a dark popover needs an edge. */
639
+ .nora-app[data-theme="dark"] .nora-panel,
640
+ .nora-app[data-theme="dark"] .nora-palette {
641
+ box-shadow:
642
+ var(--nora-shadow),
643
+ inset 0 0 0 1px var(--nora-pill-rim);
644
+ }
645
+
646
+ .nora-bar-area[data-vside="top"] .nora-panel,
647
+ .nora-bar-area[data-vside="top"] .nora-palette {
648
+ transform-origin: top right;
649
+ animation-name: nora-drop;
650
+ }
651
+
652
+ .nora-bar-area[data-hside="left"] .nora-panel,
653
+ .nora-bar-area[data-hside="left"] .nora-palette {
654
+ transform-origin: bottom left;
655
+ }
656
+
657
+ .nora-bar-area[data-vside="top"][data-hside="left"] .nora-panel,
658
+ .nora-bar-area[data-vside="top"][data-hside="left"] .nora-palette {
659
+ transform-origin: top left;
660
+ }
661
+
662
+ @keyframes nora-drop {
663
+ from {
664
+ opacity: 0;
665
+ transform: scale(0.92) translateY(-8px);
666
+ }
667
+ to {
668
+ opacity: 1;
669
+ transform: scale(1) translateY(0);
670
+ }
671
+ }
672
+
673
+ @keyframes nora-rise {
674
+ from {
675
+ opacity: 0;
676
+ transform: scale(0.92) translateY(8px);
677
+ }
678
+ to {
679
+ opacity: 1;
680
+ transform: scale(1) translateY(0);
681
+ }
682
+ }
683
+
684
+ .nora-panel-head {
685
+ display: flex;
686
+ align-items: center;
687
+ justify-content: space-between;
688
+ gap: 0.5rem;
689
+ padding: 0.45rem 0.6rem 0.35rem;
690
+ }
691
+
692
+ .nora-panel-title {
693
+ font-family: var(--nora-mono);
694
+ font-size: 0.63rem;
695
+ letter-spacing: 0.1em;
696
+ text-transform: uppercase;
697
+ color: var(--nora-pill-dim);
698
+ overflow: hidden;
699
+ text-overflow: ellipsis;
700
+ white-space: nowrap;
701
+ }
702
+
703
+ .nora-panel-up {
704
+ display: inline-flex;
705
+ align-items: center;
706
+ gap: 0.25rem;
707
+ border: none;
708
+ background: var(--nora-pill-soft);
709
+ color: var(--nora-pill-ink);
710
+ font-family: var(--nora-sans);
711
+ font-size: 0.68rem;
712
+ padding: 0.2rem 0.45rem;
713
+ border-radius: 7px;
714
+ cursor: pointer;
715
+ flex: none;
716
+ }
717
+
718
+ .nora-panel-up:hover {
719
+ background: var(--nora-pill-soft-hi);
720
+ }
721
+
722
+ .nora-panel-body,
723
+ .nora-palette-list {
724
+ max-height: min(46vh, 340px);
725
+ overflow-y: auto;
726
+ display: flex;
727
+ flex-direction: column;
728
+ gap: 0.1rem;
729
+ scrollbar-width: thin;
730
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
731
+ }
732
+
733
+ .nora-row-wrap {
734
+ display: flex;
735
+ align-items: center;
736
+ gap: 0.2rem;
737
+ }
738
+
739
+ .nora-row {
740
+ flex: 1;
741
+ min-width: 0;
742
+ display: flex;
743
+ align-items: center;
744
+ gap: 0.5rem;
745
+ padding: 0.45rem 0.6rem;
746
+ border: none;
747
+ border-radius: 9px;
748
+ background: none;
749
+ color: var(--nora-pill-ink);
750
+ font-family: var(--nora-sans);
751
+ font-size: 0.8rem;
752
+ letter-spacing: -0.005em;
753
+ text-align: left;
754
+ cursor: pointer;
755
+ }
756
+
757
+ .nora-row:hover:not(:disabled) {
758
+ background: var(--nora-pill-soft);
759
+ }
760
+
761
+ .nora-row:disabled {
762
+ opacity: 0.5;
763
+ cursor: default;
764
+ }
765
+
766
+ .nora-row svg {
767
+ flex: none;
768
+ opacity: 0.55;
769
+ }
770
+
771
+ .nora-row-name {
772
+ flex: 1;
773
+ min-width: 0;
774
+ overflow: hidden;
775
+ text-overflow: ellipsis;
776
+ white-space: nowrap;
777
+ }
778
+
779
+ .nora-row-count {
780
+ flex: none;
781
+ font-family: var(--nora-mono);
782
+ font-size: 0.66rem;
783
+ font-variant-numeric: tabular-nums;
784
+ color: var(--nora-pill-dim);
785
+ }
786
+
787
+ .nora-row-primary {
788
+ color: var(--nora-accent);
789
+ }
790
+
791
+ .nora-row-pick {
792
+ flex: none;
793
+ border: none;
794
+ background: var(--nora-pill-soft);
795
+ color: var(--nora-pill-ink);
796
+ font-family: var(--nora-sans);
797
+ font-size: 0.66rem;
798
+ padding: 0.28rem 0.5rem;
799
+ border-radius: 7px;
800
+ cursor: pointer;
801
+ opacity: 0;
802
+ transition: opacity 0.12s ease;
803
+ }
804
+
805
+ .nora-row-wrap:hover .nora-row-pick,
806
+ .nora-row-pick:focus-visible {
807
+ opacity: 1;
808
+ }
809
+
810
+ .nora-row-pick:hover {
811
+ background: var(--nora-accent);
812
+ }
813
+
814
+ .nora-panel-empty,
815
+ .nora-panel-error {
816
+ padding: 0.8rem 0.6rem;
817
+ font-size: 0.76rem;
818
+ color: var(--nora-pill-dim);
819
+ text-align: center;
820
+ line-height: 1.5;
821
+ }
822
+
823
+ .nora-panel-error {
824
+ color: var(--nora-flag);
825
+ text-align: left;
826
+ }
827
+
828
+ .nora-panel-close {
829
+ border: none;
830
+ background: none;
831
+ color: var(--nora-pill-dim);
832
+ font-family: var(--nora-sans);
833
+ font-size: 0.68rem;
834
+ padding: 0.4rem;
835
+ cursor: pointer;
836
+ border-top: 1px solid var(--nora-pill-line);
837
+ /* Same balance as .nora-sweep-actions: the button's own 0.4rem sits below the
838
+ rule, so the space above it has to match that, not the old 0.15rem. */
839
+ margin-top: 0.4rem;
840
+ }
841
+
842
+ .nora-panel-close:hover {
843
+ color: var(--nora-pill-ink);
844
+ }
845
+
846
+ /* ---------- palette ---------- */
847
+
848
+ .nora-palette-input {
849
+ width: 100%;
850
+ border: none;
851
+ outline: none;
852
+ background: var(--nora-pill-soft);
853
+ color: var(--nora-pill-ink);
854
+ font-family: var(--nora-sans);
855
+ font-size: 0.82rem;
856
+ letter-spacing: -0.005em;
857
+ padding: 0.55rem 0.7rem;
858
+ border-radius: 11px;
859
+ margin-bottom: 0.25rem;
860
+ }
861
+
862
+ .nora-palette-input::placeholder {
863
+ color: var(--nora-pill-dim);
864
+ }
865
+
866
+ .nora-palette-row {
867
+ display: flex;
868
+ align-items: baseline;
869
+ gap: 0.55rem;
870
+ width: 100%;
871
+ padding: 0.45rem 0.6rem;
872
+ border: none;
873
+ border-radius: 9px;
874
+ background: none;
875
+ color: var(--nora-pill-ink);
876
+ font-family: var(--nora-sans);
877
+ font-size: 0.8rem;
878
+ letter-spacing: -0.005em;
879
+ text-align: left;
880
+ cursor: pointer;
881
+ }
882
+
883
+ .nora-palette-row.is-active {
884
+ background: var(--nora-pill-soft);
885
+ }
886
+
887
+ .nora-palette-row.is-selected .nora-palette-name {
888
+ color: var(--nora-accent);
889
+ }
890
+
891
+ .nora-palette-row.is-unsupported {
892
+ opacity: 0.55;
893
+ }
894
+
895
+ .nora-palette-name {
896
+ flex: none;
897
+ font-weight: 500;
898
+ max-width: 55%;
899
+ overflow: hidden;
900
+ text-overflow: ellipsis;
901
+ white-space: nowrap;
902
+ }
903
+
904
+ /* A file path is a path: the mono face is doing real work here, not styling. */
905
+ .nora-palette-path {
906
+ flex: 1;
907
+ min-width: 0;
908
+ font-family: var(--nora-mono);
909
+ font-size: 0.65rem;
910
+ color: var(--nora-pill-dim);
911
+ overflow: hidden;
912
+ text-overflow: ellipsis;
913
+ white-space: nowrap;
914
+ text-align: right;
915
+ }
916
+
917
+ /* ---------- picker: folders sitting alongside components ---------- */
918
+
919
+ /* Where you are. The list holds two kinds of thing and the folder rows are all
920
+ relative to this path, so it has to be on screen — but it is context rather
921
+ than a result, so it sits between the input and the list instead of in it. */
922
+ .nora-picker-where {
923
+ display: flex;
924
+ align-items: baseline;
925
+ justify-content: space-between;
926
+ gap: 0.5rem;
927
+ padding: 0.35rem 0.6rem 0.2rem;
928
+ font-family: var(--nora-mono);
929
+ font-size: 0.6rem;
930
+ letter-spacing: 0.04em;
931
+ color: var(--nora-pill-dim);
932
+ }
933
+
934
+ .nora-picker-path {
935
+ min-width: 0;
936
+ overflow: hidden;
937
+ text-overflow: ellipsis;
938
+ white-space: nowrap;
939
+ }
940
+
941
+ .nora-picker-pending {
942
+ flex: none;
943
+ color: var(--nora-accent);
944
+ }
945
+
946
+ /* The rule between what is *in* this folder and what is *next to* it. At this
947
+ row density a plain gap reads as a rendering accident rather than a divide. */
948
+ .nora-picker-break {
949
+ margin-top: 0.3rem;
950
+ padding-top: 0.3rem;
951
+ border-top: 1px solid var(--nora-pill-line);
952
+ }
953
+
954
+ /* Folder rows carry an icon, so they centre rather than sitting on the baseline
955
+ that the component rows share with their file names. */
956
+ .nora-picker-dir {
957
+ align-items: center;
958
+ gap: 0.5rem;
959
+ }
960
+
961
+ .nora-picker-dir svg {
962
+ flex: none;
963
+ color: var(--nora-pill-dim);
964
+ }
965
+
966
+ /* A folder name has no file path beside it to yield room to, so unlike a
967
+ component name it takes the whole row and pushes the count to the edge. */
968
+ .nora-picker-dir .nora-palette-name {
969
+ flex: 1;
970
+ max-width: none;
971
+ }
972
+
973
+ .nora-picker-dir:hover:not(:disabled) {
974
+ background: var(--nora-pill-soft);
975
+ }
976
+
977
+ .nora-picker-dir:disabled {
978
+ opacity: 0.5;
979
+ cursor: default;
980
+ }
981
+
982
+ /* The one row that lands you somewhere when you have browsed away from the
983
+ scanned folder, so it carries the accent the way a primary action should. */
984
+ .nora-picker-open .nora-palette-name,
985
+ .nora-picker-open svg {
986
+ color: var(--nora-accent);
987
+ }
988
+
989
+ .nora-picker-count {
990
+ flex: none;
991
+ margin-left: auto;
992
+ font-family: var(--nora-mono);
993
+ font-size: 0.62rem;
994
+ color: var(--nora-pill-dim);
995
+ font-variant-numeric: tabular-nums;
996
+ }
997
+
998
+ .nora-palette-foot {
999
+ display: flex;
1000
+ flex-wrap: wrap;
1001
+ row-gap: 0.3rem;
1002
+ gap: 0.9rem;
1003
+ padding: 0.5rem 0.6rem 0.25rem;
1004
+ border-top: 1px solid var(--nora-pill-line);
1005
+ margin-top: 0.2rem;
1006
+ font-size: 0.64rem;
1007
+ color: var(--nora-pill-dim);
1008
+ }
1009
+
1010
+ .nora-palette-foot kbd {
1011
+ font-family: var(--nora-mono);
1012
+ background: var(--nora-pill-soft);
1013
+ border-radius: 4px;
1014
+ padding: 0.05rem 0.28rem;
1015
+ margin-right: 0.15rem;
1016
+ }
1017
+
1018
+ @media (prefers-reduced-motion: reduce) {
1019
+ .nora-pill,
1020
+ .nora-pill-face,
1021
+ .nora-pill-controls,
1022
+ .nora-panel,
1023
+ .nora-palette,
1024
+ .nora-app {
1025
+ transition-duration: 0.01ms !important;
1026
+ animation-duration: 0.01ms !important;
1027
+ }
1028
+ }
1029
+
1030
+ /* ==========================================================================
1031
+ Sweep — a popover on the bar, sharing the .nora-panel surface
1032
+ ========================================================================== */
1033
+
1034
+ .nora-sweep-btn {
1035
+ position: relative;
1036
+ overflow: hidden;
1037
+ }
1038
+
1039
+ .nora-sweep-btn.is-running {
1040
+ background: var(--nora-accent);
1041
+ }
1042
+
1043
+ .nora-sweep-panel {
1044
+ gap: 0.1rem;
1045
+ }
1046
+
1047
+ .nora-sweep-summary {
1048
+ display: flex;
1049
+ gap: 0.6rem;
1050
+ font-family: var(--nora-mono);
1051
+ font-size: 0.65rem;
1052
+ margin-left: auto;
1053
+ }
1054
+
1055
+ .nora-sweep-bad {
1056
+ color: var(--nora-flag);
1057
+ font-weight: 600;
1058
+ }
1059
+ .nora-sweep-neutral {
1060
+ color: var(--nora-note);
1061
+ }
1062
+ /* The one line carrying the verdict, previously the same --nora-pill-dim as
1063
+ every other muted label here — so the result read as chrome. */
1064
+ .nora-sweep-ok {
1065
+ display: inline-flex;
1066
+ align-items: center;
1067
+ gap: 0.28rem;
1068
+ color: var(--nora-ok);
1069
+ }
1070
+
1071
+ /* Every width passed, so the whole track says so. This is the same grammar as
1072
+ an overflow band two notches quieter: the sweep already speaks in bands, and
1073
+ a clean run is simply the band that covers the entire range. It also stops
1074
+ the axis reading as an empty widget on the one result that has no findings
1075
+ to plot. */
1076
+ .nora-sweep-axis[data-clean="true"] .nora-sweep-track {
1077
+ background: linear-gradient(
1078
+ 90deg,
1079
+ rgba(142, 211, 169, 0.16),
1080
+ rgba(142, 211, 169, 0.34) 50%,
1081
+ rgba(142, 211, 169, 0.16)
1082
+ );
1083
+ }
1084
+
1085
+ /* The axis is the point: a finding lives at a width, so it belongs on a ruler. */
1086
+ .nora-sweep-axis {
1087
+ position: relative;
1088
+ height: 30px;
1089
+ margin: 0.2rem 0.6rem 0;
1090
+ cursor: crosshair;
1091
+ }
1092
+
1093
+ /* Of those 30px, 21 sit above the track and exist only to hold tick labels. A
1094
+ sweep that found no breakpoints has no labels to put there, so the space
1095
+ became a hole directly beneath the heading. Keep just enough for the track. */
1096
+ .nora-sweep-axis[data-ticks="false"] {
1097
+ height: 21px;
1098
+ }
1099
+
1100
+ .nora-sweep-track {
1101
+ position: absolute;
1102
+ left: 0;
1103
+ right: 0;
1104
+ bottom: 6px;
1105
+ height: 3px;
1106
+ border-radius: 2px;
1107
+ background: rgba(255, 255, 255, 0.12);
1108
+ }
1109
+
1110
+ .nora-sweep-band {
1111
+ position: absolute;
1112
+ bottom: 4px;
1113
+ height: 7px;
1114
+ border-radius: 2px;
1115
+ background: var(--nora-flag-solid);
1116
+ }
1117
+
1118
+ .nora-sweep-tick {
1119
+ position: absolute;
1120
+ bottom: 3px;
1121
+ width: 1px;
1122
+ height: 12px;
1123
+ background: var(--nora-note);
1124
+ }
1125
+
1126
+ .nora-sweep-tick-label {
1127
+ position: absolute;
1128
+ bottom: 13px;
1129
+ left: 50%;
1130
+ transform: translateX(-50%);
1131
+ font-family: var(--nora-mono);
1132
+ font-size: 0.6rem;
1133
+ color: var(--nora-note);
1134
+ font-variant-numeric: tabular-nums;
1135
+ white-space: nowrap;
1136
+ }
1137
+
1138
+ .nora-sweep-cursor {
1139
+ position: absolute;
1140
+ bottom: 1px;
1141
+ width: 2px;
1142
+ height: 16px;
1143
+ background: var(--nora-pill-ink);
1144
+ border-radius: 1px;
1145
+ }
1146
+
1147
+ /* The axis and its end labels are one object, so they sit tight together; the
1148
+ findings are a separate readout and need to look like one. At the panel's
1149
+ 0.1rem gap the first finding read as a third scale label. */
1150
+ .nora-sweep-panel .nora-panel-body {
1151
+ margin-top: 0.45rem;
1152
+ }
1153
+
1154
+ .nora-sweep-scale {
1155
+ display: flex;
1156
+ justify-content: space-between;
1157
+ padding: 0 0.6rem;
1158
+ font-family: var(--nora-mono);
1159
+ font-size: 0.58rem;
1160
+ color: var(--nora-pill-dim);
1161
+ font-variant-numeric: tabular-nums;
1162
+ }
1163
+
1164
+ .nora-finding {
1165
+ display: flex;
1166
+ align-items: baseline;
1167
+ gap: 0.6rem;
1168
+ width: 100%;
1169
+ border: none;
1170
+ background: none;
1171
+ border-radius: 9px;
1172
+ padding: 0.4rem 0.6rem;
1173
+ cursor: pointer;
1174
+ text-align: left;
1175
+ font-family: var(--nora-sans);
1176
+ font-size: 0.76rem;
1177
+ color: var(--nora-pill-ink);
1178
+ }
1179
+
1180
+ .nora-finding:hover {
1181
+ background: var(--nora-pill-soft);
1182
+ }
1183
+
1184
+ .nora-finding-range {
1185
+ flex: none;
1186
+ min-width: 4.8rem;
1187
+ font-family: var(--nora-mono);
1188
+ color: var(--nora-note);
1189
+ font-variant-numeric: tabular-nums;
1190
+ font-size: 0.69rem;
1191
+ }
1192
+
1193
+ .nora-finding.is-bad .nora-finding-range {
1194
+ color: var(--nora-flag);
1195
+ font-weight: 600;
1196
+ }
1197
+
1198
+ .nora-finding-text {
1199
+ flex: 1;
1200
+ min-width: 0;
1201
+ font-size: 0.72rem;
1202
+ color: var(--nora-pill-dim);
1203
+ overflow: hidden;
1204
+ text-overflow: ellipsis;
1205
+ white-space: nowrap;
1206
+ }
1207
+
1208
+ /* Deliberately not .nora-panel-empty. An empty findings list is the *good*
1209
+ outcome here rather than a nothing-to-show state, and the summary in the head
1210
+ now does the announcing, so this line is elaboration and is weighted as such.
1211
+ Shorter, too: the range is already stated twice above it, and at the old
1212
+ length it wrapped and orphaned "1600." onto a line of its own. */
1213
+ .nora-sweep-clear {
1214
+ padding: 0.55rem 0.6rem 0.7rem;
1215
+ font-size: 0.72rem;
1216
+ color: var(--nora-pill-dim);
1217
+ text-align: center;
1218
+ line-height: 1.5;
1219
+ }
1220
+
1221
+ /* Equal air on both sides of the button text, measured to the two edges the
1222
+ eye actually reads: the rule above, the panel's own rounded edge below.
1223
+ Each side is this row's 0.4rem plus the button's own 0.4rem of padding —
1224
+ 6.4 + 6.4 above the glyphs, and 6.4 plus the panel's 0.4rem below them.
1225
+ Add nothing here without taking it off the panel. */
1226
+ .nora-sweep-actions {
1227
+ display: flex;
1228
+ align-items: center;
1229
+ gap: 0.3rem;
1230
+ border-top: 1px solid var(--nora-pill-line);
1231
+ margin-top: 0.5rem;
1232
+ padding-top: 0.4rem;
1233
+ }
1234
+
1235
+ .nora-sweep-rerun,
1236
+ .nora-sweep-copy,
1237
+ .nora-sweep-dismiss {
1238
+ flex: 1;
1239
+ border: none;
1240
+ background: none;
1241
+ color: var(--nora-pill-dim);
1242
+ font-family: var(--nora-sans);
1243
+ font-size: 0.68rem;
1244
+ padding: 0.4rem;
1245
+ cursor: pointer;
1246
+ border-radius: 7px;
1247
+ }
1248
+
1249
+ .nora-sweep-rerun:hover:not(:disabled),
1250
+ .nora-sweep-copy:hover,
1251
+ .nora-sweep-dismiss:hover {
1252
+ color: var(--nora-pill-ink);
1253
+ background: var(--nora-pill-soft);
1254
+ }
1255
+
1256
+ .nora-sweep-rerun:disabled {
1257
+ opacity: 0.5;
1258
+ cursor: default;
1259
+ }
1260
+
1261
+ .nora-sweep-dismiss {
1262
+ border-top: none;
1263
+ margin-top: 0;
1264
+ }