@justai/cuts 0.19.0 → 0.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.19.0",
3
+ "version": "0.21.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/Base.astro CHANGED
@@ -248,7 +248,7 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
248
248
  before the work), and it never asks twice (a slip-away counts as the quiet no; the door in
249
249
  the footer is the change-of-mind path). */}
250
250
  {gaId && (
251
- <ConfirmSheet id="consent" body={t.consentText}
251
+ <ConfirmSheet id="consent" ask body={t.consentText}
252
252
  actionLabel={t.consentAllow} cancelLabel={t.consentDecline} cancelValue="denied"
253
253
  data-choice-label={t.consentChoice} />
254
254
  <script is:inline>
@@ -258,21 +258,24 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
258
258
  var h = location.hostname;
259
259
  if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]" || h.indexOf("dev-") === 0) return;
260
260
  var el = document.getElementById("consent"); if (!el || !el.showModal) return;
261
- // Whether the CURRENT open is the first-visit auto-ask (vs a change-your-mind reopen) —
262
- // it decides what a slip-away means in the close handler below.
263
- var autoAsk = false;
264
261
  // returnValue survives a close, so reset before every open or a stale answer replays.
265
262
  var open = function () { if (!el.open) { el.returnValue = ""; el.showModal(); } };
266
263
  el.addEventListener("close", function () {
267
- var rv = el.returnValue, first = autoAsk; autoAsk = false;
264
+ var rv = el.returnValue;
268
265
  // "confirm" = the ❤️. "denied" = a CHOSEN "Stay invisible" (the sheet's cancelValue).
269
- // "" is a slip-away (Escape / backdrop tap): on the first ask it counts as the quiet
270
- // no asking again on every visit would be the nag we promised never to be — but on a
271
- // door reopen it is "never mind", and an accident must not overwrite a stored choice.
272
- var v = rv === "confirm" ? "granted" : rv === "denied" ? "denied" : first ? "denied" : null;
273
- if (!v) return;
274
- gtag("consent", "update", { analytics_storage: v });
275
- try { localStorage.setItem("consent", v); } catch (_) {}
266
+ // Anything else (Escape, a backdrop tap) is NOT an answer, and ONLY A CHOICE MAY BE
267
+ // REMEMBERED AS A CHOICE (founder's law, 2026-07-24 an earlier draft recorded the
268
+ // slip-away as a "quiet no", i.e. an answer the visitor never gave). A slip-away
269
+ // stores nothing: analytics stays at the regional default and the ask RETURNS on the
270
+ // next visit; the session flag below just keeps THIS visit quiet — one dodge buys one
271
+ // visit of peace, not an answer.
272
+ var v = rv === "confirm" ? "granted" : rv === "denied" ? "denied" : null;
273
+ if (v) {
274
+ gtag("consent", "update", { analytics_storage: v });
275
+ try { localStorage.setItem("consent", v); } catch (_) {}
276
+ } else {
277
+ try { sessionStorage.setItem("consent-later", "1"); } catch (_) {}
278
+ }
276
279
  });
277
280
  // THE CHANGE-YOUR-MIND DOOR — and it is universal, not a consent-region courtesy. Trust is
278
281
  // the choice staying reachable: the Posts cut's footer carries a hidden anchor
@@ -288,12 +291,14 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
288
291
  var w = a.closest("[data-consent-door]"); if (w) w.hidden = false;
289
292
  a.addEventListener("click", function (ev) { ev.preventDefault(); open(); });
290
293
  }
291
- // auto-ask: only the first visit, only where consent is legally asked, only on a content page
294
+ // auto-ask: no stored CHOICE, not already dodged THIS visit, only where consent is
295
+ // legally asked, only on a content page
292
296
  if (localStorage.getItem("consent")) return;
297
+ try { if (sessionStorage.getItem("consent-later")) return; } catch (_) {}
293
298
  if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
294
299
  if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
295
300
  // the first second belongs to the kernel; the question arrives on the second beat
296
- setTimeout(function () { if (!el.open) { autoAsk = true; open(); } }, 700);
301
+ setTimeout(open, 700);
297
302
  } catch (_) {}
298
303
  })();
299
304
  </script>
@@ -6,10 +6,22 @@
6
6
  // actions with one dialect while another improvises a different one is how a fleet stops feeling
7
7
  // like one hand made it. Anything that needs "are you sure?" uses THIS.
8
8
  //
9
- // The anatomy is Apple's, faithfully: a frosted grouped card (message + the full-width action,
10
- // separated by a hairline) and a PHYSICALLY DETACHED Cancel in its own card — bolder than the
11
- // action, because the safe choice wears the most confident face. A destructive action is iOS
12
- // system red. The sheet springs up from the bottom (reduced-motion honored).
9
+ // The anatomy is Apple's, faithfully and Apple has TWO genres, so this sheet has two:
10
+ //
11
+ // CONFIRM (default) the destructive action sheet: message + the action in the grouped card,
12
+ // and a PHYSICALLY DETACHED Cancel in its own card, bolder than the action, because when
13
+ // something can end, the safe choice wears the most confident face. iOS system red for
14
+ // destruction. This is /me's "Erase everything?".
15
+ //
16
+ // ASK (`ask` prop) — the alert/ATT genre, for INVITATIONS: both choices live INSIDE the grouped
17
+ // card, stacked; the action wears the preferred bold face (Apple's preferredAction) and takes
18
+ // the focus; the safe choice sits beneath it in calm regular weight — equal size, equal tap,
19
+ // one hairline apart, but the invitation is the emphasized path. Porting the confirm genre to a
20
+ // friendly ask was tried and rejected by the founder on sight (2026-07-24): a detached, boldest,
21
+ // focus-ringed "Stay invisible" ENCOURAGED declining — the destructive anatomy exists to protect,
22
+ // and there is nothing to protect from in a warm question.
23
+ //
24
+ // The sheet springs up from the bottom (reduced-motion honored).
13
25
  //
14
26
  // WHY native <dialog> + showModal(), and not a hand-rolled scrim: the /me original had the visible
15
27
  // half of Apple right and the INVISIBLE half missing — no focus trap, no Escape, no focus restore.
@@ -36,15 +48,20 @@ interface Props {
36
48
  cancelLabel: string;
37
49
  /** Paint the action iOS system red — for actions that delete or end something. */
38
50
  destructive?: boolean;
51
+ /** The ASK genre: an invitation, not a guard. Both choices join the grouped card, the action is
52
+ * the preferred (bold, focused) path, the safe choice sits quietly beneath. Use for questions
53
+ * you'd be glad to hear "yes" to (consent); never for actions that end something. */
54
+ ask?: boolean;
39
55
  /** What returnValue reports when the safe button is chosen. Default "" — indistinguishable from
40
56
  * Escape/backdrop, which is fine when both mean "do nothing". Pass a distinct value when a
41
- * CHOSEN no must be tellable apart from a slip-away (consent stores the first, ignores the
42
- * second on a reopen). */
57
+ * CHOSEN no must be tellable apart from a slip-away consent does: only a choice may be
58
+ * remembered as a choice, so the chosen no is stored while a slip-away stores nothing and the
59
+ * ask returns next visit. */
43
60
  cancelValue?: string;
44
61
  /** Anything else (data-* attributes, aria overrides) lands on the <dialog> itself. */
45
62
  [key: string]: unknown;
46
63
  }
47
- const { id, title, body, actionLabel, cancelLabel, destructive = false, cancelValue = "", ...rest } = Astro.props;
64
+ const { id, title, body, actionLabel, cancelLabel, destructive = false, ask = false, cancelValue = "", ...rest } = Astro.props;
48
65
  ---
49
66
 
50
67
  <dialog class="confirm" id={id} aria-labelledby={title ? `${id}-t` : `${id}-b`} aria-describedby={`${id}-b`} {...rest}>
@@ -54,9 +71,10 @@ const { id, title, body, actionLabel, cancelLabel, destructive = false, cancelVa
54
71
  {title && <strong id={`${id}-t`}>{title}</strong>}
55
72
  <span id={`${id}-b`}>{body}</span>
56
73
  </div>
57
- <button class:list={["confirm-act", { destructive }]} id={`${id}-act`} value="confirm">{actionLabel}</button>
74
+ <button class:list={["confirm-act", { destructive, preferred: ask }]} id={`${id}-act`} value="confirm" autofocus={ask || undefined}>{actionLabel}</button>
75
+ {ask && <button class="confirm-act quiet" id={`${id}-cancel`} value={cancelValue} formnovalidate>{cancelLabel}</button>}
58
76
  </div>
59
- <button class="confirm-act confirm-cancel" id={`${id}-cancel`} value={cancelValue} formnovalidate autofocus>{cancelLabel}</button>
77
+ {!ask && <button class="confirm-act confirm-cancel" id={`${id}-cancel`} value={cancelValue} formnovalidate autofocus>{cancelLabel}</button>}
60
78
  </form>
61
79
  </dialog>
62
80
 
@@ -82,15 +100,19 @@ const { id, title, body, actionLabel, cancelLabel, destructive = false, cancelVa
82
100
  .confirm-form { display: flex; flex-direction: column; gap: 8px; }
83
101
  .confirm-group {
84
102
  border-radius: 18px; overflow: hidden; border: 1px solid var(--border);
85
- background: color-mix(in srgb, var(--bg) 80%, transparent);
103
+ /* Apple's THICK material, deliberately: at 80% the kernel behind ghosted through the card
104
+ (founder-caught on the QR grid). The frosted feel comes from the blur, not from letting
105
+ content bleed into the words — a sheet asking for a decision must be legible first. */
106
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
86
107
  backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
87
108
  }
88
109
  .confirm-msg { padding: 18px 18px 16px; text-align: center; display: flex; flex-direction: column; gap: 5px; }
89
110
  .confirm-msg strong { font-size: var(--fs-md); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
90
111
  .confirm-msg span { font-size: var(--fs-xs); color: var(--text-dim); line-height: 1.45; }
91
- /* a title-less sheet's body is not supporting text under a headline — it IS the voice, so it
92
- speaks in the primary colour at reading size */
93
- .confirm-msg span:only-child { color: var(--text); font-size: var(--fs-sm); }
112
+ /* a title-less sheet's body is not supporting text under a headline — it IS the voice AND the
113
+ title, so it speaks in the primary colour at the native body size (founder: the question
114
+ deserves reading size, not caption size) */
115
+ .confirm-msg span:only-child { color: var(--text); font-size: var(--fs-base); line-height: 1.5; }
94
116
  .confirm-act {
95
117
  width: 100%; padding: 15px; border: 0; border-block-start: 1px solid var(--border);
96
118
  /* --confirm-accent: the declared hook for personas whose accent is too BRIGHT to read as text
@@ -101,12 +123,21 @@ const { id, title, body, actionLabel, cancelLabel, destructive = false, cancelVa
101
123
  transition: background 0.15s;
102
124
  }
103
125
  .confirm-act:active { background: color-mix(in srgb, var(--text) 9%, transparent); }
126
+ /* focus is a wash, not a ring: the fleet's 4px focus ring gets CLIPPED by the card's
127
+ overflow:hidden into two stray bars — inside a sheet, the focused button fills with a
128
+ whisper of its own colour instead (currentColor: accent, red, or text alike) */
129
+ .confirm-act:focus-visible { box-shadow: none; outline: none; background: color-mix(in srgb, currentColor 12%, transparent); }
104
130
  /* iOS system red — the one colour that means "this ends something" on every persona alike */
105
131
  .confirm-act.destructive { color: #ff453a; }
132
+ /* the ASK genre: the invitation is the preferred path (Apple's bold preferredAction)… */
133
+ .confirm-act.preferred { font-weight: 700; }
134
+ /* …and the safe choice answers in a calm, full-contrast regular voice — equal size, equal tap,
135
+ never dimmed (a decline you have to hunt for is not a free choice), just not the emphasis */
136
+ .confirm-act.quiet { color: var(--text); font-weight: 400; }
106
137
  /* the safe choice wears the boldest face, in its own detached card */
107
138
  .confirm-cancel {
108
139
  border: 1px solid var(--border); border-radius: 18px;
109
- background: color-mix(in srgb, var(--bg) 88%, transparent);
140
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
110
141
  backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
111
142
  font-weight: 700; color: var(--text);
112
143
  }