@justai/cuts 0.44.0 → 0.46.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 +112 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
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.
@@ -111,6 +129,18 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
111
129
  </dialog>
112
130
 
113
131
  <style>
132
+ /* queries: the page scrolls behind an open sheet · background scroll lock · the sheet feels
133
+ inactive · scroll behind the dialog
134
+ THE PAGE BEHIND MUST NOT MOVE. `showModal()` makes the rest of the document INERT — it cannot
135
+ be clicked or focused — but it does not stop it SCROLLING, and a person swiping over the dim
136
+ watches the page slide under a sheet they thought they were using. The founder named the
137
+ feeling exactly: "aktif sheet sanki aktif degilmis hissi veriyor". Measured before fixing:
138
+ scrollY went 2244 → 1644 with a sheet open.
139
+
140
+ Set by script on <html> and reference-counted, because sheets nest: unlocking on the first
141
+ close would free the page while a second sheet is still up. */
142
+ :global(html[data-sheet-locked]) { overflow: hidden; }
143
+
114
144
  /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
115
145
  place a thumb looks for "I am finished here". */
116
146
  .sheet-done {
@@ -227,6 +257,18 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
227
257
  flex: 1 1 auto;
228
258
  min-height: 0;
229
259
  overflow-y: auto;
260
+ /* queries: the sheet scrolls sideways · horizontal scroll in a sheet · long text pushes the
261
+ panel wider · content wider than the sheet
262
+ NOTHING SCROLLS SIDEWAYS. A sheet is a column with a known width, and a person dragging it
263
+ left to read the end of a mime type has been handed a defect rather than a feature —
264
+ measured at 867px of content in a 390px panel, from one unbroken string.
265
+
266
+ Two halves, and both are needed: `overflow-x` stops the panel itself from becoming a
267
+ horizontal scroller, and `overflow-wrap` makes a long unbroken token WRAP instead of being
268
+ silently clipped by it. Content that is genuinely wide — a table, a code block — carries its
269
+ own scroller; the sheet never becomes one. */
270
+ overflow-x: hidden;
271
+ overflow-wrap: anywhere;
230
272
  /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
231
273
  overscroll-behavior: contain;
232
274
  -webkit-overflow-scrolling: touch;
@@ -260,9 +302,55 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
260
302
  const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
261
303
  const wired = new WeakSet<HTMLDialogElement>();
262
304
 
305
+ // queries: background scroll lock · html data-sheet-locked · nested sheets unlock too early ·
306
+ // how does the sheet know it opened
307
+ //
308
+ // COUNTED, NOT TOGGLED. Sheets nest — qr's share sheet opens the file door on top of itself —
309
+ // so unlocking on the first `close` would free the page while a second sheet is still up. The
310
+ // count goes 0→1 to lock and 1→0 to unlock, and nothing in between touches it.
311
+ //
312
+ // WATCHED WITH A MutationObserver, because `<dialog>` has no "opened" event and consumers call
313
+ // `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
314
+ // would lock for sheets opened one way and not the other — which is worse than not locking at
315
+ // all, because it would work in testing and fail in the persona.
316
+ const locked = new WeakSet<HTMLDialogElement>();
317
+ let openCount = 0;
318
+ const relock = (delta: number) => {
319
+ openCount = Math.max(0, openCount + delta);
320
+ if (openCount > 0) document.documentElement.setAttribute("data-sheet-locked", "");
321
+ else document.documentElement.removeAttribute("data-sheet-locked");
322
+ };
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
+
263
342
  function wire(sheet: HTMLDialogElement) {
264
343
  if (wired.has(sheet) || !sheet.showModal) return;
265
344
  wired.add(sheet);
345
+ let wasOpen = sheet.open;
346
+ if (wasOpen) relock(1);
347
+ new MutationObserver(() => {
348
+ if (sheet.open === wasOpen) return;
349
+ wasOpen = sheet.open;
350
+ relock(wasOpen ? 1 : -1);
351
+ if (wasOpen) floorOnto(sheet);
352
+ else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
353
+ }).observe(sheet, { attributes: true, attributeFilter: ["open"] });
266
354
  const panel = sheet.querySelector<HTMLElement>("[data-panel]");
267
355
  const grip = sheet.querySelector<HTMLElement>("[data-grip]");
268
356
  if (!panel) return;
@@ -310,7 +398,28 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
310
398
  // because its sheet opens when a persona ASKS for a file rather than when a person presses a
311
399
  // button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
312
400
  // of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
313
- 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
+ };
314
423
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
315
424
  else wireAll();
316
425