@justai/cuts 0.45.0 → 0.46.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.45.0",
4
- "description": "A persona's named parts the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.46.1",
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": {
7
7
  "type": "git",
package/src/Base.astro CHANGED
@@ -40,7 +40,20 @@ import ConfirmSheet from "./ConfirmSheet.astro";
40
40
 
41
41
  interface Props {
42
42
  lang: string;
43
- t: Record<string, string>;
43
+ /**
44
+ * The persona's strings, in the shape the persona keeps them.
45
+ *
46
+ * NOT `Record<string, string>` — that was the declaration for a year and it was wrong for every
47
+ * consumer: a persona's dictionary is NESTED (`t.share.title`, `t.types.wifi`), so the one type
48
+ * this layout advertised was the one type nobody passes. It compiled everywhere because nobody
49
+ * ran a typechecker over a page; qr turned one on and the framework's own contract was the first
50
+ * thing it caught.
51
+ *
52
+ * This layout only ever reads a handful of top-level strings off it and forwards the rest to the
53
+ * cuts, so what it actually requires is "an object of strings, possibly nested" — stated here
54
+ * rather than narrowed to a shape only this package would recognise.
55
+ */
56
+ t: Record<string, unknown>;
44
57
  /** The persona's own origin, e.g. https://tabata.app.zone — canonical, hreflang and OG all read it. */
45
58
  site: string;
46
59
  /** From the consuming repo's src/i18n — never from this package. */
package/src/Sheet.astro CHANGED
@@ -57,6 +57,24 @@ interface Props {
57
57
  title?: string;
58
58
  /** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
59
59
  detent?: "medium" | "large";
60
+ // queries: a sheet opened over another looks like going back · stacked sheet height · floor ·
61
+ // why is the second sheet shorter · going deeper should not look like retreating
62
+ //
63
+ // A STACK MUST NEVER APPEAR TO MOVE BACKWARDS (founder, 2026-07-28).
64
+ //
65
+ // A sheet that opens SHORTER than the one beneath it reads as a retreat: the content area
66
+ // shrinks, the dim jumps, and the feeling is "something closed" rather than "I went deeper".
67
+ // So an opened sheet takes the height of the one below as a FLOOR.
68
+ //
69
+ // It does not make depth visible on its own — two `large` sheets are still the same height —
70
+ // and it is not meant to. Paired with the ceiling of two, it guarantees the only thing that
71
+ // matters at two levels: the movement always reads forward.
72
+ //
73
+ // `false` opts out, and ConfirmSheet does. A question that must be READ is served by being
74
+ // small and focused; stretching an alert to match whatever it was asked from would weaken the
75
+ // one surface whose whole job is to be taken seriously. Apple keeps alerts small regardless of
76
+ // context, for the same reason.
77
+ floorToParent?: boolean;
60
78
  // queries: should a sheet have a Done button · how do I close a full height sheet · sheet done ·
61
79
  // the exit is not obvious · dismiss a large sheet
62
80
  //
@@ -79,7 +97,7 @@ interface Props {
79
97
  // hardcoded the English would be a component making its consumer's copy decisions.
80
98
  done?: string | false;
81
99
  }
82
- const { id, title, detent = "medium", done } = Astro.props;
100
+ const { id, title, detent = "medium", done, floorToParent = true } = Astro.props;
83
101
  // Default: shown at large, absent at medium. `false` removes it, a string replaces the word.
84
102
  const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done" : null));
85
103
  ---
@@ -89,7 +107,7 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
89
107
  exposes. A surface that translates at RUNTIME (the vault, the feed — one bundle, thirteen
90
108
  languages, no per-locale routes) renders this cut in English and re-labels it from its own
91
109
  dictionary. Without the hook such a consumer cannot adopt the cut at all, and writes its own. */}
92
- <dialog class="sheet" id={id} data-detent={detent} aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Sheet"}>
110
+ <dialog class="sheet" id={id} data-detent={detent} data-floor={floorToParent ? "" : undefined} aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Sheet"}>
93
111
  {/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
94
112
  hands focus to the first focusable descendant — which for a picker is the search field, and on
95
113
  a phone that throws the software keyboard over the list the visitor opened the sheet to read.
@@ -295,6 +313,7 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
295
313
  // `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
296
314
  // would lock for sheets opened one way and not the other — which is worse than not locking at
297
315
  // all, because it would work in testing and fail in the persona.
316
+ const locked = new WeakSet<HTMLDialogElement>();
298
317
  let openCount = 0;
299
318
  const relock = (delta: number) => {
300
319
  openCount = Math.max(0, openCount + delta);
@@ -302,6 +321,24 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
302
321
  else document.documentElement.removeAttribute("data-sheet-locked");
303
322
  };
304
323
 
324
+ // Measure the tallest sheet ALREADY open and use it as this one's floor. Measured rather than
325
+ // derived from the detent, because a `medium` sheet whose content is short is shorter than its
326
+ // detent allows — the floor has to be what is actually on the screen, not what was permitted.
327
+ function floorOnto(sheet: HTMLDialogElement) {
328
+ if (!sheet.hasAttribute("data-floor")) return;
329
+ const panel = sheet.querySelector<HTMLElement>("[data-panel]");
330
+ if (!panel) return;
331
+ let floor = 0;
332
+ for (const other of document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]")) {
333
+ if (other === sheet) continue;
334
+ const p = other.querySelector<HTMLElement>("[data-panel]");
335
+ if (p) floor = Math.max(floor, p.getBoundingClientRect().height);
336
+ }
337
+ // Never taller than the screen, and never a floor of zero — this is the first sheet then.
338
+ if (floor > 0) panel.style.minHeight = Math.min(floor, window.innerHeight) + "px";
339
+ else panel.style.removeProperty("min-height");
340
+ }
341
+
305
342
  function wire(sheet: HTMLDialogElement) {
306
343
  if (wired.has(sheet) || !sheet.showModal) return;
307
344
  wired.add(sheet);
@@ -311,6 +348,8 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
311
348
  if (sheet.open === wasOpen) return;
312
349
  wasOpen = sheet.open;
313
350
  relock(wasOpen ? 1 : -1);
351
+ if (wasOpen) floorOnto(sheet);
352
+ else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
314
353
  }).observe(sheet, { attributes: true, attributeFilter: ["open"] });
315
354
  const panel = sheet.querySelector<HTMLElement>("[data-panel]");
316
355
  const grip = sheet.querySelector<HTMLElement>("[data-grip]");
@@ -359,7 +398,28 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
359
398
  // because its sheet opens when a persona ASKS for a file rather than when a person presses a
360
399
  // button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
361
400
  // of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
362
- const wireAll = () => document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
401
+ // queries: does the confirm dialog lock the page too · scroll behind ConfirmSheet
402
+ //
403
+ // ConfirmSheet is its own `<dialog class="confirm">` rather than a `<Sheet>` — deliberately, it
404
+ // is an alert and not a surface. But the page scrolling behind it is the same defect on the
405
+ // same person's thumb, so the LOCK covers it while the floor and the drag do not. A rule about
406
+ // how a modal feels belongs to every modal, not to one component.
407
+ function lockOnly(d: HTMLDialogElement) {
408
+ if (locked.has(d)) return;
409
+ locked.add(d);
410
+ let was = d.open;
411
+ if (was) relock(1);
412
+ new MutationObserver(() => {
413
+ if (d.open === was) return;
414
+ was = d.open;
415
+ relock(was ? 1 : -1);
416
+ }).observe(d, { attributes: true, attributeFilter: ["open"] });
417
+ }
418
+
419
+ const wireAll = () => {
420
+ document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
421
+ document.querySelectorAll<HTMLDialogElement>("dialog.confirm").forEach(lockOnly);
422
+ };
363
423
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
364
424
  else wireAll();
365
425