@justai/cuts 0.5.1 → 0.6.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 +27 -18
- package/src/LangPicker.astro +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"src"
|
|
14
14
|
],
|
|
15
15
|
"exports": {
|
|
16
|
-
"./Base.astro": "./src/Base.astro"
|
|
16
|
+
"./Base.astro": "./src/Base.astro",
|
|
17
|
+
"./LangPicker.astro": "./src/LangPicker.astro"
|
|
17
18
|
},
|
|
18
19
|
"peerDependencies": {
|
|
19
20
|
"@justai/ui": ">=0.2.0",
|
package/src/Base.astro
CHANGED
|
@@ -221,25 +221,34 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
221
221
|
{/* Consent banner — shown only to European-timezone visitors with no stored choice. The
|
|
222
222
|
Consent Mode region default already denies analytics for the EEA/UK until "Allow". Gated on
|
|
223
223
|
gaId with the analytics block above: a persona that runs no analytics has nothing to consent
|
|
224
|
-
to, so it renders no banner.
|
|
224
|
+
to, so it renders no banner.
|
|
225
|
+
|
|
226
|
+
The <style> below sits INSIDE this {gaId && (...)} expression, so Astro does NOT scope it — a
|
|
227
|
+
style that is a direct static child of a component gets a data-astro-cid hash, but one nested
|
|
228
|
+
in a JSX expression is emitted verbatim, GLOBAL. So every selector here is fleet-wide and MUST
|
|
229
|
+
carry its own namespace. It once used a two-letter `.cc` for the banner container; the feed
|
|
230
|
+
loader emits `<div class="cc">` for its content column, so the global `.cc { display: none }`
|
|
231
|
+
hid the feed's text on EVERY persona. The whole family is `consent-*` now: unmistakably this
|
|
232
|
+
widget's, never anyone else's. A generic global class is a landmine that the shell hides from
|
|
233
|
+
the persona until a feed goes white — see [[the-shell-guarantees-you-cannot-see]]. */}
|
|
225
234
|
{gaId && (
|
|
226
|
-
<div id="
|
|
227
|
-
<div class="
|
|
228
|
-
<p class="
|
|
229
|
-
<div class="
|
|
230
|
-
<button type="button" class="
|
|
231
|
-
<button type="button" class="
|
|
235
|
+
<div id="consent" class="consent" role="dialog" aria-label={t.chromePrivacy}>
|
|
236
|
+
<div class="consent-card">
|
|
237
|
+
<p class="consent-text">{t.consentText}</p>
|
|
238
|
+
<div class="consent-row">
|
|
239
|
+
<button type="button" class="consent-btn" data-consent="denied">{t.consentDecline}</button>
|
|
240
|
+
<button type="button" class="consent-btn consent-ok" data-consent="granted">{t.consentAllow}</button>
|
|
232
241
|
</div>
|
|
233
242
|
</div>
|
|
234
243
|
</div>
|
|
235
244
|
<style>
|
|
236
|
-
.
|
|
237
|
-
.
|
|
238
|
-
.
|
|
239
|
-
.
|
|
240
|
-
.
|
|
241
|
-
.
|
|
242
|
-
.
|
|
245
|
+
.consent { display: none; position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 300; justify-content: center; padding: 0 12px; pointer-events: none; }
|
|
246
|
+
.consent.show { display: flex; }
|
|
247
|
+
.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; }
|
|
248
|
+
.consent-text { font-size: var(--fs-xs); color: var(--text); line-height: 1.5; }
|
|
249
|
+
.consent-row { display: flex; gap: 8px; justify-content: flex-end; }
|
|
250
|
+
.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); }
|
|
251
|
+
.consent-btn:active { transform: scale(0.94); }
|
|
243
252
|
/* The consent button is the one place the fleet genuinely disagreed, and the disagreement is
|
|
244
253
|
about CONTRAST rather than taste. Seven personas have dark accents and take white text on
|
|
245
254
|
--accent-press. cleanmylink and howlong have bright ones — mint and amber — where white
|
|
@@ -247,9 +256,9 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
247
256
|
Two hooks with the majority as the default, so the seven say nothing and the two say
|
|
248
257
|
exactly what they already verified. This is the styling contract working as designed:
|
|
249
258
|
colour is the persona's, and a real difference gets a declared hook rather than a fork. */
|
|
250
|
-
.
|
|
251
|
-
@keyframes
|
|
252
|
-
@media (prefers-reduced-motion: reduce) { .
|
|
259
|
+
.consent-ok { background: var(--consent-ok-bg, var(--accent-press)); border-color: var(--consent-ok-bg, var(--accent-press)); color: var(--on-accent, #fff); }
|
|
260
|
+
@keyframes consent-up { from { transform: translateY(24px); opacity: 0; } }
|
|
261
|
+
@media (prefers-reduced-motion: reduce) { .consent-card { animation: none; } }
|
|
253
262
|
</style>
|
|
254
263
|
<script is:inline>
|
|
255
264
|
(function () {
|
|
@@ -257,7 +266,7 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
|
|
|
257
266
|
if (localStorage.getItem("consent")) return;
|
|
258
267
|
if (!document.getElementById("kernel")) return; // no kernel = not a content page (e.g. 404) — don't ask for consent on a dead end
|
|
259
268
|
if ((Intl.DateTimeFormat().resolvedOptions().timeZone || "").indexOf("Europe/") !== 0) return;
|
|
260
|
-
var el = document.getElementById("
|
|
269
|
+
var el = document.getElementById("consent"); if (!el) return;
|
|
261
270
|
el.classList.add("show");
|
|
262
271
|
el.addEventListener("click", function (e) {
|
|
263
272
|
var v = e.target.getAttribute("data-consent"); if (!v) return;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The language picker — the shared app.zone chrome, extracted from the eleven byte-identical copies
|
|
3
|
+
// (they differed only in comments). The markup, the style and the dropdown behaviour are the SAME for
|
|
4
|
+
// every persona; only the DATA differs, so the data is passed in, never imported.
|
|
5
|
+
//
|
|
6
|
+
// It deliberately does NOT own the locale list. @justai/ui/locale is explicit that the list belongs to
|
|
7
|
+
// the consuming repo's src/i18n — this ecosystem has been bitten twice by a second copy of it — so the
|
|
8
|
+
// persona hands `locales`, `localeNames` and `localePath` (and the two localized labels) as props. The
|
|
9
|
+
// component holds the shape; the persona holds the list. It localize-routes to /xx/ (localePath),
|
|
10
|
+
// remembers the choice in localStorage (the @justai/cuts head router reads it to auto-route next
|
|
11
|
+
// visit), and preserves the section hash (Profile/Kernel/Posts) across the switch.
|
|
12
|
+
interface Props {
|
|
13
|
+
current: string;
|
|
14
|
+
locales: readonly string[];
|
|
15
|
+
localeNames: Record<string, string>;
|
|
16
|
+
localePath: (lang: string) => string;
|
|
17
|
+
label: string; // the persona's t.chromeLanguage
|
|
18
|
+
searchLabel: string; // the persona's t.chromeLangSearch
|
|
19
|
+
}
|
|
20
|
+
const { current, locales, localeNames, localePath, label, searchLabel } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<div class="picker" data-picker>
|
|
24
|
+
<button class="trigger" type="button" aria-haspopup="listbox" aria-expanded="false" data-trigger aria-label={`${label}: ${current.toUpperCase()}`} title={label}>{current.toUpperCase()}</button>
|
|
25
|
+
|
|
26
|
+
<div class="menu" role="listbox" data-menu hidden>
|
|
27
|
+
<input type="search" class="search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
|
|
28
|
+
<div class="list" data-list>
|
|
29
|
+
{locales.map((l) => (
|
|
30
|
+
<a
|
|
31
|
+
href={localePath(l)}
|
|
32
|
+
role="option"
|
|
33
|
+
class:list={["item", { active: l === current }]}
|
|
34
|
+
data-lang={l}
|
|
35
|
+
data-name={localeNames[l].toLowerCase()}
|
|
36
|
+
aria-selected={l === current ? "true" : "false"}
|
|
37
|
+
lang={l}
|
|
38
|
+
>
|
|
39
|
+
<span>{localeNames[l]}</span>
|
|
40
|
+
{l === current && (
|
|
41
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
42
|
+
)}
|
|
43
|
+
</a>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.picker { position: relative; }
|
|
51
|
+
/* most minimalist: just the 2-letter code, flush to the right edge */
|
|
52
|
+
.trigger {
|
|
53
|
+
display: inline-grid; place-items: center; min-width: 36px; height: 36px;
|
|
54
|
+
font: inherit; font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.03em;
|
|
55
|
+
color: var(--text-dim); background: none; border: none; padding: 0 8px; cursor: pointer;
|
|
56
|
+
margin-inline-end: -7px;
|
|
57
|
+
transition: color 0.2s, transform 0.2s var(--ease-spring);
|
|
58
|
+
}
|
|
59
|
+
.trigger:hover { color: var(--text); }
|
|
60
|
+
.trigger:active { transform: scale(0.9); }
|
|
61
|
+
|
|
62
|
+
.menu {
|
|
63
|
+
position: absolute;
|
|
64
|
+
inset-block-start: calc(100% + 8px);
|
|
65
|
+
inset-inline-end: 0;
|
|
66
|
+
width: 230px;
|
|
67
|
+
/* Near-opaque: the toolbar's own backdrop-filter breaks this menu's, so don't rely on it
|
|
68
|
+
for legibility — the blur is a bonus when it works, the solid fill is the guarantee. */
|
|
69
|
+
background: color-mix(in srgb, var(--bg) 97%, transparent);
|
|
70
|
+
border: 1px solid var(--border);
|
|
71
|
+
border-radius: var(--radius-md);
|
|
72
|
+
box-shadow: 0 14px 40px -20px rgba(0, 0, 0, 0.2);
|
|
73
|
+
backdrop-filter: blur(40px) saturate(1.6);
|
|
74
|
+
-webkit-backdrop-filter: blur(40px) saturate(1.6);
|
|
75
|
+
padding: 8px;
|
|
76
|
+
z-index: 200;
|
|
77
|
+
animation: pop 0.2s var(--ease-out) both;
|
|
78
|
+
}
|
|
79
|
+
@keyframes pop { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }
|
|
80
|
+
|
|
81
|
+
.search {
|
|
82
|
+
width: 100%; font: inherit; font-size: var(--fs-base); color: var(--text);
|
|
83
|
+
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
|
84
|
+
padding: 9px 12px; outline: none; margin-block-end: 6px;
|
|
85
|
+
}
|
|
86
|
+
.list { max-height: min(54vh, 360px); overflow-y: auto; scrollbar-width: thin; }
|
|
87
|
+
.item {
|
|
88
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
89
|
+
text-decoration: none; color: var(--text); font-size: var(--fs-sm);
|
|
90
|
+
padding: 9px 12px; border-radius: var(--radius-sm);
|
|
91
|
+
transition: background 0.15s;
|
|
92
|
+
}
|
|
93
|
+
.item:hover { background: var(--surface-2); }
|
|
94
|
+
.item.active { color: var(--accent); font-weight: 600; }
|
|
95
|
+
.item svg { width: 16px; height: 16px; flex: none; }
|
|
96
|
+
</style>
|
|
97
|
+
|
|
98
|
+
<script>
|
|
99
|
+
const picker = document.querySelector<HTMLElement>("[data-picker]");
|
|
100
|
+
if (picker) {
|
|
101
|
+
const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
|
|
102
|
+
const menu = picker.querySelector<HTMLElement>("[data-menu]")!;
|
|
103
|
+
const search = picker.querySelector<HTMLInputElement>("[data-search]")!;
|
|
104
|
+
const items = Array.from(picker.querySelectorAll<HTMLAnchorElement>(".item"));
|
|
105
|
+
|
|
106
|
+
function filter() {
|
|
107
|
+
const q = search.value.trim().toLowerCase();
|
|
108
|
+
for (const it of items) {
|
|
109
|
+
it.style.display = (it.dataset.name ?? "").includes(q) ? "" : "none";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function open() {
|
|
113
|
+
menu.hidden = false;
|
|
114
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
115
|
+
search.value = "";
|
|
116
|
+
filter();
|
|
117
|
+
setTimeout(() => search.focus(), 0);
|
|
118
|
+
}
|
|
119
|
+
function close() {
|
|
120
|
+
menu.hidden = true;
|
|
121
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
trigger.addEventListener("click", () => (menu.hidden ? open() : close()));
|
|
125
|
+
document.addEventListener("click", (e) => {
|
|
126
|
+
if (!picker.contains(e.target as Node)) close();
|
|
127
|
+
});
|
|
128
|
+
document.addEventListener("keydown", (e) => {
|
|
129
|
+
if (e.key === "Escape") close();
|
|
130
|
+
});
|
|
131
|
+
search.addEventListener("input", filter);
|
|
132
|
+
// Remember the chosen language for auto-routing on the next visit.
|
|
133
|
+
for (const it of items) {
|
|
134
|
+
it.addEventListener("click", (e) => {
|
|
135
|
+
localStorage.setItem("lang", it.dataset.lang ?? "en");
|
|
136
|
+
// preserve the section you're viewing (Profile/Kernel/Posts) across the language switch
|
|
137
|
+
const href = it.getAttribute("href");
|
|
138
|
+
if (href && location.hash) { e.preventDefault(); location.assign(href + location.hash); }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
</script>
|