@justai/cuts 0.46.1 → 0.47.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/Sheet.astro +144 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/src/Sheet.astro CHANGED
@@ -118,6 +118,28 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
118
118
  the thing you see is the thing you can grab. Hidden from AX: Escape and the backdrop are the
119
119
  keyboard/assistive paths, and a decorative pill announced as a control is noise. */}
120
120
  <div class="sheet-grip" data-grip aria-hidden="true"><span></span></div>
121
+ {/* queries: back button in a sheet · sheet navigation · go deeper without opening another sheet
122
+ · in-sheet push · a second sheet loses the context · sheet breadcrumb
123
+
124
+ DEPTH WITHOUT A SECOND SHEET.
125
+
126
+ Stacking works — an opened sheet takes the one below as a height floor, so it never reads as
127
+ a retreat. What stacking cannot do is keep you ORIENTED: a full-height sheet over a
128
+ full-height sheet covers the thing you came from, and the only way back is a dismissal that
129
+ looks exactly like leaving altogether.
130
+
131
+ So a sheet may hold PAGES, and push between them. `<button data-sheet-push="versions">`
132
+ moves to the element carrying `data-sheet-page="versions"`; this chevron appears with the
133
+ previous page's title beside it and pops one level. Escape and the backdrop pop too, and
134
+ only close the sheet at the root — the platform behaviour a person already expects from a
135
+ navigation stack anywhere else.
136
+
137
+ NO HOMEWORK: a consumer writes markup and two attributes. No script, and the sheet still
138
+ works exactly as before for the consumers that never declare a page. */}
139
+ <button class="sheet-back" id={`${id}-back`} type="button" data-sheet-back hidden>
140
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
141
+ <span data-sheet-back-label></span>
142
+ </button>
121
143
  {title && <h2 class="sheet-title" id={`${id}-title`}>{title}</h2>}
122
144
  {/* `id`-suffixed for the same reason the title is: a surface that translates at RUNTIME needs a
123
145
  stable hook to re-label it from its own dictionary. */}
@@ -143,6 +165,36 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
143
165
 
144
166
  /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
145
167
  place a thumb looks for "I am finished here". */
168
+ /* The back control mirrors Done across the header: same band, same weight, same restraint. It is
169
+ the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
170
+ chevron sits tight against the label the way iOS sets a back item. */
171
+ .sheet-back {
172
+ position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
173
+ display: inline-flex; align-items: center; gap: 2px;
174
+ border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
175
+ color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
176
+ max-width: 42%;
177
+ }
178
+ .sheet-back[hidden] { display: none; }
179
+ .sheet-back svg { width: 20px; height: 20px; flex: none; }
180
+ .sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
181
+ .sheet-back:active { opacity: 0.6; }
182
+ .sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
183
+
184
+ /* queries: sheet page transition · in-sheet push animation · the pages slide
185
+
186
+ Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
187
+ what makes the sheet grow and shrink around the content the way a navigation stack does. The
188
+ slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
189
+ :global([data-sheet-page]) { display: none; }
190
+ :global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
191
+ :global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
192
+ @keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
193
+ @keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
194
+ @media (prefers-reduced-motion: reduce) {
195
+ :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
196
+ }
197
+
146
198
  .sheet-done {
147
199
  position: absolute; inset-block-start: 0; inset-inline-end: 0;
148
200
  background: none; border: 0; cursor: pointer; padding: 12px 16px;
@@ -339,6 +391,91 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
339
391
  else panel.style.removeProperty("min-height");
340
392
  }
341
393
 
394
+ // ── in-sheet navigation ────────────────────────────────────────────────────────────────────
395
+ // queries: sheet push pop · back button in a sheet · data-sheet-push · escape pops instead of
396
+ // closing · in-sheet navigation stack
397
+ //
398
+ // A STACK PER SHEET, held on the element rather than in a module variable, because two sheets
399
+ // can be open at once and each has its own history. Empty means "at the root", which is also the
400
+ // state every sheet that never declares a page is permanently in — that is what makes this
401
+ // addition free for the consumers who do not use it.
402
+ const stacks = new WeakMap<HTMLDialogElement, string[]>();
403
+
404
+ const pageOf = (sheet: HTMLDialogElement, key: string) =>
405
+ sheet.querySelector<HTMLElement>(`[data-sheet-page="${CSS.escape(key)}"]`);
406
+
407
+ // The name a person reads on the way back. The page's own `data-sheet-page-title` if it has one,
408
+ // otherwise the sheet's heading — which is the honest answer at the root, since that IS where
409
+ // back goes.
410
+ const titleAt = (sheet: HTMLDialogElement, key: string | undefined) =>
411
+ (key ? pageOf(sheet, key)?.dataset.sheetPageTitle : null)
412
+ ?? sheet.querySelector<HTMLElement>(".sheet-title")?.textContent?.trim()
413
+ ?? "";
414
+
415
+ function show(sheet: HTMLDialogElement, popping: boolean) {
416
+ const stack = stacks.get(sheet) ?? [];
417
+ const pages = sheet.querySelectorAll<HTMLElement>("[data-sheet-page]");
418
+ if (!pages.length) return;
419
+ const current = stack[stack.length - 1];
420
+ for (const el of pages) {
421
+ const on = current ? el.dataset.sheetPage === current : el.hasAttribute("data-sheet-root");
422
+ el.classList.toggle("is-current", on && !popping);
423
+ el.classList.toggle("is-popping", on && popping);
424
+ }
425
+ const back = sheet.querySelector<HTMLElement>("[data-sheet-back]");
426
+ const label = sheet.querySelector<HTMLElement>("[data-sheet-back-label]");
427
+ if (back) back.hidden = stack.length === 0;
428
+ // The label names WHERE BACK GOES — the level beneath the current one — which is the whole
429
+ // convention iOS set. Naming the current page instead is the mistake that makes a back button
430
+ // read as a title.
431
+ if (label) label.textContent = stack.length ? titleAt(sheet, stack[stack.length - 2]) : "";
432
+ }
433
+
434
+ function push(sheet: HTMLDialogElement, key: string) {
435
+ if (!pageOf(sheet, key)) return;
436
+ const stack = stacks.get(sheet) ?? [];
437
+ stacks.set(sheet, [...stack, key]);
438
+ show(sheet, false);
439
+ }
440
+
441
+ function pop(sheet: HTMLDialogElement) {
442
+ const stack = stacks.get(sheet) ?? [];
443
+ if (!stack.length) return false;
444
+ stacks.set(sheet, stack.slice(0, -1));
445
+ show(sheet, true);
446
+ return true;
447
+ }
448
+
449
+ document.addEventListener("click", (e) => {
450
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-push]");
451
+ if (t) {
452
+ const sheet = t.closest("dialog.sheet") as HTMLDialogElement | null;
453
+ if (sheet) { e.preventDefault(); push(sheet, t.getAttribute("data-sheet-push") || ""); }
454
+ return;
455
+ }
456
+ const b = (e.target as HTMLElement)?.closest?.("[data-sheet-back]");
457
+ if (b) { e.preventDefault(); pop(b.closest("dialog.sheet") as HTMLDialogElement); }
458
+ });
459
+
460
+ // ESCAPE POPS BEFORE IT CLOSES, and the backdrop does the same. Anywhere else in a browser, Back
461
+ // at depth means "up one level"; a sheet that jumps straight out from three levels down throws
462
+ // away everything the person navigated through, and they cannot tell the two gestures apart
463
+ // beforehand. `preventDefault` on `cancel` is what keeps <dialog> from closing underneath us.
464
+ document.addEventListener("cancel", (e) => {
465
+ const sheet = e.target as HTMLDialogElement;
466
+ if (sheet?.classList?.contains("sheet") && pop(sheet)) e.preventDefault();
467
+ }, true);
468
+
469
+ // A sheet always REOPENS AT ITS ROOT. Coming back to a surface and finding it three levels deep
470
+ // where you left it is the behaviour of a window, not of a sheet — and nobody expects a sheet to
471
+ // remember.
472
+ document.addEventListener("click", (e) => {
473
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
474
+ if (!t) return;
475
+ const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
476
+ if (sheet) { stacks.set(sheet, []); show(sheet, false); }
477
+ }, true);
478
+
342
479
  function wire(sheet: HTMLDialogElement) {
343
480
  if (wired.has(sheet) || !sheet.showModal) return;
344
481
  wired.add(sheet);
@@ -356,11 +493,17 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
356
493
  if (!panel) return;
357
494
 
358
495
  // A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
359
- sheet.addEventListener("click", (e) => { if (e.target === sheet) sheet.close(); });
496
+ // A tap on the stage pops one level first, for the same reason Escape does.
497
+ sheet.addEventListener("click", (e) => { if (e.target === sheet && !pop(sheet)) sheet.close(); });
360
498
  sheet.addEventListener("close", () => {
361
499
  sheet.removeAttribute("data-expanded");
362
500
  panel.style.transform = "";
501
+ // Back to the root on the way out, whichever way it was dismissed — see the reopen note.
502
+ if (stacks.get(sheet)?.length) { stacks.set(sheet, []); show(sheet, false); }
363
503
  });
504
+ // A sheet that ships pages must show one from the very first frame, or its body is empty until
505
+ // somebody navigates. The root is whichever page carries `data-sheet-root`.
506
+ show(sheet, false);
364
507
 
365
508
  // ── drag: the grabber follows the pointer, and the release decides ──
366
509
  let id: number | null = null, y0 = 0, t0 = 0, dy = 0;