@justai/cuts 0.18.0 → 0.20.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.18.0",
3
+ "version": "0.20.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
@@ -5,8 +5,9 @@
5
5
  * Measured before it was written: across the nine app.zone tool personas this file was 154 lines,
6
6
  * of which 132 were IDENTICAL. The 22 that differed were all identity — the host, the theme colour,
7
7
  * the favicon, the analytics property — plus two real ones, both preserved here as contract rather
8
- * than flattened: --on-accent (cleanmylink and howlong have light accents where white text fails
9
- * contrast) and the DEV service-worker kill, which came from the one persona that had it.
8
+ * than flattened: the bright-accent contrast hook (now --confirm-accent on the ConfirmSheet, for the
9
+ * personas whose mint/amber fails as text on the frosted card in light mode) and the DEV
10
+ * service-worker kill, which came from the one persona that had it.
10
11
  *
11
12
  * NO head slot, and the docstring claimed one for a while before anyone checked. time self-hosts
12
13
  * Geist through `import "@fontsource-variable/geist"` in its OWN layout frontmatter — a build-time
@@ -29,6 +30,9 @@ import { dir as dirOf } from "@justai/ui/locale";
29
30
  // The load-bearing behaviour is framework-free and lives in @justai/core, so `feed` (Next) injects the
30
31
  // SAME strings instead of hand-copying them. This binding just decides where they go in an Astro head.
31
32
  import { AUTH_CAPTURE_JS, JS_CLASS_JS, REVEAL_CSS } from "@justai/core";
33
+ // The consent ask IS a confirmation — a moment of trust — so it rides the fleet's ONE dialog
34
+ // rather than keeping a private dialect. One anatomy for "are you sure?" and "may I count you?".
35
+ import ConfirmSheet from "./ConfirmSheet.astro";
32
36
 
33
37
  interface Props {
34
38
  lang: string;
@@ -231,81 +235,70 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
231
235
  </head>
232
236
  <body>
233
237
  <slot />
234
- {/* Consent banner shown only to European-timezone visitors with no stored choice. The
235
- Consent Mode region default already denies analytics for the EEA/UK until "Allow". Gated on
236
- gaId with the analytics block above: a persona that runs no analytics has nothing to consent
237
- to, so it renders no banner.
238
+ {/* Consent — the ConfirmSheet, asked as a MODAL. Gated on gaId with the analytics block above:
239
+ a persona that runs no analytics has nothing to consent to, so it renders no sheet.
238
240
 
239
- The <style> below sits INSIDE this {gaId && (...)} expression, so Astro does NOT scope it — a
240
- style that is a direct static child of a component gets a data-astro-cid hash, but one nested
241
- in a JSX expression is emitted verbatim, GLOBAL. So every selector here is fleet-wide and MUST
242
- carry its own namespace. It once used a two-letter `.cc` for the banner container; the feed
243
- loader emits `<div class="cc">` for its content column, so the global `.cc { display: none }`
244
- hid the feed's text on EVERY persona. The whole family is `consent-*` now: unmistakably this
245
- widget's, never anyone else's. A generic global class is a landmine that the shell hides from
246
- the persona until a feed goes white see [[the-shell-guarantees-you-cannot-see]]. */}
241
+ Why it BLOCKS (founder's law, 2026-07-24): a banner at the ankle of the page treats the
242
+ question as ignorable and most visitors oblige, leaving the ask hanging on every page
243
+ forever. Blocking says the opposite: this choice is respected enough to be asked properly,
244
+ ONCE. Apple's own consent (the ATT prompt) is exactly this a blocking sheet, two equally
245
+ dignified choices. Three conditions keep it justai: it only auto-asks where consent is
246
+ legally asked (Europe everyone else just has the door), it arrives on the SECOND beat over
247
+ a persona already visibly doing its job behind the scrim (ask-after-proof, never a wall
248
+ before the work), and it never asks twice (a slip-away counts as the quiet no; the door in
249
+ the footer is the change-of-mind path). */}
247
250
  {gaId && (
248
- <div id="consent" class="consent" role="dialog" aria-label={t.chromePrivacy} data-choice-label={t.consentChoice}>
249
- <div class="consent-card">
250
- <p class="consent-text">{t.consentText}</p>
251
- <div class="consent-row">
252
- <button type="button" class="consent-btn" data-consent="denied">{t.consentDecline}</button>
253
- <button type="button" class="consent-btn consent-ok" data-consent="granted">{t.consentAllow}</button>
254
- </div>
255
- </div>
256
- </div>
257
- <style>
258
- .consent { display: none; position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 300; justify-content: center; padding: 0 12px; pointer-events: none; }
259
- .consent.show { display: flex; }
260
- .consent-card { pointer-events: auto; width: 100%; max-width: 460px; margin-block-end: max(12px, env(safe-area-inset-bottom)); background: color-mix(in srgb, var(--bg) 93%, transparent); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: 0 14px 40px -20px rgba(0, 0, 0, 0.2); backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5); padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; animation: consent-up 0.45s var(--ease-spring) both; }
261
- .consent-text { font-size: var(--fs-xs); color: var(--text); line-height: 1.5; }
262
- .consent-row { display: flex; gap: 8px; justify-content: flex-end; }
263
- .consent-btn { font: inherit; font-size: var(--fs-xs); font-weight: 600; padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); color: var(--text); cursor: pointer; transition: transform 0.2s var(--ease-spring); }
264
- .consent-btn:active { transform: scale(0.94); }
265
- /* The consent button is the one place the fleet genuinely disagreed, and the disagreement is
266
- about CONTRAST rather than taste. Seven personas have dark accents and take white text on
267
- --accent-press. cleanmylink and howlong have bright ones — mint and amber — where white
268
- fails, so they fill with --accent and set --on-accent to a dark tone that passes.
269
- Two hooks with the majority as the default, so the seven say nothing and the two say
270
- exactly what they already verified. This is the styling contract working as designed:
271
- colour is the persona's, and a real difference gets a declared hook rather than a fork. */
272
- .consent-ok { background: var(--consent-ok-bg, var(--accent-press)); border-color: var(--consent-ok-bg, var(--accent-press)); color: var(--on-accent, #fff); }
273
- @keyframes consent-up { from { transform: translateY(24px); opacity: 0; } }
274
- @media (prefers-reduced-motion: reduce) { .consent-card { animation: none; } }
275
- </style>
251
+ <ConfirmSheet id="consent" ask body={t.consentText}
252
+ actionLabel={t.consentAllow} cancelLabel={t.consentDecline} cancelValue="denied"
253
+ data-choice-label={t.consentChoice} />
276
254
  <script is:inline>
277
255
  (function () {
278
256
  try {
279
257
  // same non-production guard as the analytics block: no measurement → nothing to consent to
280
258
  var h = location.hostname;
281
259
  if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]" || h.indexOf("dev-") === 0) return;
282
- var el = document.getElementById("consent"); if (!el) return;
283
- var show = function () { el.classList.add("show"); };
284
- el.addEventListener("click", function (e) {
285
- var v = e.target.getAttribute("data-consent"); if (!v) return;
286
- gtag("consent", "update", { analytics_storage: v });
287
- try { localStorage.setItem("consent", v); } catch (_) {}
288
- el.classList.remove("show");
260
+ var el = document.getElementById("consent"); if (!el || !el.showModal) return;
261
+ // returnValue survives a close, so reset before every open or a stale answer replays.
262
+ var open = function () { if (!el.open) { el.returnValue = ""; el.showModal(); } };
263
+ el.addEventListener("close", function () {
264
+ var rv = el.returnValue;
265
+ // "confirm" = the ❤️. "denied" = a CHOSEN "Stay invisible" (the sheet's cancelValue).
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
+ }
289
279
  });
290
280
  // THE CHANGE-YOUR-MIND DOOR — and it is universal, not a consent-region courtesy. Trust is
291
281
  // the choice staying reachable: the Posts cut's footer carries a hidden anchor
292
282
  // ([data-consent-open]); this script labels it, reveals it, and wires it to reopen the
293
- // banner for ANY visitor in ANY region, stored choice or not. A non-EEA visitor gets a real
283
+ // sheet for ANY visitor in ANY region, stored choice or not. A non-EEA visitor gets a real
294
284
  // opt-out they were never offered before; an EEA visitor can reverse either answer. The
295
- // auto-show below keeps its narrow gates — the DOOR has none.
285
+ // auto-ask below keeps its narrow gates — the DOOR has none.
296
286
  var doors = document.querySelectorAll("[data-consent-open]");
297
287
  for (var i = 0; i < doors.length; i++) {
298
288
  var a = doors[i];
299
289
  if (!a.textContent) a.textContent = el.getAttribute("data-choice-label") || "privacy choice";
300
290
  a.hidden = false;
301
291
  var w = a.closest("[data-consent-door]"); if (w) w.hidden = false;
302
- a.addEventListener("click", function (ev) { ev.preventDefault(); show(); });
292
+ a.addEventListener("click", function (ev) { ev.preventDefault(); open(); });
303
293
  }
304
- // auto-show: 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
305
296
  if (localStorage.getItem("consent")) return;
297
+ try { if (sessionStorage.getItem("consent-later")) return; } catch (_) {}
306
298
  if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
307
299
  if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
308
- show();
300
+ // the first second belongs to the kernel; the question arrives on the second beat
301
+ setTimeout(open, 700);
309
302
  } catch (_) {}
310
303
  })();
311
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.
@@ -21,31 +33,48 @@
21
33
  // actionLabel={t.erase} cancelLabel={t.cancel} destructive />
22
34
  // open: document.getElementById("eraseConfirm").showModal()
23
35
  // outcome: dialog.addEventListener("close", () => dialog.returnValue === "confirm" ? ... : ...)
24
- // Both buttons live in a <form method="dialog">, so closing is native: Cancel returns "", the
25
- // action returns "confirm", Escape returns "". No custom event vocabulary to learn or misspell.
36
+ // Both buttons live in a <form method="dialog">, so closing is native: Cancel returns cancelValue
37
+ // (default ""), the action returns "confirm", Escape returns "". No custom event vocabulary to
38
+ // learn or misspell. NOTE for repeat-open consumers: returnValue survives a close — reset it to ""
39
+ // before each showModal(), or a stale "confirm" will masquerade as this open's answer.
26
40
  interface Props {
27
41
  /** The dialog element's id — the consumer opens it via getElementById(id).showModal(). */
28
42
  id: string;
29
- title: string;
43
+ /** Optional. A title-less sheet is a single spoken line (the consent ask is one): the body then
44
+ * wears the primary text colour and labels the dialog itself. */
45
+ title?: string;
30
46
  body: string;
31
47
  actionLabel: string;
32
48
  cancelLabel: string;
33
49
  /** Paint the action iOS system red — for actions that delete or end something. */
34
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;
55
+ /** What returnValue reports when the safe button is chosen. Default "" — indistinguishable from
56
+ * Escape/backdrop, which is fine when both mean "do nothing". Pass a distinct value when a
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. */
60
+ cancelValue?: string;
61
+ /** Anything else (data-* attributes, aria overrides) lands on the <dialog> itself. */
62
+ [key: string]: unknown;
35
63
  }
36
- const { id, title, body, actionLabel, cancelLabel, destructive = false } = Astro.props;
64
+ const { id, title, body, actionLabel, cancelLabel, destructive = false, ask = false, cancelValue = "", ...rest } = Astro.props;
37
65
  ---
38
66
 
39
- <dialog class="confirm" id={id} aria-labelledby={`${id}-t`} aria-describedby={`${id}-b`}>
67
+ <dialog class="confirm" id={id} aria-labelledby={title ? `${id}-t` : `${id}-b`} aria-describedby={`${id}-b`} {...rest}>
40
68
  <form method="dialog" class="confirm-form">
41
69
  <div class="confirm-group">
42
70
  <div class="confirm-msg">
43
- <strong id={`${id}-t`}>{title}</strong>
71
+ {title && <strong id={`${id}-t`}>{title}</strong>}
44
72
  <span id={`${id}-b`}>{body}</span>
45
73
  </div>
46
- <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>}
47
76
  </div>
48
- <button class="confirm-act confirm-cancel" id={`${id}-cancel`} value="" formnovalidate autofocus>{cancelLabel}</button>
77
+ {!ask && <button class="confirm-act confirm-cancel" id={`${id}-cancel`} value={cancelValue} formnovalidate autofocus>{cancelLabel}</button>}
49
78
  </form>
50
79
  </dialog>
51
80
 
@@ -77,14 +106,30 @@ const { id, title, body, actionLabel, cancelLabel, destructive = false } = Astro
77
106
  .confirm-msg { padding: 18px 18px 16px; text-align: center; display: flex; flex-direction: column; gap: 5px; }
78
107
  .confirm-msg strong { font-size: var(--fs-md); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
79
108
  .confirm-msg span { font-size: var(--fs-xs); color: var(--text-dim); line-height: 1.45; }
109
+ /* a title-less sheet's body is not supporting text under a headline — it IS the voice, so it
110
+ speaks in the primary colour at reading size */
111
+ .confirm-msg span:only-child { color: var(--text); font-size: var(--fs-sm); }
80
112
  .confirm-act {
81
113
  width: 100%; padding: 15px; border: 0; border-block-start: 1px solid var(--border);
82
- background: none; font: 600 var(--fs-md) var(--font, inherit); color: var(--accent); cursor: pointer;
114
+ /* --confirm-accent: the declared hook for personas whose accent is too BRIGHT to read as text
115
+ on the frosted card in light mode (cleanmylink's mint, howlong's amber, whatismyip's mint —
116
+ the same three that once hooked the old filled consent button). They set a theme-aware
117
+ darker tone of their own hue; everyone else says nothing. */
118
+ background: none; font: 600 var(--fs-md) var(--font, inherit); color: var(--confirm-accent, var(--accent)); cursor: pointer;
83
119
  transition: background 0.15s;
84
120
  }
85
121
  .confirm-act:active { background: color-mix(in srgb, var(--text) 9%, transparent); }
122
+ /* focus is a wash, not a ring: the fleet's 4px focus ring gets CLIPPED by the card's
123
+ overflow:hidden into two stray bars — inside a sheet, the focused button fills with a
124
+ whisper of its own colour instead (currentColor: accent, red, or text alike) */
125
+ .confirm-act:focus-visible { box-shadow: none; outline: none; background: color-mix(in srgb, currentColor 12%, transparent); }
86
126
  /* iOS system red — the one colour that means "this ends something" on every persona alike */
87
127
  .confirm-act.destructive { color: #ff453a; }
128
+ /* the ASK genre: the invitation is the preferred path (Apple's bold preferredAction)… */
129
+ .confirm-act.preferred { font-weight: 700; }
130
+ /* …and the safe choice answers in a calm, full-contrast regular voice — equal size, equal tap,
131
+ never dimmed (a decline you have to hunt for is not a free choice), just not the emphasis */
132
+ .confirm-act.quiet { color: var(--text); font-weight: 400; }
88
133
  /* the safe choice wears the boldest face, in its own detached card */
89
134
  .confirm-cancel {
90
135
  border: 1px solid var(--border); border-radius: 18px;