@justai/cuts 0.1.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 +12 -0
- package/src/Base.astro +203 -0
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@justai/cuts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": { "type": "git", "url": "git+https://github.com/justai-pro/web.git", "directory": "packages/cuts" },
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": ["src"],
|
|
9
|
+
"exports": { "./Base.astro": "./src/Base.astro" },
|
|
10
|
+
"peerDependencies": { "astro": ">=5", "@justai/ui": ">=0.2.0" },
|
|
11
|
+
"publishConfig": { "access": "public" }
|
|
12
|
+
}
|
package/src/Base.astro
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <Base> — the persona-page shell.
|
|
4
|
+
*
|
|
5
|
+
* Measured before it was written: across the nine app.zone tool personas this file was 154 lines,
|
|
6
|
+
* of which 132 were IDENTICAL. The 22 that differed were all identity — the host, the theme colour,
|
|
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 a head slot (time self-hosts Geist).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT DELIBERATELY DOES NOT OWN.
|
|
12
|
+
*
|
|
13
|
+
* The locale list. `locales` and `defaultLocale` are PROPS, read from the consuming repo's
|
|
14
|
+
* src/i18n/index.ts, and this package ships neither. A framework that carried its own list would be
|
|
15
|
+
* a second declaration site for the one thing this ecosystem has already been bitten by twice —
|
|
16
|
+
* an inert astro.config block that drifted to twelve while a thirteenth locale shipped, and a
|
|
17
|
+
* literal array in this very script that nothing checked. There is one home for that list and it is
|
|
18
|
+
* not here.
|
|
19
|
+
*
|
|
20
|
+
* The dictionaries. Same reason, stronger: the machinery is shared, the CONTENT is the persona's.
|
|
21
|
+
*/
|
|
22
|
+
import { dir as dirOf } from "@justai/ui/locale";
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
lang: string;
|
|
26
|
+
t: Record<string, string>;
|
|
27
|
+
/** The persona's own origin, e.g. https://tabata.app.zone — canonical, hreflang and OG all read it. */
|
|
28
|
+
site: string;
|
|
29
|
+
/** From the consuming repo's src/i18n — never from this package. */
|
|
30
|
+
locales: readonly string[];
|
|
31
|
+
defaultLocale: string;
|
|
32
|
+
/** A data: URI. The persona's mark; the one asset a shell cannot supply. */
|
|
33
|
+
favicon: string;
|
|
34
|
+
/** Defaults are the fleet majority, measured. Override when the persona genuinely differs. */
|
|
35
|
+
themeDark?: string;
|
|
36
|
+
themeLight?: string;
|
|
37
|
+
/** app.zone's property. justai.pro is its own apex and passes its own. */
|
|
38
|
+
gaId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
lang, t, site, locales, defaultLocale, favicon,
|
|
43
|
+
themeDark = "#000000",
|
|
44
|
+
themeLight = "#f2f2f7",
|
|
45
|
+
gaId = "G-13L3BP6XCW",
|
|
46
|
+
} = Astro.props;
|
|
47
|
+
|
|
48
|
+
const d = dirOf(lang);
|
|
49
|
+
const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<!doctype html>
|
|
53
|
+
<html lang={lang} dir={d}>
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8" />
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
57
|
+
<link rel="preconnect" href="https://api.justai.pro" crossorigin />
|
|
58
|
+
<!-- detail: reveal only after we've positioned at the Kernel — no scroll flash.
|
|
59
|
+
Gated behind .js so it's always visible if JS is off. -->
|
|
60
|
+
<!-- justai auth: capture the SSO token here, synchronously, BEFORE the scroll-spy rewrites the hash
|
|
61
|
+
to #kernel — otherwise it clobbers #auth_token before the deferred sdk.js can read it. -->
|
|
62
|
+
<script is:inline>(function(){try{var h=location.hash||"";var m=h.match(/(?:^#|&)auth_token=([^&]+)/);if(m){localStorage.setItem("justai_token",decodeURIComponent(m[1]))}else if(/(?:^#|&)auth_signout=1\b/.test(h)){localStorage.removeItem("justai_token")}else{return}history.replaceState(null,"",location.pathname+location.search)}catch(e){}})();</script>
|
|
63
|
+
<script is:inline>document.documentElement.classList.add("js")</script>
|
|
64
|
+
<style is:inline>html.js body{opacity:0}html.js body.ready{opacity:1}</style>
|
|
65
|
+
<!-- The locale list is INJECTED, never typed. It used to be a literal array here — a second
|
|
66
|
+
declaration beside src/i18n/index.ts, kept in agreement by hand, in the one place where
|
|
67
|
+
being wrong is invisible: a locale missing from this list simply never auto-routes, and
|
|
68
|
+
nothing anywhere goes red. The same shape in astro.config had already drifted (twelve
|
|
69
|
+
listed while /it/ was live), which is why this one is wired to the real list instead of
|
|
70
|
+
being checked against it. -->
|
|
71
|
+
<script is:inline define:vars={{ supported: locales, def: defaultLocale }}>
|
|
72
|
+
// Auto-route to the visitor's language: a saved choice wins; otherwise the browser
|
|
73
|
+
// language on first visit. Only on the default (en) root — explicit /xx/ links are
|
|
74
|
+
// respected, and crawlers + hreflang still index every locale.
|
|
75
|
+
(function () {
|
|
76
|
+
try {
|
|
77
|
+
var path = location.pathname;
|
|
78
|
+
if (path !== "/" && path !== "/index.html") return;
|
|
79
|
+
var params = new URLSearchParams(location.search);
|
|
80
|
+
// Priority: ?lang hint from app.zone (saved as the choice) > saved pref > browser.
|
|
81
|
+
var hint = params.get("lang");
|
|
82
|
+
var target;
|
|
83
|
+
if (hint && supported.indexOf(hint) !== -1) {
|
|
84
|
+
target = hint;
|
|
85
|
+
localStorage.setItem("lang", hint);
|
|
86
|
+
} else {
|
|
87
|
+
target = localStorage.getItem("lang");
|
|
88
|
+
if (!target) {
|
|
89
|
+
var langs = navigator.languages || [navigator.language || def];
|
|
90
|
+
for (var i = 0; i < langs.length; i++) {
|
|
91
|
+
var c = (langs[i] || "").toLowerCase().split("-")[0];
|
|
92
|
+
if (supported.indexOf(c) !== -1) { target = c; break; }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (target && target !== def && supported.indexOf(target) !== -1) {
|
|
97
|
+
location.replace("/" + target + "/" + location.search); // preserve ?ref
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {}
|
|
100
|
+
})();
|
|
101
|
+
</script>
|
|
102
|
+
<title>{t.metaTitle}</title>
|
|
103
|
+
<meta name="description" content={t.metaDescription} />
|
|
104
|
+
<meta property="og:title" content={t.metaTitle} />
|
|
105
|
+
<meta property="og:description" content={t.metaDescription} />
|
|
106
|
+
<meta property="og:type" content="website" />
|
|
107
|
+
<meta property="og:image" content={new URL("/og.jpg", Astro.site)} />
|
|
108
|
+
<meta property="og:image:type" content="image/jpeg" />
|
|
109
|
+
<meta property="og:image:width" content="1200" />
|
|
110
|
+
<meta property="og:image:height" content="630" />
|
|
111
|
+
<meta property="og:url" content={new URL(Astro.url.pathname, Astro.site)} />
|
|
112
|
+
<meta property="og:site_name" content={new URL(site).host} />
|
|
113
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
114
|
+
<meta name="twitter:image" content={new URL("/og.jpg", Astro.site)} />
|
|
115
|
+
<meta name="theme-color" content={themeDark} media="(prefers-color-scheme: dark)" />
|
|
116
|
+
<meta name="theme-color" content={themeLight} media="(prefers-color-scheme: light)" />
|
|
117
|
+
<link rel="canonical" href={`${site}${localePath(lang)}`} />
|
|
118
|
+
{locales.map((l) => (
|
|
119
|
+
<link rel="alternate" hreflang={l} href={`${site}${localePath(l)}`} />
|
|
120
|
+
))}
|
|
121
|
+
<link rel="alternate" hreflang="x-default" href={`${site}/`} />
|
|
122
|
+
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
123
|
+
<link
|
|
124
|
+
rel="icon"
|
|
125
|
+
href={favicon}
|
|
126
|
+
/>
|
|
127
|
+
<!-- PWA: Workbox service worker (precache + offline) + installable manifest -->
|
|
128
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
129
|
+
<link rel="apple-touch-icon" href="/icon-192.png" />
|
|
130
|
+
<script is:inline>
|
|
131
|
+
if ("serviceWorker" in navigator) addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
|
|
132
|
+
</script>
|
|
133
|
+
<!-- Google Analytics 4 — anonymous (ad signals + personalization OFF), lazy-loaded after idle.
|
|
134
|
+
analytics_storage granted: direct user counting (cookieless modeling needs high traffic to
|
|
135
|
+
surface, so it shows nothing at low/launch volume).
|
|
136
|
+
|
|
137
|
+
gaId comes through define:vars because this script runs in the BROWSER. An is:inline block
|
|
138
|
+
has no access to component scope, so a bare `gaId` here would be undefined at runtime and
|
|
139
|
+
every persona would report to a property that does not exist — silently, with no failed
|
|
140
|
+
request and no console error, which is the only kind of analytics bug nobody finds. -->
|
|
141
|
+
<script is:inline define:vars={{ gaId }}>
|
|
142
|
+
window.dataLayer = window.dataLayer || [];
|
|
143
|
+
function gtag() { dataLayer.push(arguments); }
|
|
144
|
+
gtag("js", new Date());
|
|
145
|
+
gtag("consent", "default", { ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied", analytics_storage: "granted" });
|
|
146
|
+
// EEA + UK + CH: deny analytics until the visitor consents (the banner below grants it).
|
|
147
|
+
gtag("consent", "default", { region: ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB","CH"], ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied", analytics_storage: "denied" });
|
|
148
|
+
try { var cc0 = localStorage.getItem("consent"); if (cc0) gtag("consent", "update", { analytics_storage: cc0 }); } catch (e) {}
|
|
149
|
+
gtag("config", gaId, { cookie_expires: 60 * 60 * 24 * 60, /* two months — matches the GA data retention; the cookie must not outlive the data it labels (foundations/15) */ allow_google_signals: false, allow_ad_personalization_signals: false });
|
|
150
|
+
addEventListener("load", function () {
|
|
151
|
+
(window.requestIdleCallback || function (f) { setTimeout(f, 1200); })(function () {
|
|
152
|
+
var s = document.createElement("script");
|
|
153
|
+
s.async = true;
|
|
154
|
+
s.src = "https://www.googletagmanager.com/gtag/js?id=" + gaId;
|
|
155
|
+
document.head.appendChild(s);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
</script>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<slot />
|
|
162
|
+
<!-- Consent banner — shown only to European-timezone visitors with no stored choice. The
|
|
163
|
+
Consent Mode region default already denies analytics for the EEA/UK until "Allow". -->
|
|
164
|
+
<div id="cc" class="cc" role="dialog" aria-label={t.chromePrivacy}>
|
|
165
|
+
<div class="cc-card">
|
|
166
|
+
<p class="cc-text">{t.consentText}</p>
|
|
167
|
+
<div class="cc-row">
|
|
168
|
+
<button type="button" class="cc-btn" data-consent="denied">{t.consentDecline}</button>
|
|
169
|
+
<button type="button" class="cc-btn cc-ok" data-consent="granted">{t.consentAllow}</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<style>
|
|
174
|
+
.cc { display: none; position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 300; justify-content: center; padding: 0 12px; pointer-events: none; }
|
|
175
|
+
.cc.show { display: flex; }
|
|
176
|
+
.cc-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: cc-up 0.45s var(--ease-spring) both; }
|
|
177
|
+
.cc-text { font-size: var(--fs-xs); color: var(--text); line-height: 1.5; }
|
|
178
|
+
.cc-row { display: flex; gap: 8px; justify-content: flex-end; }
|
|
179
|
+
.cc-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); }
|
|
180
|
+
.cc-btn:active { transform: scale(0.94); }
|
|
181
|
+
.cc-ok { background: var(--accent-press); border-color: var(--accent-press); color: #fff; }
|
|
182
|
+
@keyframes cc-up { from { transform: translateY(24px); opacity: 0; } }
|
|
183
|
+
@media (prefers-reduced-motion: reduce) { .cc-card { animation: none; } }
|
|
184
|
+
</style>
|
|
185
|
+
<script is:inline>
|
|
186
|
+
(function () {
|
|
187
|
+
try {
|
|
188
|
+
if (localStorage.getItem("consent")) return;
|
|
189
|
+
if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
|
|
190
|
+
if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
|
|
191
|
+
var el = document.getElementById("cc"); if (!el) return;
|
|
192
|
+
el.classList.add("show");
|
|
193
|
+
el.addEventListener("click", function (e) {
|
|
194
|
+
var v = e.target.getAttribute("data-consent"); if (!v) return;
|
|
195
|
+
gtag("consent", "update", { analytics_storage: v });
|
|
196
|
+
try { localStorage.setItem("consent", v); } catch (_) {}
|
|
197
|
+
el.classList.remove("show");
|
|
198
|
+
});
|
|
199
|
+
} catch (_) {}
|
|
200
|
+
})();
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|