@justai/cuts 0.5.2 → 0.7.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.5.2",
3
+ "version": "0.7.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,9 @@
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",
18
+ "./BackToZone.astro": "./src/BackToZone.astro"
17
19
  },
18
20
  "peerDependencies": {
19
21
  "@justai/ui": ">=0.2.0",
@@ -0,0 +1,33 @@
1
+ ---
2
+ // Home affordance → app.zone (the platform / App Store home). Standard on every app:
3
+ // always visible at the top-left of the toolbar. Also tidies the ?ref / ?lang hints
4
+ // from the URL so shared links stay clean.
5
+ ---
6
+
7
+ <a class="home" href="https://app.zone" data-home aria-label="app.zone" title="app.zone">
8
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5"/><path d="M5 9.5V20h14V9.5"/></svg>
9
+ </a>
10
+
11
+ <style>
12
+ /* icon-only, flat — flush to the left edge of the (full-bleed) toolbar */
13
+ .home {
14
+ display: inline-grid; place-items: center; width: 36px; height: 36px;
15
+ color: var(--text-dim); text-decoration: none; margin-inline-start: -7px;
16
+ transition: color 0.2s, transform 0.2s var(--ease-spring);
17
+ }
18
+ .home:hover { color: var(--text); }
19
+ .home:active { transform: scale(0.9); }
20
+ .home svg { width: 20px; height: 20px; }
21
+ </style>
22
+
23
+ <script>
24
+ // Tidy the URL: the ?ref / ?lang hints have done their job (lang is consumed by the
25
+ // head detect script); keep shared links clean.
26
+ const params = new URLSearchParams(location.search);
27
+ if (params.has("ref") || params.has("lang")) {
28
+ params.delete("ref");
29
+ params.delete("lang");
30
+ const q = params.toString();
31
+ history.replaceState(null, "", location.pathname + (q ? "?" + q : "") + location.hash);
32
+ }
33
+ </script>
@@ -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>