@justai/cuts 0.17.0 → 0.19.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 +3 -2
- package/src/Base.astro +54 -50
- package/src/ConfirmSheet.astro +126 -0
- package/src/Posts.astro +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./BackToZone.astro": "./src/BackToZone.astro",
|
|
19
19
|
"./Posts.astro": "./src/Posts.astro",
|
|
20
20
|
"./Toolbar.astro": "./src/Toolbar.astro",
|
|
21
|
-
"./Profile.astro": "./src/Profile.astro"
|
|
21
|
+
"./Profile.astro": "./src/Profile.astro",
|
|
22
|
+
"./ConfirmSheet.astro": "./src/ConfirmSheet.astro"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
25
|
"@justai/ui": ">=0.2.0",
|
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:
|
|
9
|
-
*
|
|
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,65 +235,65 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
231
235
|
</head>
|
|
232
236
|
<body>
|
|
233
237
|
<slot />
|
|
234
|
-
{/* Consent
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
the
|
|
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
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
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" 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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
var
|
|
286
|
-
|
|
287
|
-
el.
|
|
288
|
-
|
|
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
|
+
// returnValue survives a close, so reset before every open or a stale answer replays.
|
|
265
|
+
var open = function () { if (!el.open) { el.returnValue = ""; el.showModal(); } };
|
|
266
|
+
el.addEventListener("close", function () {
|
|
267
|
+
var rv = el.returnValue, first = autoAsk; autoAsk = false;
|
|
268
|
+
// "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;
|
|
289
274
|
gtag("consent", "update", { analytics_storage: v });
|
|
290
275
|
try { localStorage.setItem("consent", v); } catch (_) {}
|
|
291
|
-
el.classList.remove("show");
|
|
292
276
|
});
|
|
277
|
+
// THE CHANGE-YOUR-MIND DOOR — and it is universal, not a consent-region courtesy. Trust is
|
|
278
|
+
// the choice staying reachable: the Posts cut's footer carries a hidden anchor
|
|
279
|
+
// ([data-consent-open]); this script labels it, reveals it, and wires it to reopen the
|
|
280
|
+
// sheet for ANY visitor in ANY region, stored choice or not. A non-EEA visitor gets a real
|
|
281
|
+
// opt-out they were never offered before; an EEA visitor can reverse either answer. The
|
|
282
|
+
// auto-ask below keeps its narrow gates — the DOOR has none.
|
|
283
|
+
var doors = document.querySelectorAll("[data-consent-open]");
|
|
284
|
+
for (var i = 0; i < doors.length; i++) {
|
|
285
|
+
var a = doors[i];
|
|
286
|
+
if (!a.textContent) a.textContent = el.getAttribute("data-choice-label") || "privacy choice";
|
|
287
|
+
a.hidden = false;
|
|
288
|
+
var w = a.closest("[data-consent-door]"); if (w) w.hidden = false;
|
|
289
|
+
a.addEventListener("click", function (ev) { ev.preventDefault(); open(); });
|
|
290
|
+
}
|
|
291
|
+
// auto-ask: only the first visit, only where consent is legally asked, only on a content page
|
|
292
|
+
if (localStorage.getItem("consent")) return;
|
|
293
|
+
if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
|
|
294
|
+
if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
|
|
295
|
+
// the first second belongs to the kernel; the question arrives on the second beat
|
|
296
|
+
setTimeout(function () { if (!el.open) { autoAsk = true; open(); } }, 700);
|
|
293
297
|
} catch (_) {}
|
|
294
298
|
})();
|
|
295
299
|
</script>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
// ░░ CONFIRM SHEET — the fleet's ONE confirmation dialog: an iOS-style action sheet. ░░
|
|
3
|
+
//
|
|
4
|
+
// Born in the /me vault ("Forget everything?") and promoted to the framework because a confirmation
|
|
5
|
+
// is a moment of trust, and trust surfaces must not drift: one persona confirming destructive
|
|
6
|
+
// actions with one dialect while another improvises a different one is how a fleet stops feeling
|
|
7
|
+
// like one hand made it. Anything that needs "are you sure?" uses THIS.
|
|
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).
|
|
13
|
+
//
|
|
14
|
+
// WHY native <dialog> + showModal(), and not a hand-rolled scrim: the /me original had the visible
|
|
15
|
+
// half of Apple right and the INVISIBLE half missing — no focus trap, no Escape, no focus restore.
|
|
16
|
+
// The platform ships all of that, plus the top layer (no z-index wars, ever) and ::backdrop, for
|
|
17
|
+
// free. The component's own script only adds the one thing <dialog> lacks: click-on-backdrop closes.
|
|
18
|
+
//
|
|
19
|
+
// HOW A CONSUMER USES IT (the whole contract):
|
|
20
|
+
// <ConfirmSheet id="eraseConfirm" title={t.confirmTitle} body={t.confirmBody}
|
|
21
|
+
// actionLabel={t.erase} cancelLabel={t.cancel} destructive />
|
|
22
|
+
// open: document.getElementById("eraseConfirm").showModal()
|
|
23
|
+
// outcome: dialog.addEventListener("close", () => dialog.returnValue === "confirm" ? ... : ...)
|
|
24
|
+
// Both buttons live in a <form method="dialog">, so closing is native: Cancel returns cancelValue
|
|
25
|
+
// (default ""), the action returns "confirm", Escape returns "". No custom event vocabulary to
|
|
26
|
+
// learn or misspell. NOTE for repeat-open consumers: returnValue survives a close — reset it to ""
|
|
27
|
+
// before each showModal(), or a stale "confirm" will masquerade as this open's answer.
|
|
28
|
+
interface Props {
|
|
29
|
+
/** The dialog element's id — the consumer opens it via getElementById(id).showModal(). */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Optional. A title-less sheet is a single spoken line (the consent ask is one): the body then
|
|
32
|
+
* wears the primary text colour and labels the dialog itself. */
|
|
33
|
+
title?: string;
|
|
34
|
+
body: string;
|
|
35
|
+
actionLabel: string;
|
|
36
|
+
cancelLabel: string;
|
|
37
|
+
/** Paint the action iOS system red — for actions that delete or end something. */
|
|
38
|
+
destructive?: boolean;
|
|
39
|
+
/** What returnValue reports when the safe button is chosen. Default "" — indistinguishable from
|
|
40
|
+
* 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). */
|
|
43
|
+
cancelValue?: string;
|
|
44
|
+
/** Anything else (data-* attributes, aria overrides) lands on the <dialog> itself. */
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
const { id, title, body, actionLabel, cancelLabel, destructive = false, cancelValue = "", ...rest } = Astro.props;
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<dialog class="confirm" id={id} aria-labelledby={title ? `${id}-t` : `${id}-b`} aria-describedby={`${id}-b`} {...rest}>
|
|
51
|
+
<form method="dialog" class="confirm-form">
|
|
52
|
+
<div class="confirm-group">
|
|
53
|
+
<div class="confirm-msg">
|
|
54
|
+
{title && <strong id={`${id}-t`}>{title}</strong>}
|
|
55
|
+
<span id={`${id}-b`}>{body}</span>
|
|
56
|
+
</div>
|
|
57
|
+
<button class:list={["confirm-act", { destructive }]} id={`${id}-act`} value="confirm">{actionLabel}</button>
|
|
58
|
+
</div>
|
|
59
|
+
<button class="confirm-act confirm-cancel" id={`${id}-cancel`} value={cancelValue} formnovalidate autofocus>{cancelLabel}</button>
|
|
60
|
+
</form>
|
|
61
|
+
</dialog>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
/* The dialog sits in the browser's top layer — above every z-index in every persona, by law. */
|
|
65
|
+
.confirm {
|
|
66
|
+
/* dock to the bottom like an iOS sheet (native <dialog> centers by default) */
|
|
67
|
+
margin: auto auto max(12px, env(safe-area-inset-bottom));
|
|
68
|
+
width: min(400px, calc(100vw - 24px));
|
|
69
|
+
padding: 0;
|
|
70
|
+
border: 0;
|
|
71
|
+
background: none;
|
|
72
|
+
overflow: visible;
|
|
73
|
+
}
|
|
74
|
+
.confirm::backdrop {
|
|
75
|
+
background: rgba(0, 0, 0, 0.4);
|
|
76
|
+
backdrop-filter: blur(4px);
|
|
77
|
+
-webkit-backdrop-filter: blur(4px);
|
|
78
|
+
}
|
|
79
|
+
.confirm[open] .confirm-form { animation: confirm-up 0.4s var(--ease-spring) both; }
|
|
80
|
+
.confirm[open]::backdrop { animation: confirm-fade 0.2s var(--ease-out) both; }
|
|
81
|
+
|
|
82
|
+
.confirm-form { display: flex; flex-direction: column; gap: 8px; }
|
|
83
|
+
.confirm-group {
|
|
84
|
+
border-radius: 18px; overflow: hidden; border: 1px solid var(--border);
|
|
85
|
+
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
|
86
|
+
backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
|
|
87
|
+
}
|
|
88
|
+
.confirm-msg { padding: 18px 18px 16px; text-align: center; display: flex; flex-direction: column; gap: 5px; }
|
|
89
|
+
.confirm-msg strong { font-size: var(--fs-md); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
|
|
90
|
+
.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); }
|
|
94
|
+
.confirm-act {
|
|
95
|
+
width: 100%; padding: 15px; border: 0; border-block-start: 1px solid var(--border);
|
|
96
|
+
/* --confirm-accent: the declared hook for personas whose accent is too BRIGHT to read as text
|
|
97
|
+
on the frosted card in light mode (cleanmylink's mint, howlong's amber, whatismyip's mint —
|
|
98
|
+
the same three that once hooked the old filled consent button). They set a theme-aware
|
|
99
|
+
darker tone of their own hue; everyone else says nothing. */
|
|
100
|
+
background: none; font: 600 var(--fs-md) var(--font, inherit); color: var(--confirm-accent, var(--accent)); cursor: pointer;
|
|
101
|
+
transition: background 0.15s;
|
|
102
|
+
}
|
|
103
|
+
.confirm-act:active { background: color-mix(in srgb, var(--text) 9%, transparent); }
|
|
104
|
+
/* iOS system red — the one colour that means "this ends something" on every persona alike */
|
|
105
|
+
.confirm-act.destructive { color: #ff453a; }
|
|
106
|
+
/* the safe choice wears the boldest face, in its own detached card */
|
|
107
|
+
.confirm-cancel {
|
|
108
|
+
border: 1px solid var(--border); border-radius: 18px;
|
|
109
|
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
110
|
+
backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
|
|
111
|
+
font-weight: 700; color: var(--text);
|
|
112
|
+
}
|
|
113
|
+
@keyframes confirm-up { from { transform: translateY(20px); opacity: 0; } }
|
|
114
|
+
@keyframes confirm-fade { from { opacity: 0; } }
|
|
115
|
+
@media (prefers-reduced-motion: reduce) {
|
|
116
|
+
.confirm[open] .confirm-form, .confirm[open]::backdrop { animation: none; }
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
119
|
+
|
|
120
|
+
<script>
|
|
121
|
+
// The one behavior <dialog> lacks natively: a tap on the backdrop cancels. A click whose target is
|
|
122
|
+
// the dialog element itself can only be the backdrop area (the form fills the visible sheet).
|
|
123
|
+
for (const d of document.querySelectorAll<HTMLDialogElement>("dialog.confirm")) {
|
|
124
|
+
d.addEventListener("click", (e) => { if (e.target === d) d.close(); });
|
|
125
|
+
}
|
|
126
|
+
</script>
|
package/src/Posts.astro
CHANGED
|
@@ -47,7 +47,13 @@ const vars = [
|
|
|
47
47
|
>
|
|
48
48
|
<!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
|
|
49
49
|
<div id="feed"></div>
|
|
50
|
-
<footer class="z-foot"
|
|
50
|
+
<footer class="z-foot">
|
|
51
|
+
<a href="https://justai.pro">justai.pro</a> · {footer}
|
|
52
|
+
{/* the change-your-mind door: hidden until the shell's consent script labels + reveals it
|
|
53
|
+
(Base owns the behavior and the localized label; this cut only owns the placement). On a
|
|
54
|
+
page with no analytics — and so no consent banner — it simply never appears. */}
|
|
55
|
+
<span class="z-choice" hidden data-consent-door> · <a href="#" data-consent-open></a></span>
|
|
56
|
+
</footer>
|
|
51
57
|
</section>
|
|
52
58
|
|
|
53
59
|
<style>
|