@justai/cuts 0.16.0 → 0.18.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 +37 -5
- package/src/ConfirmSheet.astro +108 -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.18.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
|
@@ -184,6 +184,18 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
184
184
|
ten that measure themselves exactly nothing. */}
|
|
185
185
|
{gaId && (
|
|
186
186
|
<script is:inline define:vars={{ gaId }}>
|
|
187
|
+
// A non-production page measures NOTHING. Found by the first read through the analytics MCP:
|
|
188
|
+
// 127.0.0.1 + localhost were the property's TOP "persona" (562 fake users in 28 days — local
|
|
189
|
+
// dev, plus every headless verification sweep loading the built HTML). The guard also covers
|
|
190
|
+
// the edge's dev- preview hosts: previews are rehearsals, not visits. gtag is still defined
|
|
191
|
+
// (as the same queue-stub) even on a silenced host, so the consent script can never hit
|
|
192
|
+
// "gtag is not defined" — the exact trap documented below. The whole block sits in an if
|
|
193
|
+
// rather than behind an early return: Astro 7's compiler rejects a top-level `return` in an
|
|
194
|
+
// inline script at BUILD time (the define:vars IIFE only exists later, at runtime).
|
|
195
|
+
window.gtag = window.gtag || function () { (window.dataLayer = window.dataLayer || []).push(arguments); };
|
|
196
|
+
var __h = location.hostname;
|
|
197
|
+
var __nonprod = __h === "localhost" || __h === "127.0.0.1" || __h === "::1" || __h === "[::1]" || __h.indexOf("dev-") === 0;
|
|
198
|
+
if (!__nonprod) {
|
|
187
199
|
window.dataLayer = window.dataLayer || [];
|
|
188
200
|
// gtag is put on WINDOW, not declared as `function gtag`, and this is load-bearing: `define:vars`
|
|
189
201
|
// wraps this inline script in an IIFE to scope the injected `gaId`, so a `function gtag` here
|
|
@@ -213,6 +225,7 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
213
225
|
document.head.appendChild(s);
|
|
214
226
|
});
|
|
215
227
|
});
|
|
228
|
+
}
|
|
216
229
|
</script>
|
|
217
230
|
)}
|
|
218
231
|
</head>
|
|
@@ -232,7 +245,7 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
232
245
|
widget's, never anyone else's. A generic global class is a landmine that the shell hides from
|
|
233
246
|
the persona until a feed goes white — see [[the-shell-guarantees-you-cannot-see]]. */}
|
|
234
247
|
{gaId && (
|
|
235
|
-
<div id="consent" class="consent" role="dialog" aria-label={t.chromePrivacy}>
|
|
248
|
+
<div id="consent" class="consent" role="dialog" aria-label={t.chromePrivacy} data-choice-label={t.consentChoice}>
|
|
236
249
|
<div class="consent-card">
|
|
237
250
|
<p class="consent-text">{t.consentText}</p>
|
|
238
251
|
<div class="consent-row">
|
|
@@ -263,17 +276,36 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
263
276
|
<script is:inline>
|
|
264
277
|
(function () {
|
|
265
278
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
279
|
+
// same non-production guard as the analytics block: no measurement → nothing to consent to
|
|
280
|
+
var h = location.hostname;
|
|
281
|
+
if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]" || h.indexOf("dev-") === 0) return;
|
|
269
282
|
var el = document.getElementById("consent"); if (!el) return;
|
|
270
|
-
el.classList.add("show");
|
|
283
|
+
var show = function () { el.classList.add("show"); };
|
|
271
284
|
el.addEventListener("click", function (e) {
|
|
272
285
|
var v = e.target.getAttribute("data-consent"); if (!v) return;
|
|
273
286
|
gtag("consent", "update", { analytics_storage: v });
|
|
274
287
|
try { localStorage.setItem("consent", v); } catch (_) {}
|
|
275
288
|
el.classList.remove("show");
|
|
276
289
|
});
|
|
290
|
+
// THE CHANGE-YOUR-MIND DOOR — and it is universal, not a consent-region courtesy. Trust is
|
|
291
|
+
// the choice staying reachable: the Posts cut's footer carries a hidden anchor
|
|
292
|
+
// ([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
|
|
294
|
+
// 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.
|
|
296
|
+
var doors = document.querySelectorAll("[data-consent-open]");
|
|
297
|
+
for (var i = 0; i < doors.length; i++) {
|
|
298
|
+
var a = doors[i];
|
|
299
|
+
if (!a.textContent) a.textContent = el.getAttribute("data-choice-label") || "privacy choice";
|
|
300
|
+
a.hidden = false;
|
|
301
|
+
var w = a.closest("[data-consent-door]"); if (w) w.hidden = false;
|
|
302
|
+
a.addEventListener("click", function (ev) { ev.preventDefault(); show(); });
|
|
303
|
+
}
|
|
304
|
+
// auto-show: only the first visit, only where consent is legally asked, only on a content page
|
|
305
|
+
if (localStorage.getItem("consent")) return;
|
|
306
|
+
if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
|
|
307
|
+
if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
|
|
308
|
+
show();
|
|
277
309
|
} catch (_) {}
|
|
278
310
|
})();
|
|
279
311
|
</script>
|
|
@@ -0,0 +1,108 @@
|
|
|
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 "", the
|
|
25
|
+
// action returns "confirm", Escape returns "". No custom event vocabulary to learn or misspell.
|
|
26
|
+
interface Props {
|
|
27
|
+
/** The dialog element's id — the consumer opens it via getElementById(id).showModal(). */
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
actionLabel: string;
|
|
32
|
+
cancelLabel: string;
|
|
33
|
+
/** Paint the action iOS system red — for actions that delete or end something. */
|
|
34
|
+
destructive?: boolean;
|
|
35
|
+
}
|
|
36
|
+
const { id, title, body, actionLabel, cancelLabel, destructive = false } = Astro.props;
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<dialog class="confirm" id={id} aria-labelledby={`${id}-t`} aria-describedby={`${id}-b`}>
|
|
40
|
+
<form method="dialog" class="confirm-form">
|
|
41
|
+
<div class="confirm-group">
|
|
42
|
+
<div class="confirm-msg">
|
|
43
|
+
<strong id={`${id}-t`}>{title}</strong>
|
|
44
|
+
<span id={`${id}-b`}>{body}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<button class:list={["confirm-act", { destructive }]} id={`${id}-act`} value="confirm">{actionLabel}</button>
|
|
47
|
+
</div>
|
|
48
|
+
<button class="confirm-act confirm-cancel" id={`${id}-cancel`} value="" formnovalidate autofocus>{cancelLabel}</button>
|
|
49
|
+
</form>
|
|
50
|
+
</dialog>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
/* The dialog sits in the browser's top layer — above every z-index in every persona, by law. */
|
|
54
|
+
.confirm {
|
|
55
|
+
/* dock to the bottom like an iOS sheet (native <dialog> centers by default) */
|
|
56
|
+
margin: auto auto max(12px, env(safe-area-inset-bottom));
|
|
57
|
+
width: min(400px, calc(100vw - 24px));
|
|
58
|
+
padding: 0;
|
|
59
|
+
border: 0;
|
|
60
|
+
background: none;
|
|
61
|
+
overflow: visible;
|
|
62
|
+
}
|
|
63
|
+
.confirm::backdrop {
|
|
64
|
+
background: rgba(0, 0, 0, 0.4);
|
|
65
|
+
backdrop-filter: blur(4px);
|
|
66
|
+
-webkit-backdrop-filter: blur(4px);
|
|
67
|
+
}
|
|
68
|
+
.confirm[open] .confirm-form { animation: confirm-up 0.4s var(--ease-spring) both; }
|
|
69
|
+
.confirm[open]::backdrop { animation: confirm-fade 0.2s var(--ease-out) both; }
|
|
70
|
+
|
|
71
|
+
.confirm-form { display: flex; flex-direction: column; gap: 8px; }
|
|
72
|
+
.confirm-group {
|
|
73
|
+
border-radius: 18px; overflow: hidden; border: 1px solid var(--border);
|
|
74
|
+
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
|
75
|
+
backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
|
|
76
|
+
}
|
|
77
|
+
.confirm-msg { padding: 18px 18px 16px; text-align: center; display: flex; flex-direction: column; gap: 5px; }
|
|
78
|
+
.confirm-msg strong { font-size: var(--fs-md); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
|
|
79
|
+
.confirm-msg span { font-size: var(--fs-xs); color: var(--text-dim); line-height: 1.45; }
|
|
80
|
+
.confirm-act {
|
|
81
|
+
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;
|
|
83
|
+
transition: background 0.15s;
|
|
84
|
+
}
|
|
85
|
+
.confirm-act:active { background: color-mix(in srgb, var(--text) 9%, transparent); }
|
|
86
|
+
/* iOS system red — the one colour that means "this ends something" on every persona alike */
|
|
87
|
+
.confirm-act.destructive { color: #ff453a; }
|
|
88
|
+
/* the safe choice wears the boldest face, in its own detached card */
|
|
89
|
+
.confirm-cancel {
|
|
90
|
+
border: 1px solid var(--border); border-radius: 18px;
|
|
91
|
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
92
|
+
backdrop-filter: blur(30px) saturate(1.5); -webkit-backdrop-filter: blur(30px) saturate(1.5);
|
|
93
|
+
font-weight: 700; color: var(--text);
|
|
94
|
+
}
|
|
95
|
+
@keyframes confirm-up { from { transform: translateY(20px); opacity: 0; } }
|
|
96
|
+
@keyframes confirm-fade { from { opacity: 0; } }
|
|
97
|
+
@media (prefers-reduced-motion: reduce) {
|
|
98
|
+
.confirm[open] .confirm-form, .confirm[open]::backdrop { animation: none; }
|
|
99
|
+
}
|
|
100
|
+
</style>
|
|
101
|
+
|
|
102
|
+
<script>
|
|
103
|
+
// The one behavior <dialog> lacks natively: a tap on the backdrop cancels. A click whose target is
|
|
104
|
+
// the dialog element itself can only be the backdrop area (the form fills the visible sheet).
|
|
105
|
+
for (const d of document.querySelectorAll<HTMLDialogElement>("dialog.confirm")) {
|
|
106
|
+
d.addEventListener("click", (e) => { if (e.target === d) d.close(); });
|
|
107
|
+
}
|
|
108
|
+
</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>
|