@justai/cuts 0.133.0 → 0.138.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.
package/src/Sheet.astro CHANGED
@@ -187,524 +187,264 @@ const doneIsOurs = doneDefault || done === true;
187
187
  </dialog>
188
188
 
189
189
  <style>
190
- /* queries: the page scrolls behind an open sheet · background scroll lock · the sheet feels
191
- inactive · scroll behind the dialog
192
- THE PAGE BEHIND MUST NOT MOVE. `showModal()` makes the rest of the document INERT — it cannot
193
- be clicked or focused — but it does not stop it SCROLLING, and a person swiping over the dim
194
- watches the page slide under a sheet they thought they were using. The founder named the
195
- feeling exactly: "aktif sheet sanki aktif degilmis hissi veriyor". Measured before fixing:
196
- scrollY went 2244 → 1644 with a sheet open.
197
-
198
- Set by script on <html> and reference-counted, because sheets nest: unlocking on the first
199
- close would free the page while a second sheet is still up. */
200
- :global(html[data-sheet-locked]) { overflow: hidden; }
201
-
202
- /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
203
- place a thumb looks for "I am finished here". */
204
- /* The back control mirrors Done across the header: same band, same weight, same restraint. It is
205
- the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
206
- chevron sits tight against the label the way iOS sets a back item. */
207
- .sheet-back {
208
- position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
209
- display: inline-flex; align-items: center; gap: 2px;
210
- border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
211
- color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
212
- max-width: 42%;
213
- }
214
- .sheet-back[hidden] { display: none; }
215
- .sheet-back svg { width: 20px; height: 20px; flex: none; }
216
- .sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
217
- .sheet-back:active { opacity: 0.6; }
218
- .sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
219
-
220
- /* queries: sheet page transition · in-sheet push animation · the pages slide
221
-
222
- Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
223
- what makes the sheet grow and shrink around the content the way a navigation stack does. The
224
- slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
225
- /* queries: the second sheet page appears BELOW the first · pushing shows both pages · the history
226
- list is squeezed at the bottom · a page does not hide · consumer css beats the sheet
227
- HIDING A PAGE IS A MECHANISM, NOT A SUGGESTION, so it is written where a class cannot outrank
228
- it. `[data-sheet-page] { display: none }` is one attribute — specificity (0,1,0) — and any
229
- consumer rule naming two classes beats it. files styles its detail page as `.pp .fd { display:
230
- flex }`, which is ordinary and correct, and it meant the root page NEVER HID: pushing to the
231
- versions drew them one under the other, so a person who tapped History got a cramped strip at
232
- the bottom of a sheet still showing everything they had just left.
233
- Qualified by the dialog it lives in, which raises this to (0,2,1) and keeps the rule honest —
234
- a page outside a sheet is nobody's business here. */
235
- :global(dialog [data-sheet-page]:not(.is-current):not(.is-popping)) { display: none; }
236
- :global([data-sheet-page]) { display: none; }
237
- :global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
238
- :global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
239
- @keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
240
- @keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
241
- @media (prefers-reduced-motion: reduce) {
242
- :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
243
- }
244
-
245
- /* queries: cancel colour · should Cancel be tinted · cancel looks disabled · grey cancel ·
246
- accent on the sheet bar · cancel vs done weight
247
- MIRRORS DONE ACROSS THE BAR — same band, opposite edge, SAME TINT, LIGHTER WEIGHT.
248
-
249
- This was grey (`--text-dim`), argued as "leaving without answering is an ordinary thing to do,
250
- not the reason you came". True about the hierarchy, wrong about the means: on iOS a bar button
251
- is TINTED, and grey text on a bar is what the platform uses to say UNAVAILABLE. Mail, Calendar
252
- and Photos all put Cancel in the tint colour beside a bolder Done — the hierarchy is carried by
253
- WEIGHT, 500 against 600, which is the one channel that does not collide with an existing
254
- meaning. A dimmed way out reads as a door that will not open. */
255
- .sheet-cancel {
256
- position: absolute; inset-block-start: 0; inset-inline-start: 0;
257
- background: none; border: 0; cursor: pointer; padding: 12px 16px;
258
- font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--accent);
259
- }
260
- .sheet-cancel:active { opacity: 0.6; }
261
- .sheet-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
262
- /* Cancel, Done and Back must never stand together — going back IS the way out of a pushed page,
263
- so the leading slot belongs to whichever of the two is meaningful right now. */
264
- .sheet:has(.sheet-back:not([hidden])) .sheet-cancel { display: none; }
265
-
266
- .sheet-done {
267
- position: absolute; inset-block-start: 0; inset-inline-end: 0;
268
- background: none; border: 0; cursor: pointer; padding: 12px 16px;
269
- font: inherit; font-size: var(--fs-sm); font-weight: 600; color: var(--accent);
270
- }
271
- .sheet-done:active { opacity: 0.6; }
272
- .sheet-done:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
273
-
274
- .sheet {
275
- /* The dialog is the full-screen STAGE; the PANEL is the sheet.
276
- `position: fixed` is written out rather than inherited: the UA gives a dialog
277
- `position: absolute`, and an absolutely-positioned top-layer element resolves against the
278
- initial containing block — which is document-relative, so on a scrolled page the sheet would
279
- be placed at coordinates the visitor is no longer looking at. */
280
- position: fixed;
281
- inset: 0;
282
- width: 100%;
283
- height: 100%;
284
- max-width: none;
285
- max-height: none;
286
- margin: 0;
287
- padding: 0;
288
- border: 0;
289
- background: none;
290
- overflow: visible;
291
- /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
292
- display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
293
- UA rule that hides it while closed — a sheet permanently visible on every page. */
294
- display: none;
295
- }
296
- .sheet[open] {
297
- display: flex;
298
- align-items: flex-end;
299
- justify-content: center;
300
- }
301
-
302
- /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
303
- display:none, and a property that had no previous computed value does not transition — the
304
- "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
305
- all. That silent no-op is the class of failure the harness exists to catch. */
306
- .sheet::backdrop {
307
- background: rgba(0, 0, 0, 0.4);
308
- -webkit-backdrop-filter: blur(3px);
309
- backdrop-filter: blur(3px);
310
- opacity: 1;
311
- transition: opacity 0.3s var(--ease-out);
312
- }
313
- @starting-style {
314
- .sheet[open]::backdrop { opacity: 0; }
315
- }
316
-
317
- .sheet-panel {
318
- position: relative; /* the Done action is positioned against this */
319
- display: flex;
320
- flex-direction: column;
321
- /* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
322
- is the active view, and its width is READ rather than repeated: `--column` is declared once in
323
- @justai/ui and the page is sized from the same token. On a phone this is the whole screen; on a
324
- desk it is the persona's own width, centred. It said 480 until 2026-08-04, in its own copy of
325
- the number — so when the page became 768 the sheet stayed a phone's width on a tablet. */
326
- /* THE FALLBACK IS NOT DECORATION. `--column` arrives with @justai/ui, and a page that uses this
327
- cut without it — the harness's own sheet fixture is one — never sees the token at all. So the
328
- literal here is the fleet's real width, not a historical one: it was 768 for an afternoon and
329
- the sheet quietly rendered at 768 on a 640 fleet, which is how that was found. */
330
- width: min(100%, var(--sheet-width, var(--column, 640px)));
331
- /* dvh is Chromium 108+; the fallback above it survives because the build has targets. */
332
- max-height: min(92vh, 100vh - 24px);
333
- max-height: min(92dvh, 100dvh - 24px);
334
- /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
335
- background: color-mix(in srgb, var(--bg) 93%, transparent);
336
- -webkit-backdrop-filter: blur(30px) saturate(1.5);
337
- backdrop-filter: blur(30px) saturate(1.5);
338
- border-top: 1px solid var(--border);
339
- border-radius: 20px 20px 0 0;
340
- /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
341
- iOS decelerates into place instead — an overshoot spring belongs to something that scales,
342
- not to something that travels to an edge. */
343
- transform: translateY(0);
344
- transition: transform 0.34s var(--ease-out);
345
- will-change: transform;
346
- }
347
- @starting-style {
348
- .sheet[open] .sheet-panel { transform: translateY(100%); }
349
- }
350
- /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
351
- a control. Focus a visitor MOVES lands on the rows, which do show one. */
352
- .sheet-panel:focus { outline: none; }
353
- /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
354
- every pointermove is what makes a web sheet feel like a web page. */
355
- .sheet[data-dragging] .sheet-panel { transition: none; }
356
-
357
- .sheet-grip {
358
- flex: none;
359
- display: grid;
360
- place-items: center;
361
- height: 28px;
362
- cursor: grab;
363
- touch-action: none; /* the browser must not claim the vertical gesture we are reading */
364
- }
365
- .sheet-grip span {
366
- width: 36px;
367
- height: 5px;
368
- border-radius: 2.5px;
369
- background: color-mix(in srgb, var(--text) 22%, transparent);
370
- }
371
- .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
372
-
373
- .sheet-title {
374
- flex: none;
375
- margin: 0;
376
- padding: 2px 20px 10px;
377
- font-size: var(--fs-md);
378
- font-weight: 700;
379
- letter-spacing: -0.01em;
380
- color: var(--text);
381
- text-align: center;
382
- }
383
-
384
- .sheet-body {
385
- flex: 1 1 auto;
386
- min-height: 0;
387
- overflow-y: auto;
388
- /* queries: the sheet scrolls sideways · horizontal scroll in a sheet · long text pushes the
389
- panel wider · content wider than the sheet
390
- NOTHING SCROLLS SIDEWAYS. A sheet is a column with a known width, and a person dragging it
391
- left to read the end of a mime type has been handed a defect rather than a feature —
392
- measured at 867px of content in a 390px panel, from one unbroken string.
393
-
394
- Two halves, and both are needed: `overflow-x` stops the panel itself from becoming a
395
- horizontal scroller, and `overflow-wrap` makes a long unbroken token WRAP instead of being
396
- silently clipped by it. Content that is genuinely wide — a table, a code block — carries its
397
- own scroller; the sheet never becomes one. */
398
- overflow-x: hidden;
399
- overflow-wrap: anywhere;
400
- /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
401
- overscroll-behavior: contain;
402
- -webkit-overflow-scrolling: touch;
403
- /* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
404
- its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
405
- exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
406
- reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
407
- number that makes the clipping impossible rather than unlikely. */
408
- padding: 6px 12px max(14px, env(safe-area-inset-bottom));
409
- }
410
-
411
- /* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
412
- now, because the gesture that changes it does too: the grabber is never hidden. */
413
- .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
414
-
415
- @media (prefers-reduced-motion: reduce) {
416
- .sheet-panel, .sheet::backdrop { transition: none; }
417
- .sheet-panel { transform: none; opacity: 1; }
418
- }
419
- </style>
420
-
421
- <script>
422
- import { chromeCopy } from "@justai/core";
423
-
424
- // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
425
- // an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
426
- //
427
- // Note what is NOT here any more: a matchMedia guard. The drag worked only below 768px while this
428
- // component still had a desktop branch; now that there is one behaviour, there is one code path,
429
- // and a mouse can pull the grabber exactly as a finger does.
430
- (function () {
431
- // queries: sheet says Cancel in English · the picker's cancel is not translated · Done stays
432
- // English · translate the sheet bar · why does the cut translate itself
433
- //
434
- // THE BAR SAYS ITS OWN WORDS, IN THE PAGE'S LANGUAGE.
435
- //
436
- // Found on an Arabic page: every word in the picker in Arabic, and `Cancel` in English in the
437
- // sheet's own bar — because the cut rendered the literal and left translating it to whoever
438
- // mounted it.
439
- //
440
- // WHY AT RUNTIME rather than at build. There is no locale to read at build. The fleet routes
441
- // languages through a `[lang]` PARAMETER, not Astro's i18n config, so `Astro.currentLocale` is
442
- // undefined in every persona that has one — and the two surfaces that need this most (`files`
443
- // and `/me`) are ONE bundle serving thirteen languages, so no build-time answer could be right
444
- // for them anyway. `<html lang>` is the one place every one of them agrees.
445
- //
446
- // A CONSUMER'S WORD ALWAYS WINS. `<Sheet cancel="Not now">` is a deliberate choice and this must
447
- // not overwrite it — so only the DEFAULTS carry `data-chrome`, and only those are rewritten.
448
- // That is also what keeps the runtime-translating surfaces working: they re-label by id from
449
- // their own dictionary, which is the older contract and still the more specific one.
450
- const say = () => {
451
- const w = chromeCopy(document.documentElement.lang);
452
- for (const el of document.querySelectorAll<HTMLElement>("[data-chrome]")) {
453
- const word = w[el.dataset.chrome as "cancel" | "done"];
454
- if (word) el.textContent = word;
455
- }
456
- };
457
- say();
458
- // The child personas set `<html lang>` from their own storage AFTER this module runs — a sheet
459
- // translated only at load would be correct on a host and English inside a guest. Cheaper than a
460
- // MutationObserver and exact: the attribute cannot change without the element being replaced.
461
- new MutationObserver(say).observe(document.documentElement, { attributeFilter: ["lang"] });
462
- const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
463
- const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
464
- const wired = new WeakSet<HTMLDialogElement>();
465
-
466
- // queries: background scroll lock · html data-sheet-locked · nested sheets unlock too early ·
467
- // how does the sheet know it opened
468
- //
469
- // COUNTED, NOT TOGGLED. Sheets nest — qr's share sheet opens the file door on top of itself —
470
- // so unlocking on the first `close` would free the page while a second sheet is still up. The
471
- // count goes 0→1 to lock and 1→0 to unlock, and nothing in between touches it.
472
- //
473
- // WATCHED WITH A MutationObserver, because `<dialog>` has no "opened" event and consumers call
474
- // `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
475
- // would lock for sheets opened one way and not the other — which is worse than not locking at
476
- // all, because it would work in testing and fail in the persona.
477
- const locked = new WeakSet<HTMLDialogElement>();
478
- let openCount = 0;
479
- const relock = (delta: number) => {
480
- openCount = Math.max(0, openCount + delta);
481
- if (openCount > 0) document.documentElement.setAttribute("data-sheet-locked", "");
482
- else document.documentElement.removeAttribute("data-sheet-locked");
483
- };
484
-
485
- // Measure the tallest sheet ALREADY open and use it as this one's floor. Measured rather than
486
- // derived from the detent, because a `medium` sheet whose content is short is shorter than its
487
- // detent allows — the floor has to be what is actually on the screen, not what was permitted.
488
- function floorOnto(sheet: HTMLDialogElement) {
489
- if (!sheet.hasAttribute("data-floor")) return;
490
- const panel = sheet.querySelector<HTMLElement>("[data-panel]");
491
- if (!panel) return;
492
- let floor = 0;
493
- for (const other of document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]")) {
494
- if (other === sheet) continue;
495
- const p = other.querySelector<HTMLElement>("[data-panel]");
496
- if (p) floor = Math.max(floor, p.getBoundingClientRect().height);
497
- }
498
- // Never taller than the screen, and never a floor of zero — this is the first sheet then.
499
- if (floor > 0) panel.style.minHeight = Math.min(floor, window.innerHeight) + "px";
500
- else panel.style.removeProperty("min-height");
190
+ /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
191
+ out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
192
+ @layer justai.components {
193
+ /* queries: the page scrolls behind an open sheet · background scroll lock · the sheet feels
194
+ inactive · scroll behind the dialog
195
+ THE PAGE BEHIND MUST NOT MOVE. `showModal()` makes the rest of the document INERT — it cannot
196
+ be clicked or focused — but it does not stop it SCROLLING, and a person swiping over the dim
197
+ watches the page slide under a sheet they thought they were using. The founder named the
198
+ feeling exactly: "aktif sheet sanki aktif degilmis hissi veriyor". Measured before fixing:
199
+ scrollY went 2244 → 1644 with a sheet open.
200
+
201
+ Set by script on <html> and reference-counted, because sheets nest: unlocking on the first
202
+ close would free the page while a second sheet is still up. */
203
+ :global(html[data-sheet-locked]) { overflow: hidden; }
204
+
205
+ /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
206
+ place a thumb looks for "I am finished here". */
207
+ /* The back control mirrors Done across the header: same band, same weight, same restraint. It is
208
+ the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
209
+ chevron sits tight against the label the way iOS sets a back item. */
210
+ .sheet-back {
211
+ position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
212
+ display: inline-flex; align-items: center; gap: 2px;
213
+ border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
214
+ color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
215
+ max-width: 42%;
501
216
  }
502
-
503
- // ── in-sheet navigation ────────────────────────────────────────────────────────────────────
504
- // queries: sheet push pop · back button in a sheet · data-sheet-push · escape pops instead of
505
- // closing · in-sheet navigation stack
506
- //
507
- // A STACK PER SHEET, held on the element rather than in a module variable, because two sheets
508
- // can be open at once and each has its own history. Empty means "at the root", which is also the
509
- // state every sheet that never declares a page is permanently in — that is what makes this
510
- // addition free for the consumers who do not use it.
511
- const stacks = new WeakMap<HTMLDialogElement, string[]>();
512
-
513
- const pageOf = (sheet: HTMLDialogElement, key: string) =>
514
- sheet.querySelector<HTMLElement>(`[data-sheet-page="${CSS.escape(key)}"]`);
515
-
516
- // The name a person reads on the way back. The page's own `data-sheet-page-title` if it has one,
517
- // otherwise the sheet's heading — which is the honest answer at the root, since that IS where
518
- // back goes.
519
- const titleAt = (sheet: HTMLDialogElement, key: string | undefined) =>
520
- (key ? pageOf(sheet, key)?.dataset.sheetPageTitle : null)
521
- ?? sheet.querySelector<HTMLElement>(".sheet-title")?.textContent?.trim()
522
- ?? "";
523
-
524
- function show(sheet: HTMLDialogElement, popping: boolean) {
525
- const stack = stacks.get(sheet) ?? [];
526
- const pages = sheet.querySelectorAll<HTMLElement>("[data-sheet-page]");
527
- if (!pages.length) return;
528
- const current = stack[stack.length - 1];
529
- for (const el of pages) {
530
- const on = current ? el.dataset.sheetPage === current : el.hasAttribute("data-sheet-root");
531
- el.classList.toggle("is-current", on && !popping);
532
- el.classList.toggle("is-popping", on && popping);
533
- }
534
- const back = sheet.querySelector<HTMLElement>("[data-sheet-back]");
535
- const label = sheet.querySelector<HTMLElement>("[data-sheet-back-label]");
536
- if (back) back.hidden = stack.length === 0;
537
- // The label names WHERE BACK GOES — the level beneath the current one — which is the whole
538
- // convention iOS set. Naming the current page instead is the mistake that makes a back button
539
- // read as a title.
540
- if (label) label.textContent = stack.length ? titleAt(sheet, stack[stack.length - 2]) : "";
217
+ .sheet-back[hidden] { display: none; }
218
+ .sheet-back svg { width: 20px; height: 20px; flex: none; }
219
+ .sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
220
+ .sheet-back:active { opacity: 0.6; }
221
+ .sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
222
+
223
+ /* queries: sheet page transition · in-sheet push animation · the pages slide
224
+
225
+ Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
226
+ what makes the sheet grow and shrink around the content the way a navigation stack does. The
227
+ slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
228
+ /* queries: the second sheet page appears BELOW the first · pushing shows both pages · the history
229
+ list is squeezed at the bottom · a page does not hide · consumer css beats the sheet
230
+ HIDING A PAGE IS A MECHANISM, NOT A SUGGESTION, so it is written where a class cannot outrank
231
+ it. `[data-sheet-page] { display: none }` is one attribute — specificity (0,1,0) — and any
232
+ consumer rule naming two classes beats it. files styles its detail page as `.pp .fd { display:
233
+ flex }`, which is ordinary and correct, and it meant the root page NEVER HID: pushing to the
234
+ versions drew them one under the other, so a person who tapped History got a cramped strip at
235
+ the bottom of a sheet still showing everything they had just left.
236
+ Qualified by the dialog it lives in, which raises this to (0,2,1) and keeps the rule honest —
237
+ a page outside a sheet is nobody's business here. */
238
+ @keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
239
+ @keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
240
+ @media (prefers-reduced-motion: reduce) {
241
+ :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
541
242
  }
542
243
 
543
- function push(sheet: HTMLDialogElement, key: string) {
544
- if (!pageOf(sheet, key)) return;
545
- const stack = stacks.get(sheet) ?? [];
546
- stacks.set(sheet, [...stack, key]);
547
- show(sheet, false);
244
+ /* queries: cancel colour · should Cancel be tinted · cancel looks disabled · grey cancel ·
245
+ accent on the sheet bar · cancel vs done weight
246
+ MIRRORS DONE ACROSS THE BAR — same band, opposite edge, SAME TINT, LIGHTER WEIGHT.
247
+
248
+ This was grey (`--text-dim`), argued as "leaving without answering is an ordinary thing to do,
249
+ not the reason you came". True about the hierarchy, wrong about the means: on iOS a bar button
250
+ is TINTED, and grey text on a bar is what the platform uses to say UNAVAILABLE. Mail, Calendar
251
+ and Photos all put Cancel in the tint colour beside a bolder Done — the hierarchy is carried by
252
+ WEIGHT, 500 against 600, which is the one channel that does not collide with an existing
253
+ meaning. A dimmed way out reads as a door that will not open. */
254
+ .sheet-cancel {
255
+ position: absolute; inset-block-start: 0; inset-inline-start: 0;
256
+ background: none; border: 0; cursor: pointer; padding: 12px 16px;
257
+ font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--accent);
548
258
  }
549
-
550
- function pop(sheet: HTMLDialogElement) {
551
- const stack = stacks.get(sheet) ?? [];
552
- if (!stack.length) return false;
553
- stacks.set(sheet, stack.slice(0, -1));
554
- show(sheet, true);
555
- return true;
259
+ .sheet-cancel:active { opacity: 0.6; }
260
+ .sheet-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
261
+ /* Cancel, Done and Back must never stand together — going back IS the way out of a pushed page,
262
+ so the leading slot belongs to whichever of the two is meaningful right now. */
263
+ .sheet:has(.sheet-back:not([hidden])) .sheet-cancel { display: none; }
264
+
265
+ .sheet-done {
266
+ position: absolute; inset-block-start: 0; inset-inline-end: 0;
267
+ background: none; border: 0; cursor: pointer; padding: 12px 16px;
268
+ font: inherit; font-size: var(--fs-sm); font-weight: 600; color: var(--accent);
269
+ }
270
+ .sheet-done:active { opacity: 0.6; }
271
+ .sheet-done:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
272
+
273
+ .sheet {
274
+ /* The dialog is the full-screen STAGE; the PANEL is the sheet.
275
+ `position: fixed` is written out rather than inherited: the UA gives a dialog
276
+ `position: absolute`, and an absolutely-positioned top-layer element resolves against the
277
+ initial containing block — which is document-relative, so on a scrolled page the sheet would
278
+ be placed at coordinates the visitor is no longer looking at. */
279
+ position: fixed;
280
+ inset: 0;
281
+ width: 100%;
282
+ height: 100%;
283
+ max-width: none;
284
+ max-height: none;
285
+ margin: 0;
286
+ padding: 0;
287
+ border: 0;
288
+ background: none;
289
+ overflow: visible;
290
+ /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
291
+ display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
292
+ UA rule that hides it while closed — a sheet permanently visible on every page. */
293
+ display: none;
294
+ }
295
+ .sheet[open] {
296
+ display: flex;
297
+ align-items: flex-end;
298
+ justify-content: center;
556
299
  }
557
300
 
558
- document.addEventListener("click", (e) => {
559
- const t = (e.target as HTMLElement)?.closest?.("[data-sheet-push]");
560
- if (t) {
561
- const sheet = t.closest("dialog.sheet") as HTMLDialogElement | null;
562
- if (sheet) { e.preventDefault(); push(sheet, t.getAttribute("data-sheet-push") || ""); }
563
- return;
564
- }
565
- const b = (e.target as HTMLElement)?.closest?.("[data-sheet-back]");
566
- if (b) { e.preventDefault(); pop(b.closest("dialog.sheet") as HTMLDialogElement); }
567
- });
568
-
569
- // ESCAPE POPS BEFORE IT CLOSES, and the backdrop does the same. Anywhere else in a browser, Back
570
- // at depth means "up one level"; a sheet that jumps straight out from three levels down throws
571
- // away everything the person navigated through, and they cannot tell the two gestures apart
572
- // beforehand. `preventDefault` on `cancel` is what keeps <dialog> from closing underneath us.
573
- document.addEventListener("cancel", (e) => {
574
- const sheet = e.target as HTMLDialogElement;
575
- if (sheet?.classList?.contains("sheet") && pop(sheet)) e.preventDefault();
576
- }, true);
577
-
578
- // A sheet always REOPENS AT ITS ROOT. Coming back to a surface and finding it three levels deep
579
- // where you left it is the behaviour of a window, not of a sheet — and nobody expects a sheet to
580
- // remember.
581
- document.addEventListener("click", (e) => {
582
- const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
583
- if (!t) return;
584
- const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
585
- if (sheet) { stacks.set(sheet, []); show(sheet, false); }
586
- }, true);
587
-
588
- function wire(sheet: HTMLDialogElement) {
589
- if (wired.has(sheet) || !sheet.showModal) return;
590
- wired.add(sheet);
591
- let wasOpen = sheet.open;
592
- if (wasOpen) relock(1);
593
- new MutationObserver(() => {
594
- if (sheet.open === wasOpen) return;
595
- wasOpen = sheet.open;
596
- relock(wasOpen ? 1 : -1);
597
- if (wasOpen) floorOnto(sheet);
598
- else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
599
- }).observe(sheet, { attributes: true, attributeFilter: ["open"] });
600
- const panel = sheet.querySelector<HTMLElement>("[data-panel]");
601
- const grip = sheet.querySelector<HTMLElement>("[data-grip]");
602
- if (!panel) return;
603
-
604
- // A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
605
- // A tap on the stage pops one level first, for the same reason Escape does.
606
- sheet.addEventListener("click", (e) => { if (e.target === sheet && !pop(sheet)) sheet.close(); });
607
- sheet.addEventListener("close", () => {
608
- sheet.removeAttribute("data-expanded");
609
- panel.style.transform = "";
610
- // Back to the root on the way out, whichever way it was dismissed — see the reopen note.
611
- if (stacks.get(sheet)?.length) { stacks.set(sheet, []); show(sheet, false); }
612
- });
613
- // A sheet that ships pages must show one from the very first frame, or its body is empty until
614
- // somebody navigates. The root is whichever page carries `data-sheet-root`.
615
- show(sheet, false);
301
+ /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
302
+ display:none, and a property that had no previous computed value does not transition — the
303
+ "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
304
+ all. That silent no-op is the class of failure the harness exists to catch. */
305
+ .sheet::backdrop {
306
+ background: rgba(0, 0, 0, 0.4);
307
+ -webkit-backdrop-filter: blur(3px);
308
+ backdrop-filter: blur(3px);
309
+ opacity: 1;
310
+ transition: opacity 0.3s var(--ease-out);
311
+ }
312
+ @starting-style {
313
+ .sheet[open]::backdrop { opacity: 0; }
314
+ }
616
315
 
617
- // ── drag: the grabber follows the pointer, and the release decides ──
618
- let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
619
- grip?.addEventListener("pointerdown", (e: PointerEvent) => {
620
- id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
621
- grip.setPointerCapture(id);
622
- sheet.setAttribute("data-dragging", "");
623
- });
624
- grip?.addEventListener("pointermove", (e: PointerEvent) => {
625
- if (id !== e.pointerId) return;
626
- dy = e.clientY - y0;
627
- // Downward drag travels 1:1. Upward is RUBBER-BANDED rather than free: pulling up past the
628
- // detent must feel like resistance, not like a second scroll surface.
629
- panel.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy / 4}px)`;
630
- });
631
- const release = (e: PointerEvent) => {
632
- if (id !== e.pointerId) return;
633
- const dt = Math.max(1, e.timeStamp - t0);
634
- const v = dy / dt;
635
- sheet.removeAttribute("data-dragging");
636
- panel.style.transform = "";
637
- id = null;
638
- if (dy > panel.offsetHeight * DRAG_CLOSE || v > FLICK) sheet.close();
639
- // A decisive pull upwards promotes the medium detent to large — iOS's two-detent sheet.
640
- else if (dy < -40) sheet.setAttribute("data-expanded", "");
641
- };
642
- grip?.addEventListener("pointerup", release);
643
- grip?.addEventListener("pointercancel", release);
316
+ .sheet-panel {
317
+ position: relative; /* the Done action is positioned against this */
318
+ display: flex;
319
+ flex-direction: column;
320
+ /* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
321
+ is the active view, and its width is READ rather than repeated: `--column` is declared once in
322
+ @justai/ui and the page is sized from the same token. On a phone this is the whole screen; on a
323
+ desk it is the persona's own width, centred. It said 480 until 2026-08-04, in its own copy of
324
+ the number — so when the page became 768 the sheet stayed a phone's width on a tablet. */
325
+ /* THE FALLBACK IS NOT DECORATION. `--column` arrives with @justai/ui, and a page that uses this
326
+ cut without it — the harness's own sheet fixture is one — never sees the token at all. So the
327
+ literal here is the fleet's real width, not a historical one: it was 768 for an afternoon and
328
+ the sheet quietly rendered at 768 on a 640 fleet, which is how that was found. */
329
+ width: min(100%, var(--sheet-width, var(--column, 640px)));
330
+ /* dvh is Chromium 108+; the fallback above it survives because the build has targets. */
331
+ max-height: min(92vh, 100vh - 24px);
332
+ max-height: min(92dvh, 100dvh - 24px);
333
+ /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
334
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
335
+ -webkit-backdrop-filter: blur(30px) saturate(1.5);
336
+ backdrop-filter: blur(30px) saturate(1.5);
337
+ border-top: 1px solid var(--border);
338
+ border-radius: 20px 20px 0 0;
339
+ /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
340
+ iOS decelerates into place instead — an overshoot spring belongs to something that scales,
341
+ not to something that travels to an edge. */
342
+ transform: translateY(0);
343
+ transition: transform 0.34s var(--ease-out);
344
+ will-change: transform;
345
+ }
346
+ @starting-style {
347
+ .sheet[open] .sheet-panel { transform: translateY(100%); }
348
+ }
349
+ /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
350
+ a control. Focus a visitor MOVES lands on the rows, which do show one. */
351
+ .sheet-panel:focus { outline: none; }
352
+ /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
353
+ every pointermove is what makes a web sheet feel like a web page. */
354
+ .sheet[data-dragging] .sheet-panel { transition: none; }
355
+
356
+ .sheet-grip {
357
+ flex: none;
358
+ display: grid;
359
+ place-items: center;
360
+ height: 28px;
361
+ cursor: grab;
362
+ touch-action: none; /* the browser must not claim the vertical gesture we are reading */
363
+ }
364
+ .sheet-grip span {
365
+ width: 36px;
366
+ height: 5px;
367
+ border-radius: 2.5px;
368
+ background: color-mix(in srgb, var(--text) 22%, transparent);
369
+ }
370
+ .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
371
+
372
+ .sheet-title {
373
+ flex: none;
374
+ margin: 0;
375
+ padding: 2px 20px 10px;
376
+ font-size: var(--fs-md);
377
+ font-weight: 700;
378
+ letter-spacing: -0.01em;
379
+ color: var(--text);
380
+ text-align: center;
644
381
  }
645
382
 
646
- // WIRED ON LOAD, not on the first trigger click.
647
- //
648
- // It used to be lazy, and the cost of that surfaced the moment a sheet was first opened by
649
- // something other than a `data-sheet-open` trigger — <FilePicker> calls showModal() itself,
650
- // because its sheet opens when a persona ASKS for a file rather than when a person presses a
651
- // button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
652
- // of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
653
- // queries: does the confirm dialog lock the page too · scroll behind ConfirmSheet
654
- //
655
- // ConfirmSheet is its own `<dialog class="confirm">` rather than a `<Sheet>` — deliberately, it
656
- // is an alert and not a surface. But the page scrolling behind it is the same defect on the
657
- // same person's thumb, so the LOCK covers it while the floor and the drag do not. A rule about
658
- // how a modal feels belongs to every modal, not to one component.
659
- function lockOnly(d: HTMLDialogElement) {
660
- if (locked.has(d)) return;
661
- locked.add(d);
662
- let was = d.open;
663
- if (was) relock(1);
664
- new MutationObserver(() => {
665
- if (d.open === was) return;
666
- was = d.open;
667
- relock(was ? 1 : -1);
668
- }).observe(d, { attributes: true, attributeFilter: ["open"] });
383
+ .sheet-body {
384
+ flex: 1 1 auto;
385
+ min-height: 0;
386
+ overflow-y: auto;
387
+ /* queries: the sheet scrolls sideways · horizontal scroll in a sheet · long text pushes the
388
+ panel wider · content wider than the sheet
389
+ NOTHING SCROLLS SIDEWAYS. A sheet is a column with a known width, and a person dragging it
390
+ left to read the end of a mime type has been handed a defect rather than a feature —
391
+ measured at 867px of content in a 390px panel, from one unbroken string.
392
+
393
+ Two halves, and both are needed: `overflow-x` stops the panel itself from becoming a
394
+ horizontal scroller, and `overflow-wrap` makes a long unbroken token WRAP instead of being
395
+ silently clipped by it. Content that is genuinely wide — a table, a code block — carries its
396
+ own scroller; the sheet never becomes one. */
397
+ overflow-x: hidden;
398
+ overflow-wrap: anywhere;
399
+ /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
400
+ overscroll-behavior: contain;
401
+ -webkit-overflow-scrolling: touch;
402
+ /* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
403
+ its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
404
+ exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
405
+ reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
406
+ number that makes the clipping impossible rather than unlikely. */
407
+ padding: 6px 12px max(14px, env(safe-area-inset-bottom));
669
408
  }
670
409
 
671
- const wireAll = () => {
672
- document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
673
- document.querySelectorAll<HTMLDialogElement>("dialog.confirm").forEach(lockOnly);
674
- };
675
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
676
- else wireAll();
410
+ /* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
411
+ now, because the gesture that changes it does too: the grabber is never hidden. */
412
+ .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
677
413
 
678
- // Done closes, and NOTHING ELSE. It is not a commit — a sheet whose changes are live has
679
- // nothing to commit — so it must not become a hook consumers hang saving off, or the word
680
- // starts meaning two things in two personas.
681
- document.addEventListener("click", (e) => {
682
- const d = (e.target as HTMLElement)?.closest?.("[data-sheet-done]");
683
- if (!d) return;
684
- (d.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
685
- });
414
+ @media (prefers-reduced-motion: reduce) {
415
+ .sheet-panel, .sheet::backdrop { transition: none; }
416
+ .sheet-panel { transform: none; opacity: 1; }
417
+ }
418
+ }
686
419
 
687
- // Cancel closes too, and the difference is not in what it DOES — it is in what the caller may
688
- // conclude. A sheet that resolves a promise reads its own `close` event and settles null there,
689
- // so a cancel, a backdrop tap and a drag down are one path with one answer. Two words, one exit,
690
- // and no second place for a caller to be told something different.
691
- document.addEventListener("click", (e) => {
692
- const c = (e.target as HTMLElement)?.closest?.("[data-sheet-cancel]");
693
- if (!c) return;
694
- (c.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
695
- });
420
+ /* queries: why is this rule outside the layer · unlayered css · a mechanism a consumer must not
421
+ switch off · component invariant vs appearance
422
+
423
+ OUTSIDE THE LAYER, AND THAT IS THE WHOLE POINT.
424
+
425
+ Everything above is this component's APPEARANCE, and a persona overriding it should not have to
426
+ out-specify anything — that is what `@layer justai.components` buys. These four rules are not
427
+ appearance. They are the mechanism that makes a sheet a navigation stack rather than a list, and
428
+ in CSS an unlayered rule beats every layered one, whoever wrote it.
429
+
430
+ The fight was already had once with specificity: `[data-sheet-page] { display: none }` is (0,1,0)
431
+ and `files` styles its detail page `.pp .fd { display: flex }`, which is ordinary and correct —
432
+ the root page never hid, and pushing to the versions drew both, one under the other. Layering
433
+ these would hand that defect straight back, which is how it was found again: the spec named "a
434
+ consumer's own class cannot keep a page in the layout" failed the moment they went in.
435
+
436
+ THE RULE THIS IS AN INSTANCE OF: a component's appearance is the consumer's to override; an
437
+ invariant a stylesheet can switch off is not an invariant. Across fifteen components and 1,539
438
+ lines of CSS, this is the only block that needed to be here — which is worth knowing, because it
439
+ means the distinction is cheap to hold rather than a judgement on every rule. */
440
+ :global(dialog [data-sheet-page]:not(.is-current):not(.is-popping)) { display: none; }
441
+ :global([data-sheet-page]) { display: none; }
442
+ :global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
443
+ :global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
444
+ </style>
696
445
 
697
- document.addEventListener("click", (e) => {
698
- const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
699
- if (!t) return;
700
- const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
701
- if (!sheet) return;
702
- e.preventDefault();
703
- wire(sheet);
704
- if (!sheet.open) sheet.showModal();
705
- // Land the focus on the PANEL, not on whatever control happens to be first inside it — the
706
- // keyboard-over-the-list failure described on the tabindex attribute above.
707
- sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
708
- });
709
- })();
710
- </script>
446
+ <script>
447
+ // The behaviour lives in ./behaviour/sheet.ts — a module a compiler can read and a test can call.
448
+ // What stays here is the markup and the CSS, which is what a component is.
449
+ import "./behaviour/sheet";
450
+ </script>