@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/package.json +6 -3
- package/src/ActionList.astro +31 -27
- package/src/ActionRow.astro +68 -64
- package/src/BackToZone.astro +39 -35
- package/src/Base.astro +21 -3
- package/src/Catalogue.astro +95 -319
- package/src/CatalogueCard.astro +72 -68
- package/src/ChildPersona.astro +55 -51
- package/src/ConfirmSheet.astro +69 -65
- package/src/FilePicker.astro +21 -378
- package/src/LangPicker.astro +49 -104
- package/src/Posts.astro +177 -396
- package/src/Preroll.astro +204 -487
- package/src/Profile.astro +39 -35
- package/src/Segmented.astro +62 -111
- package/src/Sheet.astro +247 -507
- package/src/Toolbar.astro +288 -362
- package/src/behaviour/catalogue.ts +239 -0
- package/src/behaviour/filePicker.ts +372 -0
- package/src/behaviour/langPicker.ts +70 -0
- package/src/behaviour/posts.ts +234 -0
- package/src/behaviour/preroll.ts +298 -0
- package/src/behaviour/segmented.ts +64 -0
- package/src/behaviour/sheet.ts +300 -0
- package/src/behaviour/toolbar.ts +89 -0
- package/src/ground.css +124 -120
package/src/Sheet.astro
CHANGED
|
@@ -187,524 +187,264 @@ const doneIsOurs = doneDefault || done === true;
|
|
|
187
187
|
</dialog>
|
|
188
188
|
|
|
189
189
|
<style>
|
|
190
|
-
/*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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>
|