justskill-design 0.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
@@ -0,0 +1,1032 @@
1
+ /*
2
+ * The canvas chrome only.
3
+ *
4
+ * Nothing here reaches a design file. Each page is mounted inside its own shadow
5
+ * root, so a page's own styles cannot restyle this chrome and this chrome cannot
6
+ * restyle a design. That separation is what keeps "what you see" equal to "what
7
+ * you export".
8
+ */
9
+
10
+ :root {
11
+ /*
12
+ * Paper. The chrome sits on the same warm field as the pages, so the page
13
+ * cards read as paper on a table instead of white shapes on a dark stage.
14
+ * Every value here is a measured one: the field is a shade darker than the
15
+ * panels so a panel edge is visible without a border doing all the work, and
16
+ * `--chrome-dim` clears 4.5:1 on `--chrome-panel` (11px labels included).
17
+ */
18
+ --chrome-bg: #f1efec;
19
+ --chrome-field: #f1efec;
20
+ --chrome-dot: #d9d5cc;
21
+ --chrome-panel: #fbfaf8;
22
+ --chrome-pill: #fcfbf9;
23
+ --chrome-input: #ffffff;
24
+ --chrome-track: #f1eee8;
25
+ --chrome-hover: #f1eee8;
26
+ --chrome-selected: #efece5;
27
+ --chrome-line: rgba(20, 19, 15, 0.10);
28
+ --chrome-ink: #14130f;
29
+ --chrome-dim: #6f6a63;
30
+ --chrome-accent: #2f5fd0;
31
+ --chrome-danger: #b4453f;
32
+ --chrome-warn: #9a6b12;
33
+ --chrome-shadow-panel: 0 1px 2px rgba(20, 19, 15, 0.05), 0 16px 36px -18px rgba(20, 19, 15, 0.18);
34
+ --chrome-shadow-pill: 0 1px 2px rgba(20, 19, 15, 0.05), 0 10px 24px -12px rgba(20, 19, 15, 0.16);
35
+ --chrome-shadow-page: 0 1px 2px rgba(20, 19, 15, 0.06), 0 24px 52px -22px rgba(20, 19, 15, 0.28);
36
+ --chrome-shadow-drawer: 0 -1px 2px rgba(20, 19, 15, 0.04), 0 -18px 40px -24px rgba(20, 19, 15, 0.20);
37
+
38
+ /*
39
+ * The three columns, in one place.
40
+ *
41
+ * `#stage` reads these for its tracks, `.drawer` reads them for its left and
42
+ * right edge, and the panel code writes them. A hard-coded `240px` in two
43
+ * rules is how a drawer ends up 240px wide over a panel that is no longer
44
+ * there — which is exactly what the first version of this layout did.
45
+ *
46
+ * `--gutter-*` is the width of the drag handle, and it goes to 0 with the
47
+ * panel: a hidden panel must leave neither a column nor a gap behind.
48
+ */
49
+ --panel-left: 240px;
50
+ --panel-right: 260px;
51
+ --gutter-left: 6px;
52
+ --gutter-right: 6px;
53
+
54
+ color-scheme: light;
55
+ }
56
+
57
+ * { box-sizing: border-box; }
58
+
59
+ html, body {
60
+ margin: 0;
61
+ height: 100%;
62
+ background: var(--chrome-field);
63
+ color: var(--chrome-ink);
64
+ font: 13px/1.5 -apple-system, "SF Pro Text", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
65
+ overflow: hidden;
66
+ }
67
+
68
+ body { display: block; }
69
+
70
+ /* -- top bar ---------------------------------------------------------------- */
71
+
72
+ /*
73
+ * The chrome floats over the field instead of taking a band off the top.
74
+ *
75
+ * `left`/`right` are the panel tracks plus their gutters, so the two pills stay
76
+ * over the canvas when a panel is folded away: a bar pinned to the window would
77
+ * hang over the layers panel at 1600px and over nothing at 900px.
78
+ */
79
+ .bar {
80
+ position: fixed;
81
+ top: 12px;
82
+ left: calc(var(--panel-left) + var(--gutter-left) + 12px + var(--edge-left, 0px));
83
+ right: calc(var(--panel-right) + var(--gutter-right) + 12px + var(--edge-right, 0px));
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ gap: 12px;
88
+ height: 36px;
89
+ padding: 0;
90
+ border: 0;
91
+ background: none;
92
+ z-index: 30;
93
+ pointer-events: none;
94
+ }
95
+
96
+ .bar > * { pointer-events: auto; }
97
+
98
+ .bar-left, .bar-right {
99
+ display: flex;
100
+ flex: none;
101
+ align-items: center;
102
+ gap: 4px;
103
+ height: 36px;
104
+ padding: 0 6px;
105
+ background: var(--chrome-pill);
106
+ border: 1px solid var(--chrome-line);
107
+ border-radius: 999px;
108
+ box-shadow: var(--chrome-shadow-pill);
109
+ white-space: nowrap;
110
+ }
111
+
112
+ /*
113
+ * The bar has five widths, and `applyBarFit()` in app.mjs picks the widest one
114
+ * that fits the canvas. Waiting for a media query would be wrong here: the room
115
+ * the bar has is the window minus both panels, so opening a panel can squeeze it
116
+ * at 1600px while a closed pair is roomy at 900px.
117
+ *
118
+ * The order of what goes is the order of what is least missed: the file name and
119
+ * revision are identity, not action; `Export` duplicates `Export all`; the
120
+ * Problems label keeps its count; and at the narrowest step only the actions
121
+ * themselves survive.
122
+ */
123
+ .bar:is([data-fit="tight"], [data-fit="narrow"], [data-fit="mini"], [data-fit="micro"]) :is(.file, #revision) { display: none; }
124
+
125
+ .bar:is([data-fit="narrow"], [data-fit="mini"], [data-fit="micro"]) :is(.export-sep, #export-page) { display: none; }
126
+
127
+ .bar:is([data-fit="mini"], [data-fit="micro"]) :is(#undo, #redo, .sep, #fit) { display: none; }
128
+
129
+ /* The last step gives up the export action, not the diagnostics: at this width a
130
+ panel has to be closed to get the file out, while a warning count is the one
131
+ thing a canvas this cramped must still show. */
132
+ .bar[data-fit="micro"] #export-all { display: none; }
133
+
134
+ .bar button { height: 26px; padding: 0 9px; border-radius: 999px; }
135
+
136
+ .brand { font-weight: 600; letter-spacing: 0.02em; padding-left: 6px; }
137
+ .file { color: var(--chrome-dim); }
138
+
139
+ .pill {
140
+ padding: 1px 7px;
141
+ border: 1px solid var(--chrome-line);
142
+ border-radius: 999px;
143
+ background: var(--chrome-track);
144
+ color: var(--chrome-dim);
145
+ font-size: 11px;
146
+ }
147
+
148
+ .pill.warn { color: var(--chrome-warn); border-color: var(--chrome-warn); }
149
+
150
+ .zoom { color: var(--chrome-dim); min-width: 44px; text-align: right; }
151
+
152
+ button {
153
+ font: inherit;
154
+ color: var(--chrome-dim);
155
+ background: transparent;
156
+ border: 1px solid transparent;
157
+ border-radius: 7px;
158
+ padding: 3px 9px;
159
+ cursor: pointer;
160
+ }
161
+
162
+ button:hover { background: var(--chrome-hover); color: var(--chrome-ink); }
163
+
164
+ /*
165
+ * One focus ring, everywhere.
166
+ *
167
+ * The ring is ink, two pixels, and it sits outside the control with a two-pixel
168
+ * gap — the same weight as the selection ring, so "this is the thing the
169
+ * keyboard is on" reads the same as "this is the thing you picked". It is only
170
+ * drawn for `:focus-visible`, so a mouse click on a button leaves no ring
171
+ * behind, while a text field still shows it (a field is always "keyboard
172
+ * input", which is what the pseudo-class means).
173
+ *
174
+ * Contexts that cannot afford an outside ring tighten the offset below: a row
175
+ * inside a scrolling tree, a segment inside its track, a row inside the
176
+ * problems list. They keep the two pixels; only the gap changes.
177
+ */
178
+ button:focus-visible,
179
+ input:focus-visible,
180
+ select:focus-visible,
181
+ textarea:focus-visible,
182
+ [tabindex]:focus-visible {
183
+ outline: 2px solid var(--chrome-ink);
184
+ outline-offset: 2px;
185
+ }
186
+ button:disabled { opacity: 0.4; cursor: default; }
187
+
188
+ button.active { border-color: var(--chrome-accent); color: var(--chrome-accent); }
189
+
190
+ .count {
191
+ display: inline-block;
192
+ min-width: 16px;
193
+ padding: 0 4px;
194
+ margin-left: 4px;
195
+ border-radius: 8px;
196
+ background: var(--chrome-track);
197
+ font-size: 11px;
198
+ text-align: center;
199
+ }
200
+
201
+ .count.has-errors { background: var(--chrome-danger); color: #ffffff; }
202
+
203
+ .sep { width: 1px; height: 18px; background: var(--chrome-line); }
204
+
205
+ /* -- layout ----------------------------------------------------------------- */
206
+
207
+ #stage {
208
+ height: 100vh;
209
+ display: grid;
210
+ grid-template-columns:
211
+ var(--panel-left) var(--gutter-left)
212
+ minmax(0, 1fr)
213
+ var(--gutter-right) var(--panel-right);
214
+ min-height: 0;
215
+ }
216
+
217
+ /*
218
+ * Every column is named, not placed.
219
+ *
220
+ * The five children are laid into the five tracks in order — until one of them
221
+ * is `hidden` and drops out of the grid, at which point the rest shuffle one
222
+ * track to the left: the canvas lands in the zero-width panel track and
223
+ * disappears, and the inspector takes the `1fr`. Naming the columns is what
224
+ * makes hiding a panel a change of width instead of a change of layout.
225
+ */
226
+ #panel-left { grid-column: 1; }
227
+ #divider-left { grid-column: 2; }
228
+ .viewport { grid-column: 3; }
229
+ #divider-right { grid-column: 4; }
230
+ #panel-right { grid-column: 5; }
231
+
232
+ /*
233
+ * Both panels are cards on the field.
234
+ *
235
+ * A 12px margin leaves the field visible around each one, so the paper language
236
+ * reaches the chrome without either panel taking canvas away: the columns still
237
+ * size the grid, and the card is what the margin draws inside them.
238
+ */
239
+ .panel {
240
+ background: var(--chrome-panel);
241
+ display: flex;
242
+ flex-direction: column;
243
+ min-height: 0;
244
+ overflow: hidden;
245
+ }
246
+
247
+ .panel.left {
248
+ margin: 12px;
249
+ border: 1px solid var(--chrome-line);
250
+ border-radius: 14px;
251
+ box-shadow: var(--chrome-shadow-panel);
252
+ }
253
+
254
+ .panel.right {
255
+ margin: 12px;
256
+ border: 1px solid var(--chrome-line);
257
+ border-radius: 14px;
258
+ box-shadow: var(--chrome-shadow-panel);
259
+ }
260
+
261
+ /*
262
+ * A `hidden` panel is taken off screen by the one `[hidden]` rule at the foot of
263
+ * this sheet.
264
+ *
265
+ * It was spelled out here first, per class — `.panel[hidden]`, `.divider[hidden]`,
266
+ * `.viewport-toggle[hidden]` — and that list is how the Problems drawer stayed on
267
+ * screen: the drawer is given a `display` too, and it was written before the list
268
+ * was. One rule at the end cannot go stale that way.
269
+ */
270
+
271
+ .panel-head {
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: space-between;
275
+ gap: 8px;
276
+ padding: 6px 8px 6px 10px;
277
+ border-bottom: 1px solid var(--chrome-line);
278
+ color: var(--chrome-dim);
279
+ font-size: 11px;
280
+ text-transform: uppercase;
281
+ letter-spacing: 0.06em;
282
+ flex: none;
283
+ }
284
+
285
+ /*
286
+ * The two buttons in the Layers header: expand all, collapse all.
287
+ *
288
+ * Icons rather than words, because the header is 240px wide and
289
+ * `text-transform: uppercase` turns any label into a shout that does not fit
290
+ * beside "LAYERS". The tooltip and the accessible name carry the meaning.
291
+ */
292
+ .head-actions { display: flex; align-items: center; gap: 2px; }
293
+
294
+ /* The fold control and the panel's name share the window edge. */
295
+ .head-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
296
+ .head-title .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
297
+
298
+ /* The name in the right header is the only child that may shrink; the toggle
299
+ beside it must keep its 20px. */
300
+ #selection-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
301
+
302
+ .head-button {
303
+ display: flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ width: 20px;
307
+ height: 20px;
308
+ padding: 0;
309
+ background: none;
310
+ border: 0;
311
+ border-radius: 4px;
312
+ color: var(--chrome-dim);
313
+ }
314
+
315
+ .head-button:hover { color: var(--chrome-ink); background: var(--chrome-hover); }
316
+ .head-button:focus-visible,
317
+ .edge-toggle:focus-visible,
318
+ .bar button:focus-visible { outline-offset: 1px; }
319
+ .head-button svg { display: block; }
320
+
321
+ .panel-scroll { flex: 1; min-height: 0; overflow: auto; }
322
+
323
+ .panel-body { padding: 10px; }
324
+
325
+ /* -- panel divider ---------------------------------------------------------- */
326
+
327
+ /*
328
+ * A 6px handle with a 1px hairline down its middle.
329
+ *
330
+ * The hairline is a background layer, not a border: a border would take a pixel
331
+ * off the grab area and the line would jump a pixel sideways when the pointer
332
+ * landed on it. `touch-action: none` is what lets a finger drag the edge
333
+ * instead of scrolling the panel under it.
334
+ */
335
+ .divider {
336
+ position: relative;
337
+ width: 100%;
338
+ cursor: col-resize;
339
+ background: transparent;
340
+ touch-action: none;
341
+ }
342
+
343
+ .divider:hover, .divider.dragging { background: var(--chrome-line) center / 2px 100% no-repeat; }
344
+
345
+ .divider:focus-visible { outline: 2px solid var(--chrome-ink); outline-offset: -2px; }
346
+
347
+ /* -- panel toggles ---------------------------------------------------------- */
348
+
349
+ /*
350
+ * The tab a folded-away panel leaves behind.
351
+ *
352
+ * It exists only while its panel is closed (`hidden` is toggled with the panel),
353
+ * so the canvas never carries a control that duplicates one in a header.
354
+ */
355
+ /*
356
+ * The tabs sit on the bar's row, not under it: a folded panel's control is
357
+ * chrome, and chrome lives at the top. `--edge-left`/`--edge-right` (written by
358
+ * `applyPanels`) hold the space open so the pills never sit on top of a tab.
359
+ */
360
+ .edge-toggle {
361
+ position: absolute;
362
+ top: 16px;
363
+ z-index: 6;
364
+ display: flex;
365
+ align-items: center;
366
+ justify-content: center;
367
+ width: 28px;
368
+ height: 28px;
369
+ padding: 0;
370
+ border-radius: 8px;
371
+ background: var(--chrome-pill);
372
+ border: 1px solid var(--chrome-line);
373
+ box-shadow: var(--chrome-shadow-pill);
374
+ color: var(--chrome-dim);
375
+ }
376
+
377
+ .edge-toggle.left { left: 12px; }
378
+ .edge-toggle.right { right: 12px; }
379
+
380
+ .edge-toggle:hover { color: var(--chrome-ink); background: var(--chrome-panel); }
381
+
382
+
383
+ .edge-toggle svg { display: block; }
384
+
385
+ /* -- the layer tree --------------------------------------------------------- */
386
+
387
+ /*
388
+ * One tree, pages at its root. The nesting is real: a `<ul>` inside the `<li>`
389
+ * is the child list, so the indent is the browser's, not `depth * 12px` on a
390
+ * flat list. A flat list can be indented to look nested; it cannot be collapsed,
391
+ * and collapse is the whole reason this panel exists.
392
+ */
393
+ .tree, .tree-children {
394
+ margin: 0;
395
+ padding: 0;
396
+ list-style: none;
397
+ }
398
+
399
+ .tree-children { padding-left: 13px; }
400
+
401
+ .tree-item { min-width: 0; }
402
+
403
+ .tree-row {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: 4px;
407
+ padding: 3px 8px 3px 4px;
408
+ cursor: default;
409
+ white-space: nowrap;
410
+ overflow: hidden;
411
+ }
412
+
413
+ .tree-row { border-radius: 6px; margin: 1px 6px; padding-left: 4px; }
414
+
415
+ .tree-row:hover { background: var(--chrome-hover); }
416
+
417
+ .tree-item[aria-selected="true"] > .tree-row {
418
+ background: var(--chrome-selected);
419
+ box-shadow: inset 2px 0 0 var(--chrome-ink);
420
+ }
421
+
422
+ .tree-item:focus-visible { outline: none; }
423
+ .tree-item:focus-visible > .tree-row { outline: 2px solid var(--chrome-ink); outline-offset: -2px; }
424
+
425
+ /*
426
+ * The chevron is drawn, not typed. A glyph would inherit whatever the system
427
+ * font does with `▸`, and it would have to be rewritten on every expand; a
428
+ * rotated triangle is one rule keyed off `aria-expanded`, the attribute that
429
+ * already has to be right for the keyboard and the screen reader.
430
+ */
431
+ .chevron {
432
+ flex: none;
433
+ width: 14px;
434
+ height: 14px;
435
+ display: flex;
436
+ align-items: center;
437
+ justify-content: center;
438
+ color: var(--chrome-dim);
439
+ border-radius: 3px;
440
+ }
441
+
442
+ .chevron::before {
443
+ content: "";
444
+ width: 0;
445
+ height: 0;
446
+ border-left: 4px solid currentColor;
447
+ border-top: 3.5px solid transparent;
448
+ border-bottom: 3.5px solid transparent;
449
+ transform: rotate(0deg);
450
+ transition: transform 90ms linear;
451
+ }
452
+
453
+ .tree-item[aria-expanded="true"] > .tree-row > .chevron::before { transform: rotate(90deg); }
454
+
455
+ .chevron.leaf::before { display: none; }
456
+ .chevron:hover { color: var(--chrome-ink); background: var(--chrome-hover); }
457
+
458
+ .tree-label { overflow: hidden; text-overflow: ellipsis; }
459
+
460
+ .tree-type {
461
+ flex: none;
462
+ color: var(--chrome-dim);
463
+ font-size: 11px;
464
+ }
465
+
466
+ /* A hidden element keeps its row, greyed: it is still in the file, and a layer
467
+ list that drops it would make the next edit a guess. */
468
+ .tree-item[data-hidden="true"] > .tree-row > .tree-label { color: var(--chrome-dim); text-decoration: line-through; }
469
+
470
+ .tree-item .badge {
471
+ flex: none;
472
+ margin-left: auto;
473
+ padding-left: 6px;
474
+ font-size: 10px;
475
+ color: var(--chrome-dim);
476
+ }
477
+
478
+ .tree-empty { padding: 8px 10px; color: var(--chrome-dim); }
479
+
480
+ /* -- viewport --------------------------------------------------------------- */
481
+
482
+ /*
483
+ * The field: warm paper with one dot, repeated.
484
+ *
485
+ * The pitch and the offset are written by `applyCamera` in screen pixels: the
486
+ * pitch is clamped to 14–44px so the grid stays a texture at every zoom instead
487
+ * of becoming noise at 10% or an empty page at 400%, and the offset follows the
488
+ * camera so panning moves the paper. `background-size` here is only the first
489
+ * paint, before the first camera write. Below 30% the dots are taken away and
490
+ * the field keeps its colour.
491
+ */
492
+ .viewport {
493
+ position: relative;
494
+ overflow: hidden;
495
+ background-color: var(--chrome-field);
496
+ background-image: radial-gradient(circle, var(--chrome-dot) 1px, transparent 1.7px);
497
+ background-size: 26px 26px;
498
+ cursor: default;
499
+ min-width: 0;
500
+ }
501
+
502
+ .viewport[data-zoom="far"] { background-image: none; }
503
+
504
+ /*
505
+ * While the canvas is being dragged, nothing on it is text to select.
506
+ *
507
+ * `user-select: none` on the viewport is inherited into the pages' shadow roots,
508
+ * so it covers the design's own text as well as the chrome — which is what stops
509
+ * a pan from leaving a blue smear over every string in the file.
510
+ */
511
+ .viewport.panning { cursor: grabbing; user-select: none; }
512
+
513
+ /*
514
+ * A geometry drag is not a text gesture.
515
+ *
516
+ * `user-select: none` on the viewport reaches the chrome, and the page hosts
517
+ * resolve their own `auto` against it, so the design's text stops being
518
+ * selectable while an element is being dragged. It is scoped to the drag: with
519
+ * the class off, selecting a string inside a page works as before.
520
+ */
521
+ .viewport.moving, .viewport.moving .page-host { user-select: none; }
522
+ .viewport.move-mode { cursor: move; }
523
+
524
+ .canvas {
525
+ position: absolute;
526
+ inset: 0;
527
+ transform-origin: 0 0;
528
+ /*
529
+ * No `will-change: transform` here, on purpose.
530
+ *
531
+ * This element holds every page in the file and it is the thing the camera
532
+ * scales. `will-change` asks the engine to keep it as one composited layer
533
+ * with its own texture, and a texture has a size limit: past it the layer is
534
+ * rasterized once at a low scale and the compositor stretches the result. The
535
+ * text goes soft and nothing in the document says why.
536
+ *
537
+ * The transform changes when the user zooms or pans, not every frame, so there
538
+ * is nothing to keep the layer warm for.
539
+ */
540
+ }
541
+
542
+ .page-box {
543
+ position: absolute;
544
+ border-radius: 14px;
545
+ background: #ffffff;
546
+ border: 1px solid var(--chrome-line);
547
+ box-shadow: var(--chrome-shadow-page);
548
+ }
549
+
550
+ .page-box.selected { outline: 2px solid var(--chrome-ink); outline-offset: 2px; }
551
+
552
+ /*
553
+ * A page's name is chrome, so it does not zoom with the drawing.
554
+ *
555
+ * The label sits inside the page box, and the page box is inside the element the
556
+ * camera scales — so the name used to be a 1.9px smudge at 10% and a headline at
557
+ * 400%, and a name is only a name when it can be read. `--chrome-unzoom` is the
558
+ * reciprocal of the zoom, written by `applyCamera`; scaling by it gives the pill
559
+ * its own size in screen pixels at every step of the ladder.
560
+ *
561
+ * The bottom edge is the anchor, because the lift has to be a screen distance
562
+ * too. Pinned by its top — the `top: -22px` this used to be — the pill's 3.5px
563
+ * of air scaled with everything else: 14px over the page it names at 400%, and
564
+ * under a pixel at 25%, where the label sits as good as flat on the design.
565
+ * `bottom: 100%` moves the anchor onto the page's top edge, and translating by
566
+ * the same reciprocal leaves the same 4px of air at every zoom. At 100% that is
567
+ * where the old offset landed, to within the half pixel the pill's own height
568
+ * rounds by.
569
+ *
570
+ * Undone by a transform, not by a second element or a per-zoom class: there is
571
+ * one label per page, in one place, and its hit box is the pill you can see — as
572
+ * draggable at 10% as at 100%.
573
+ */
574
+ .page-label {
575
+ position: absolute;
576
+ left: 0;
577
+ bottom: 100%;
578
+ padding: 1px 7px;
579
+ border-radius: 999px;
580
+ background: var(--chrome-ink);
581
+ color: #fbfaf8;
582
+ font-size: 10.5px;
583
+ letter-spacing: 0.02em;
584
+ white-space: nowrap;
585
+ cursor: move;
586
+ user-select: none;
587
+ --label-gap: 4px;
588
+ transform-origin: left bottom;
589
+ transform:
590
+ translateY(calc(var(--label-gap) * var(--chrome-unzoom, 1) * -1))
591
+ scale(var(--chrome-unzoom, 1));
592
+ }
593
+
594
+ .page-box.selected .page-label { color: #ffffff; background: #2a2823; }
595
+
596
+ .page-box.dragging { opacity: 0.85; }
597
+
598
+ /* -- selection overlay ------------------------------------------------------ */
599
+
600
+ .overlay { position: absolute; inset: 0; pointer-events: none; }
601
+
602
+ /*
603
+ * A box is a line *around* the element, and nothing else.
604
+ *
605
+ * Three decisions, all of them about not covering the thing being looked at:
606
+ *
607
+ * `outline`, not `border`. An outline is painted outside the border box, so
608
+ * the weight of the line costs the design nothing — a border eats the first
609
+ * pixels of every edge, and on a 3px-wide element at 11% zoom that is the whole
610
+ * element. Because the line is free, its weight can be chosen for legibility
611
+ * alone: two pixels, drawn in viewport pixels rather than the design's, so it
612
+ * is the same at 11% and at 400%. One pixel was a hairline against a dark page
613
+ * or a busy photograph.
614
+ * No fill. The selection used to be washed with 10% blue, which on a full-page
615
+ * frame is a veil over the entire artboard: you cannot judge a colour, a
616
+ * gradient or a photograph through it.
617
+ * Weight by kind, colour by commitment. Two pixels means "a level", one means
618
+ * "a child of one". *Which* level — the committed one or the one the pointer is
619
+ * standing on — is said by the colour and by the four handles, which only the
620
+ * selection has. Encoding that in weight instead left the hover box thinner
621
+ * than the children it was framing, which is backwards: the container is the
622
+ * frame of the decision, not a cursor.
623
+ */
624
+ /*
625
+ * The selection is ink, with a white halo underneath it.
626
+ *
627
+ * The field is light, the pages are white and many designs put a photograph
628
+ * under the box — a dark line alone disappears on a dark image, a light line
629
+ * alone disappears on paper. The halo is one pixel of white so the ring reads on
630
+ * any of the three.
631
+ */
632
+ .outline {
633
+ position: absolute;
634
+ outline: 2px solid var(--chrome-ink);
635
+ outline-offset: 1px;
636
+ background: none;
637
+ pointer-events: none;
638
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.95);
639
+ }
640
+
641
+ /*
642
+ * The level the pointer is standing on: the same weight as the selection, a
643
+ * lighter blue, and no handles.
644
+ */
645
+ .outline.level { outline-color: rgba(20, 19, 15, 0.55); box-shadow: none; }
646
+
647
+ /*
648
+ * Its direct children: what a double-click steps into, one rung down.
649
+ *
650
+ * One pixel, and spelled out rather than inherited from `.outline`, which is a
651
+ * rule the class shares — the width was inherited once, and thickening the
652
+ * selection thickened every dashed box with it.
653
+ *
654
+ * One, not two, because a stack of rows with no gaps between them is the case
655
+ * this has to survive: thirteen rows in a card, each with a two pixel dashed box,
656
+ * is 26 pixels of dash across a 148 pixel card at 29% zoom — a mesh rather than
657
+ * thirteen boxes. Fading the colour instead only makes the mesh paler, which is
658
+ * why the width is what moved.
659
+ */
660
+ .outline.child {
661
+ outline-style: dashed;
662
+ outline-width: 1px;
663
+ outline-color: rgba(20, 19, 15, 0.45);
664
+ box-shadow: none;
665
+ }
666
+
667
+ /*
668
+ * The one the pointer is actually over — the box a click would take.
669
+ *
670
+ * It cannot be told apart by a wash any more, so it is told apart by the line:
671
+ * two pixels against its siblings' one, and brighter. It still reads as a dashed
672
+ * box, which is what it is — the child you are about to step into.
673
+ */
674
+ .outline.child.under {
675
+ outline-width: 2px;
676
+ outline-color: rgba(20, 19, 15, 0.75);
677
+ }
678
+
679
+ /* A box too small to have usable corners: the ring stays, the handles go. */
680
+ .outline.small .handle { display: none; }
681
+
682
+ .handle {
683
+ position: absolute;
684
+ width: 9px;
685
+ height: 9px;
686
+ margin: -4.5px 0 0 -4.5px;
687
+ background: #ffffff;
688
+ border: 1.5px solid var(--chrome-ink);
689
+ border-radius: 50%;
690
+ pointer-events: auto;
691
+ }
692
+
693
+ /* -- inspector -------------------------------------------------------------- */
694
+
695
+ .field { margin-bottom: 8px; min-width: 0; }
696
+
697
+ .field label {
698
+ display: block;
699
+ color: var(--chrome-dim);
700
+ font-size: 11px;
701
+ margin-bottom: 2px;
702
+ overflow: hidden;
703
+ text-overflow: ellipsis;
704
+ white-space: nowrap;
705
+ }
706
+
707
+ .field input[type="text"], .field input[type="number"], .field select, .field textarea {
708
+ width: 100%;
709
+ font: inherit;
710
+ font-variant-numeric: tabular-nums;
711
+ color: var(--chrome-ink);
712
+ background: var(--chrome-input);
713
+ border: 1px solid var(--chrome-line);
714
+ border-radius: 5px;
715
+ padding: 4px 6px;
716
+ }
717
+
718
+ .field input[type="color"] {
719
+ width: 100%;
720
+ height: 26px;
721
+ padding: 0;
722
+ background: var(--chrome-input);
723
+ border: 1px solid var(--chrome-line);
724
+ border-radius: 5px;
725
+ }
726
+
727
+ /*
728
+ * These sit after the base field rules on purpose: `border` above is a shorthand,
729
+ * and a `border-color` written before it loses to it however specific the
730
+ * selector is. The ring still shows without this, but the field should also
731
+ * change its edge — a screenshot of two rings at 11% is not enough to tell which
732
+ * one has the keyboard.
733
+ */
734
+ .field input:hover:not(:disabled), .field select:hover:not(:disabled), .field textarea:hover:not(:disabled) {
735
+ border-color: rgba(20, 19, 15, 0.22);
736
+ }
737
+
738
+ .field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible,
739
+ .drawer-fix textarea:focus-visible {
740
+ outline-offset: 1px;
741
+ border-color: var(--chrome-ink);
742
+ }
743
+
744
+ /*
745
+ * One row, two columns — used when a section has a pair of properties.
746
+ *
747
+ * `1fr 1fr` and a single child is a child in the first column, which is what an
748
+ * unpaired property wants: half a row would be a misleading amount of space for
749
+ * a value that has no partner.
750
+ */
751
+ .field-grid {
752
+ display: grid;
753
+ grid-template-columns: 1fr 1fr;
754
+ gap: 0 6px;
755
+ min-width: 0;
756
+ }
757
+
758
+ /*
759
+ * A row with one property, not a pair.
760
+ *
761
+ * The two-column grid is for width/height, left/top and the other pairs that are
762
+ * read together. A property without a partner took half of that grid and left
763
+ * the other half empty, which is how `padding:26px 26px 20px 26px` came to be
764
+ * cut off at "20p" in a panel with room for it. One property, one full row.
765
+ */
766
+ .field-grid.single { grid-template-columns: 1fr; }
767
+
768
+ /* -- inspector sections ----------------------------------------------------- */
769
+
770
+ .section { margin-bottom: 4px; border-top: 1px solid var(--chrome-line); }
771
+
772
+ .section-head {
773
+ display: flex;
774
+ align-items: center;
775
+ width: 100%;
776
+ padding: 7px 2px;
777
+ border: 0;
778
+ border-radius: 0;
779
+ background: none;
780
+ color: var(--chrome-dim);
781
+ font-size: 11px;
782
+ text-transform: uppercase;
783
+ letter-spacing: 0.06em;
784
+ text-align: left;
785
+ cursor: pointer;
786
+ }
787
+
788
+ .section-head:hover { color: var(--chrome-ink); background: none; }
789
+ .section-head:focus-visible { outline-offset: -2px; }
790
+
791
+ /* A triangle that turns: the fold is visible before it is used. */
792
+ .section-head::before {
793
+ content: "";
794
+ width: 0;
795
+ height: 0;
796
+ margin-right: 6px;
797
+ border-left: 4px solid currentColor;
798
+ border-top: 3.5px solid transparent;
799
+ border-bottom: 3.5px solid transparent;
800
+ transform: rotate(90deg);
801
+ transition: transform 90ms linear;
802
+ }
803
+
804
+ .section.folded .section-head::before { transform: rotate(0deg); }
805
+
806
+ .section-body { padding-bottom: 6px; }
807
+ .section.folded .section-body { display: none; }
808
+
809
+ /* -- segmented control ------------------------------------------------------ */
810
+
811
+ /*
812
+ * Two or three short alternatives as buttons. `justify-content` has five values
813
+ * and gets a dropdown instead; the rule lives in `canvas/groups.mjs`.
814
+ */
815
+ .seg {
816
+ display: flex;
817
+ gap: 2px;
818
+ padding: 2px;
819
+ background: var(--chrome-track);
820
+ border: 1px solid var(--chrome-line);
821
+ border-radius: 6px;
822
+ }
823
+
824
+ .seg-option {
825
+ flex: 1;
826
+ min-width: 0;
827
+ padding: 3px 2px;
828
+ border: 0;
829
+ border-radius: 4px;
830
+ background: none;
831
+ color: var(--chrome-dim);
832
+ font-size: 11px;
833
+ overflow: hidden;
834
+ text-overflow: ellipsis;
835
+ white-space: nowrap;
836
+ }
837
+
838
+ .seg-option:hover { background: var(--chrome-hover); }
839
+ .seg-option:focus-visible { outline-offset: -2px; }
840
+ .seg-option.active {
841
+ background: var(--chrome-input);
842
+ color: var(--chrome-ink);
843
+ box-shadow: 0 1px 2px rgba(20, 19, 15, 0.10);
844
+ }
845
+
846
+ .scrub { cursor: ew-resize; }
847
+
848
+ .value-line {
849
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
850
+ font-size: 11px;
851
+ color: var(--chrome-dim);
852
+ word-break: break-all;
853
+ }
854
+
855
+ .empty { color: var(--chrome-dim); }
856
+ .muted { color: var(--chrome-dim); }
857
+
858
+ dl.attrs { margin: 0 0 6px; }
859
+ dl.attrs dt { color: var(--chrome-dim); font-size: 11px; }
860
+ dl.attrs dd { margin: 0 0 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; word-break: break-all; }
861
+
862
+ /* -- problems drawer -------------------------------------------------------- */
863
+
864
+ /*
865
+ * The problems sheet.
866
+ *
867
+ * A card on the same field as the panels, 12px in from the canvas edges: closing
868
+ * a panel widens it instead of leaving it over a track that is no longer there.
869
+ * Inside it is the same paper language — an eyebrow with a count, monospaced
870
+ * codes, a severity chip, and the hint under the message rather than beside it,
871
+ * because a hint is a sentence and a sentence reads down, not across.
872
+ */
873
+ .drawer {
874
+ position: absolute;
875
+ left: calc(var(--panel-left) + var(--gutter-left) + 12px);
876
+ right: calc(var(--panel-right) + var(--gutter-right) + 12px);
877
+ bottom: 12px;
878
+ max-height: 42%;
879
+ display: flex;
880
+ flex-direction: column;
881
+ overflow: hidden;
882
+ background: var(--chrome-panel);
883
+ border: 1px solid var(--chrome-line);
884
+ border-radius: 14px;
885
+ box-shadow: var(--chrome-shadow-panel);
886
+ z-index: 20;
887
+ }
888
+
889
+ .drawer-head {
890
+ display: flex;
891
+ align-items: center;
892
+ justify-content: space-between;
893
+ gap: 12px;
894
+ flex: none;
895
+ padding: 10px 14px;
896
+ border-bottom: 1px solid var(--chrome-line);
897
+ }
898
+
899
+ .drawer-title { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
900
+
901
+ .drawer-summary {
902
+ font-size: 11px;
903
+ color: var(--chrome-dim);
904
+ font-variant-numeric: tabular-nums;
905
+ white-space: nowrap;
906
+ }
907
+
908
+ .drawer-actions { display: flex; align-items: center; gap: 4px; }
909
+ .drawer-actions button { height: 26px; padding: 0 10px; border-radius: 999px; }
910
+ .drawer-close { background: var(--chrome-track); }
911
+
912
+ .drawer-body { overflow: auto; padding: 4px 6px 6px; min-height: 0; }
913
+
914
+ .drawer-fix {
915
+ display: flex;
916
+ flex-direction: column;
917
+ gap: 8px;
918
+ padding: 12px;
919
+ border-top: 1px solid var(--chrome-line);
920
+ }
921
+
922
+ .drawer-hint { margin: 0; font-size: 11px; color: var(--chrome-dim); }
923
+
924
+ /* The whole file, monospaced, because that is what it is. `wrap="off"` on the element
925
+ keeps a long line from folding, which would make an offset impossible to find. */
926
+ .drawer-fix textarea {
927
+ width: 100%;
928
+ height: 32vh;
929
+ resize: vertical;
930
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
931
+ background: var(--chrome-input);
932
+ color: var(--chrome-ink);
933
+ border: 1px solid var(--chrome-line);
934
+ border-radius: 6px;
935
+ padding: 6px 8px;
936
+ white-space: pre;
937
+ overflow: auto;
938
+ }
939
+
940
+ .issue {
941
+ display: grid;
942
+ grid-template-columns: 74px minmax(0, 1fr);
943
+ gap: 10px;
944
+ align-items: start;
945
+ padding: 10px 12px;
946
+ border-radius: 10px;
947
+ border-bottom: 1px solid var(--chrome-line);
948
+ }
949
+
950
+ .issue:last-child { border-bottom: 0; }
951
+ .issue[tabindex] { cursor: pointer; }
952
+ .issue[tabindex]:hover { background: var(--chrome-hover); }
953
+ .issue:focus-visible { outline-offset: -2px; }
954
+
955
+ .issue .sev {
956
+ justify-self: start;
957
+ padding: 2px 7px;
958
+ border: 1px solid currentColor;
959
+ border-radius: 999px;
960
+ font-size: 9.5px;
961
+ letter-spacing: 0.08em;
962
+ text-transform: uppercase;
963
+ }
964
+ .issue .sev.error { color: var(--chrome-danger); }
965
+ .issue .sev.warning { color: var(--chrome-warn); }
966
+
967
+ .issue-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
968
+ .issue .code {
969
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
970
+ font-size: 10.5px;
971
+ color: var(--chrome-dim);
972
+ }
973
+ .issue .msg {
974
+ margin: 0;
975
+ font-size: 12.5px;
976
+ line-height: 1.5;
977
+ color: var(--chrome-ink);
978
+ overflow-wrap: anywhere;
979
+ }
980
+ .issue .hint { margin: 0; font-size: 11px; line-height: 1.5; color: var(--chrome-dim); }
981
+
982
+ /* The empty state is a row-shaped sentence, not a row of a table. */
983
+ .issue.empty { display: block; padding: 18px 14px; color: var(--chrome-dim); border-bottom: 0; }
984
+
985
+ /* -- toast ------------------------------------------------------------------ */
986
+
987
+ .toast {
988
+ position: fixed;
989
+ left: 50%;
990
+ bottom: 22px;
991
+ transform: translateX(-50%);
992
+ max-width: 70vw;
993
+ padding: 8px 14px;
994
+ border-radius: 999px;
995
+ background: var(--chrome-ink);
996
+ border: 1px solid var(--chrome-ink);
997
+ color: #fbfaf8;
998
+ box-shadow: var(--chrome-shadow-pill);
999
+ z-index: 40;
1000
+ }
1001
+
1002
+ .toast.refused { border-color: var(--chrome-danger); }
1003
+
1004
+ /* -- hidden ----------------------------------------------------------------- */
1005
+
1006
+ /*
1007
+ * `hidden` means hidden, whatever else this sheet says about the element.
1008
+ *
1009
+ * The browser's own `[hidden] { display: none }` lives in the user-agent sheet,
1010
+ * and every author rule beats it however low its specificity — so `.drawer {
1011
+ * display: flex }` left the Problems drawer on screen with the attribute set. It
1012
+ * covered the bottom 42% of the canvas the moment the file loaded, its Close
1013
+ * button closed nothing, and the empty fix textarea sat under every report.
1014
+ *
1015
+ * Written once, at the end, so no rule above it can beat it and no element given
1016
+ * a `display` later can be missed — which is what a per-class list of `[hidden]`
1017
+ * selectors did.
1018
+ */
1019
+ [hidden] { display: none !important; }
1020
+
1021
+ /* -- embedded mode ---------------------------------------------------------
1022
+ *
1023
+ * The DSH plugin iframes the canvas in a right-sidebar tab. The layer tree and
1024
+ * inspector are folded by `enterEmbed()` in app.mjs; the bar itself keeps only
1025
+ * what a narrow panel can use: the brand, zoom and Fit. Hiding by attribute
1026
+ * rather than waiting for `applyBarFit()` also makes the first paint correct,
1027
+ * before the bar has measured a single pill.
1028
+ */
1029
+ body[data-embed] :is(.bar-left .file, .bar-left .pill, .bar-left .warn,
1030
+ #toggle-problems, .export-sep, #export-page, #export-all) {
1031
+ display: none !important;
1032
+ }