@justai/cuts 0.157.0 → 0.159.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 +7 -2
- package/src/Catalogue.astro +42 -9
- package/src/CatalogueCard.astro +5 -3
- package/src/ListInput.astro +131 -0
- package/src/behaviour/catalogue.ts +15 -2
- package/src/behaviour/listInput.ts +169 -0
- package/src/personaHref.d.ts +6 -0
- package/src/personaHref.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.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": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./FilePicker.astro": "./src/FilePicker.astro",
|
|
26
26
|
"./Files.astro": "./src/FilePicker.astro",
|
|
27
27
|
"./LangPicker.astro": "./src/LangPicker.astro",
|
|
28
|
+
"./ListInput.astro": "./src/ListInput.astro",
|
|
28
29
|
"./Posts.astro": "./src/Posts.astro",
|
|
29
30
|
"./Preroll.astro": "./src/Preroll.astro",
|
|
30
31
|
"./Profile.astro": "./src/Profile.astro",
|
|
@@ -32,6 +33,10 @@
|
|
|
32
33
|
"./Sheet.astro": "./src/Sheet.astro",
|
|
33
34
|
"./Toolbar.astro": "./src/Toolbar.astro",
|
|
34
35
|
"./ground.css": "./src/ground.css",
|
|
36
|
+
"./personaHref": {
|
|
37
|
+
"types": "./src/personaHref.d.ts",
|
|
38
|
+
"default": "./src/personaHref.js"
|
|
39
|
+
},
|
|
35
40
|
"./tagline.js": "./src/tagline.js"
|
|
36
41
|
},
|
|
37
42
|
"peerDependencies": {
|
|
@@ -41,7 +46,7 @@
|
|
|
41
46
|
"access": "public"
|
|
42
47
|
},
|
|
43
48
|
"dependencies": {
|
|
44
|
-
"@justai/core": "^0.43.
|
|
49
|
+
"@justai/core": "^0.43.1",
|
|
45
50
|
"@justai/ui": "^0.29.0",
|
|
46
51
|
"@justai/atrium": "^0.1.9"
|
|
47
52
|
},
|
package/src/Catalogue.astro
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// statement about what the ecosystem IS, living in one page's markup, twice. It is now a question
|
|
39
39
|
// asked of the registry, which has carried `personaKind` since long before anything read it.
|
|
40
40
|
import CatalogueCard, { type CatalogueEntry } from "./CatalogueCard.astro";
|
|
41
|
+
import { personaHref } from "./personaHref.js";
|
|
41
42
|
|
|
42
43
|
interface Props {
|
|
43
44
|
/** BCP-47 code of the page — the language the registry is asked for. */
|
|
@@ -63,6 +64,19 @@ interface Props {
|
|
|
63
64
|
kind?: string;
|
|
64
65
|
/** The apex's own host, used for the referral parameter on every card. */
|
|
65
66
|
apexHost: string;
|
|
67
|
+
/**
|
|
68
|
+
* queries: localeLinks · clean locale links · ref=app.zone · lang parameter · data-locale-links
|
|
69
|
+
*
|
|
70
|
+
* LINK STRAIGHT TO THE PERSONA'S PAGE IN THIS PAGE'S LANGUAGE — `https://<home>/<lang>/`, no query
|
|
71
|
+
* string — instead of `https://<home>/?ref=<apex>&lang=<lang>`.
|
|
72
|
+
*
|
|
73
|
+
* An apex turns this on when its citizens all serve the apex's languages at `/<lang>/` (app.zone:
|
|
74
|
+
* measured, 37 of 37 personas × 13 languages). It is a statement only the apex can make, which is
|
|
75
|
+
* why it is a prop and not the default: a persona that lacks the language answers 404 at that
|
|
76
|
+
* address, where the old hint was quietly ignored. See ./personaHref.js for the measurements that
|
|
77
|
+
* made the parameters worth removing.
|
|
78
|
+
*/
|
|
79
|
+
localeLinks?: boolean;
|
|
66
80
|
/** Layer one. Empty is valid and correct for an apex with nothing seeded yet. */
|
|
67
81
|
seed?: CatalogueEntry[];
|
|
68
82
|
/** The curated sequence, keyed by host — read by the runtime sort. */
|
|
@@ -102,7 +116,7 @@ interface Props {
|
|
|
102
116
|
};
|
|
103
117
|
}
|
|
104
118
|
const {
|
|
105
|
-
lang, apexHost,
|
|
119
|
+
lang, apexHost, localeLinks = false,
|
|
106
120
|
seed = [], order = [], taglines = {}, catLabels = {}, entries = {},
|
|
107
121
|
locales, strings,
|
|
108
122
|
} = Astro.props;
|
|
@@ -228,8 +242,8 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
228
242
|
{/* taglines ride the same channel as newLabel and catMap: a data attribute on the grid. The
|
|
229
243
|
runtime append builds its cards in plain strings and cannot reach this component's scope, so
|
|
230
244
|
anything it needs has to arrive this way. Keyed by host, which is what the API calls `home`. */}
|
|
231
|
-
<section class="grid" id="grid" data-apex={apexHost} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
|
|
232
|
-
{seed.map((app) => <CatalogueCard app={app} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
|
|
245
|
+
<section class="grid" id="grid" data-apex={apexHost} data-locale-links={localeLinks ? "" : undefined} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
|
|
246
|
+
{seed.map((app) => <CatalogueCard app={app} href={localeLinks ? personaHref(hostOf(app.url), lang) : undefined} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
|
|
233
247
|
</section>
|
|
234
248
|
{/* queries: runtime cards are unstyled · the card has no CSS · an apex with no seed · scope cid ·
|
|
235
249
|
data-astro-cid · pin.games cards render as blue links
|
|
@@ -264,8 +278,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
264
278
|
// locale list has ONE home and is never typed here. A literal array once lived on this line and
|
|
265
279
|
// drifted: it was edited by hand when Italian shipped, and any locale missing from it fell through
|
|
266
280
|
// to "en", which the receiving persona then WROTE as the visitor's saved language choice.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
281
|
+
// queries: ?ref=app.zone&lang= indexed · parameterised persona url in search console · crawler sees
|
|
282
|
+
// ref and lang · Google indexed the hint, not the page · when does a card get ?ref
|
|
283
|
+
//
|
|
284
|
+
// THE VISITOR'S LANGUAGE AND A REFERRAL, HANDED OVER AT THE MOMENT OF THE TAP — never written into
|
|
285
|
+
// the page. This used to rewrite every card's href on load. A crawler renders the script too, so the
|
|
286
|
+
// links it followed were `https://<home>/?ref=<apex>&lang=<xx>`. That address is a page whose HTML is
|
|
287
|
+
// the default language, whose canonical is the root, and whose script goes elsewhere. Search Console
|
|
288
|
+
// then filed those addresses as pages of their own: 232 "Page with redirect" and 67 indexed on
|
|
289
|
+
// app.zone (2026-10-11, see ./personaHref.js).
|
|
290
|
+
//
|
|
291
|
+
// Now the href in the DOM stays whatever the card was rendered with: the persona's own page in this
|
|
292
|
+
// language when the apex opted in (`localeLinks`), its bare root otherwise. The hint is added only
|
|
293
|
+
// when a person actually opens a card: pointerdown, keyboard Enter or a context menu, delegated so
|
|
294
|
+
// runtime-appended cards are covered. A crawler does none of those. An apex on `localeLinks` needs no
|
|
295
|
+
// hint at all, because the address is already the language.
|
|
269
296
|
(function () {
|
|
270
297
|
function detectLang() {
|
|
271
298
|
const saved = localStorage.getItem("lang");
|
|
@@ -277,15 +304,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
277
304
|
}
|
|
278
305
|
return "en";
|
|
279
306
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
307
|
+
const grid = document.getElementById("grid");
|
|
308
|
+
if (!grid || grid.dataset.localeLinks !== undefined) return;
|
|
309
|
+
function hint(e) {
|
|
310
|
+
const a = e.target && e.target.closest ? e.target.closest("a.card") : null;
|
|
311
|
+
if (!a || !grid.contains(a)) return;
|
|
282
312
|
try {
|
|
283
313
|
const u = new URL(a.href);
|
|
284
314
|
u.searchParams.set("ref", apexHost);
|
|
285
|
-
u.searchParams.set("lang",
|
|
315
|
+
u.searchParams.set("lang", detectLang());
|
|
286
316
|
a.href = u.toString();
|
|
287
|
-
} catch (
|
|
317
|
+
} catch (err) {}
|
|
288
318
|
}
|
|
319
|
+
// pointerdown precedes click, middle-click and cmd-click alike; keydown covers Enter on a focused
|
|
320
|
+
// card; contextmenu covers "open in new tab". Capture phase, so nothing below can skip it.
|
|
321
|
+
for (const type of ["pointerdown", "keydown", "contextmenu"]) document.addEventListener(type, hint, true);
|
|
289
322
|
})();
|
|
290
323
|
|
|
291
324
|
// queries: can a campaign link to just one app or one category · what is the ?apps= URL parameter ·
|
package/src/CatalogueCard.astro
CHANGED
|
@@ -17,8 +17,10 @@ export interface CatalogueEntry {
|
|
|
17
17
|
// words repeated down the page — "Fitness" four times — carrying almost no information per card, while
|
|
18
18
|
// costing every card a block-level line. As a filter it earns its space instead, and it surfaces the
|
|
19
19
|
// ?category= slice the catalog already supported but never showed. data-category stays: the filter reads it.
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// `href` — where the card goes, when the apex has a better answer than the persona's bare root (its page in
|
|
21
|
+
// this page's language; see Catalogue's `localeLinks`). The icon still comes from `app.url`, the root.
|
|
22
|
+
const { app, name, desc, isNew, newLabel, href } = Astro.props as {
|
|
23
|
+
app: App; name: string; desc: string; isNew: boolean; newLabel: string; href?: string;
|
|
22
24
|
};
|
|
23
25
|
// The persona's canonical icon — every persona serves /icon.svg on its own host. One face everywhere.
|
|
24
26
|
const icon = `${app.url}/icon.svg`;
|
|
@@ -26,7 +28,7 @@ const icon = `${app.url}/icon.svg`;
|
|
|
26
28
|
|
|
27
29
|
<a
|
|
28
30
|
class="card"
|
|
29
|
-
href={app.url}
|
|
31
|
+
href={href ?? app.url}
|
|
30
32
|
data-name={`${name} ${app.name}`.toLowerCase()}
|
|
31
33
|
data-category={app.category}
|
|
32
34
|
data-id={app.id.toLowerCase()}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
// THE GROUND rides with the control, same as every cut — law 1 is a file it imports, not a promise.
|
|
3
|
+
import "./ground.css";
|
|
4
|
+
import ConfirmSheet from "./ConfirmSheet.astro";
|
|
5
|
+
/**
|
|
6
|
+
* <ListInput> — the box a person pastes a list into: one line, one entry.
|
|
7
|
+
*
|
|
8
|
+
* queries: list input · paste a list · names textarea · one per line · duplicate warning · keep one
|
|
9
|
+
* each · split by commas · clear list · confirm clear · keyboard hides the button · scrolling
|
|
10
|
+
* opens the keyboard · Arabic name in an English list · 1000 lines · 120 characters
|
|
11
|
+
*
|
|
12
|
+
* ── WHY IT IS A CUT, WITH THE COUNT ──────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Name Picker wrote it and proved it on a real iPhone 14 Pro; Team Generator copied it and changed
|
|
15
|
+
* one id. The third consumer (List Randomizer) is where the owner decided it moves here (O9,
|
|
16
|
+
* 2026-10-22), because three copies diverge in three directions. What is shared is the LAYER every
|
|
17
|
+
* list-taking persona needs the same: the label row, the bounded box, the notes about the list, the
|
|
18
|
+
* confirmed Clear, the keyboard comfort and the scroll guard. What is NOT shared:
|
|
19
|
+
*
|
|
20
|
+
* · the words — every string arrives in `words`, in the persona's own language and voice;
|
|
21
|
+
* · what a duplicate MEANS (two tickets, two people, two lines) — that is the persona's sentence;
|
|
22
|
+
* · the count line ("9 names", "7 of 9 left") — the persona writes `#count` itself;
|
|
23
|
+
* · the model — the persona parses the list again for its own job, with @justai/core/list.
|
|
24
|
+
*
|
|
25
|
+
* ── THE CONTRACT, WHICH IS THE NATIVE ONE ────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* There is no event vocabulary. Every change to the list — typing, Keep one each, Split by commas,
|
|
28
|
+
* a confirmed Clear — ends as the textarea's value plus a bubbling `input` event, which is what the
|
|
29
|
+
* persona already listens to. The other direction is the same: a persona that sets the value (an
|
|
30
|
+
* example list) dispatches `input` on `#names`, and the notes follow.
|
|
31
|
+
*
|
|
32
|
+
* The ids are fixed and documented, because personas' harnesses already speak them: `names`,
|
|
33
|
+
* `count`, `clear`, `notes`, `clearSheet`. One ListInput per page.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* <ListInput words={t} label={t.listLabel} placeholder={t.placeholder}
|
|
37
|
+
* count={t.count_other.replace("{n}", "0")} keepVisible="pick" describedBy="hint" />
|
|
38
|
+
*
|
|
39
|
+
* `words` may be the whole dictionary: only the keys below are read, and only those reach the page.
|
|
40
|
+
*/
|
|
41
|
+
export interface Props {
|
|
42
|
+
/** The persona's dictionary. Read: clear, clearTitle, clearBody, clearAct, cancel, keepOne,
|
|
43
|
+
* commaHint, splitAct, dupOne, dups[_form], overflow[_form], shortened[_form]. */
|
|
44
|
+
words: Record<string, string>;
|
|
45
|
+
label: string;
|
|
46
|
+
placeholder?: string;
|
|
47
|
+
/** The count pill's first text; the persona keeps it current (`#count`). */
|
|
48
|
+
count?: string;
|
|
49
|
+
/** A quiet line shown while the list is empty (Name Picker: "Works for names, numbers, anything."). */
|
|
50
|
+
emptyNote?: string;
|
|
51
|
+
/** The id of the primary button kept fully in view above a phone keyboard. */
|
|
52
|
+
keepVisible?: string;
|
|
53
|
+
/** Extra ids the box is described by, beside its own notes (the persona's hint line). */
|
|
54
|
+
describedBy?: string;
|
|
55
|
+
autocapitalize?: "words" | "sentences" | "off";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { words, label, placeholder = "", count = "", emptyNote, keepVisible, describedBy, autocapitalize = "words" } = Astro.props;
|
|
59
|
+
|
|
60
|
+
const KEYS = /^(clear|clearTitle|clearBody|clearAct|cancel|keepOne|commaHint|splitAct|dupOne|dups|overflow|shortened)(_\w+)?$/;
|
|
61
|
+
const used: Record<string, string> = {};
|
|
62
|
+
for (const [k, v] of Object.entries(words)) if (KEYS.test(k)) used[k] = v;
|
|
63
|
+
if (emptyNote) used.emptyNote = emptyNote;
|
|
64
|
+
---
|
|
65
|
+
<div class="jlist" data-list-input data-keep={keepVisible} data-words={JSON.stringify(used)}>
|
|
66
|
+
<div class="jlist-lbl">
|
|
67
|
+
<label for="names">{label}</label>
|
|
68
|
+
<span class="jlist-end">
|
|
69
|
+
<span class="jlist-count count" id="count">{count}</span>
|
|
70
|
+
<button class="jlist-link" type="button" id="clear" hidden>{words.clear}</button>
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
{/* dir="auto" + unicode-bidi: plaintext (CSS) — each LINE finds its own direction, so an Arabic name
|
|
74
|
+
in an English list, or a Latin one on /ar/, reads the right way round. */}
|
|
75
|
+
<textarea
|
|
76
|
+
id="names" dir="auto" rows="8" spellcheck="false" autocapitalize={autocapitalize} autocomplete="off" autocorrect="off"
|
|
77
|
+
enterkeyhint="enter" placeholder={placeholder} aria-describedby={describedBy ? `${describedBy} notes` : "notes"}
|
|
78
|
+
></textarea>
|
|
79
|
+
<div class="jlist-notes" id="notes"></div>
|
|
80
|
+
<ConfirmSheet id="clearSheet" title={words.clearTitle} body={words.clearBody} actionLabel={words.clearAct}
|
|
81
|
+
cancelLabel={words.cancel} destructive />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.jlist { display: block; }
|
|
86
|
+
[hidden] { display: none !important; }
|
|
87
|
+
|
|
88
|
+
.jlist-lbl { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-block-end: 10px; }
|
|
89
|
+
.jlist-lbl label { font-size: var(--fs-sm); font-weight: 650; color: var(--text-dim); }
|
|
90
|
+
.jlist-end { display: inline-flex; align-items: center; gap: 12px; }
|
|
91
|
+
.jlist-count { font-size: var(--fs-2xs); font-weight: 650; color: var(--text); background: var(--surface-2);
|
|
92
|
+
border-radius: 99px; padding: 4px 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
93
|
+
|
|
94
|
+
/* A quiet text button with a 44px hit area: the ::after reaches past the words without moving them.
|
|
95
|
+
The notes' buttons are built at runtime and carry no scope attribute, so the same rule is global
|
|
96
|
+
under the notes (measured in Name Picker: a bare browser button with a black border otherwise). */
|
|
97
|
+
.jlist-link, .jlist-notes :global(.jlist-link) { position: relative; font: inherit; font-size: var(--fs-xs); font-weight: 650;
|
|
98
|
+
color: var(--accent-ink, var(--accent)); background: none; border: 0; padding: 4px 2px; cursor: pointer; border-radius: 6px; }
|
|
99
|
+
.jlist-link::after, .jlist-notes :global(.jlist-link)::after { content: ""; position: absolute; inset-inline: -4px; inset-block: calc(50% - 22px); }
|
|
100
|
+
.jlist-link:hover, .jlist-notes :global(.jlist-link:hover) { text-decoration: underline; text-underline-offset: 3px; }
|
|
101
|
+
|
|
102
|
+
/* THE BOX IS BOUNDED AND SCROLLS INSIDE ITSELF: at most 40% of the view, so the primary button below
|
|
103
|
+
it stays in reach — the keyboard comfort (behaviour/listInput.ts) keeps it there when a phone
|
|
104
|
+
keyboard takes half the screen. */
|
|
105
|
+
#names {
|
|
106
|
+
display: block; inline-size: 100%; box-sizing: border-box;
|
|
107
|
+
block-size: clamp(8.5rem, 34vh, 17rem); max-block-size: min(40vh, calc(var(--vvh, 100vh) * 0.4)); min-block-size: 6.5rem;
|
|
108
|
+
resize: vertical; overflow: auto;
|
|
109
|
+
border: 1px solid var(--border); border-radius: 16px;
|
|
110
|
+
background: color-mix(in srgb, var(--surface-2) 70%, transparent);
|
|
111
|
+
padding: 12px 14px; color: var(--text);
|
|
112
|
+
font: 500 var(--fs-base)/1.6 var(--font);
|
|
113
|
+
unicode-bidi: plaintext;
|
|
114
|
+
}
|
|
115
|
+
#names::placeholder { color: var(--text-dim); opacity: 0.7; }
|
|
116
|
+
/* On a phone with the keyboard up the box takes at most a third of what is still visible, so the box,
|
|
117
|
+
the persona's options and a FULLY visible primary button fit above the accessory strip. */
|
|
118
|
+
@media (max-width: 639px) { #names { block-size: clamp(7.5rem, 30vh, 15rem); max-block-size: min(34vh, calc(var(--vvh, 100vh) * 0.32)); } }
|
|
119
|
+
|
|
120
|
+
.jlist-notes:empty { display: none; }
|
|
121
|
+
.jlist-notes { margin-block-start: 10px; display: grid; gap: 6px; }
|
|
122
|
+
.jlist-notes :global(.jlist-note) { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: var(--fs-xs);
|
|
123
|
+
color: var(--text-dim); line-height: 1.45; margin: 0; }
|
|
124
|
+
.jlist-notes :global(.jlist-note.warn)::before { content: ""; flex: none; align-self: center; inline-size: 7px; block-size: 7px;
|
|
125
|
+
border-radius: 50%; background: #E8A200; }
|
|
126
|
+
</style>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
// The behaviour lives in ./behaviour/listInput.ts — see Segmented.astro for why it is not in here.
|
|
130
|
+
import "./behaviour/listInput";
|
|
131
|
+
</script>
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
|
|
22
22
|
// the append, seeds and newcomers together, so ordering is a property of the merged list, not of
|
|
23
23
|
// either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
|
|
24
|
+
import { personaHref } from "../personaHref.js";
|
|
25
|
+
|
|
24
26
|
(function () {
|
|
25
27
|
const grid = document.getElementById("grid");
|
|
26
28
|
if (!grid) return;
|
|
@@ -40,6 +42,8 @@
|
|
|
40
42
|
// household where they part — its citizens are sometimes games and sometimes apps — and asking it
|
|
41
43
|
// for one kind returns half a family and calls that the storefront.
|
|
42
44
|
const apexHost = grid.dataset.apex || "app.zone";
|
|
45
|
+
// Present (even empty) when the apex opted in to clean locale links — see Catalogue's `localeLinks`.
|
|
46
|
+
const localeLinks = grid.dataset.localeLinks !== undefined;
|
|
43
47
|
// The category label must be localized like the SSR path (catLabel), but the API returns the
|
|
44
48
|
// category in English regardless of ?locale=. So carry this page's cat dictionary through a data
|
|
45
49
|
// attribute (same channel as newLabel) and map through it — the English key stays the stable
|
|
@@ -73,7 +77,7 @@
|
|
|
73
77
|
const esc = (s: unknown): string =>
|
|
74
78
|
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
75
79
|
|
|
76
|
-
type Entry = { handle: string; home: string; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
|
|
80
|
+
type Entry = { handle: string; home: string; locales?: string[] | null; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
|
|
77
81
|
const cardHTML = (a: Entry): string => {
|
|
78
82
|
const url = `https://${a.home}`;
|
|
79
83
|
// the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
|
|
@@ -133,7 +137,16 @@
|
|
|
133
137
|
el.setAttribute(scopeAttr, "");
|
|
134
138
|
el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
|
|
135
139
|
}
|
|
136
|
-
|
|
140
|
+
// queries: ref=app.zone · lang parameter on a card · clean locale link · data-locale-links
|
|
141
|
+
//
|
|
142
|
+
// WHERE THE CARD GOES. An apex that has opted in (`localeLinks` on Catalogue) sends the visitor
|
|
143
|
+
// straight to the persona's page in this page's language — the canonical address, no query
|
|
144
|
+
// string; see ../personaHref.js for what the parameters cost in Search Console. An apex that
|
|
145
|
+
// has not keeps the bare root it was built with, because only the apex can vouch that its
|
|
146
|
+
// citizens speak its languages, and a persona without `/<xx>/` answers 404 there. Its
|
|
147
|
+
// visitor's language still travels: Catalogue.astro adds the hint at the moment of the tap,
|
|
148
|
+
// never into the page a crawler renders.
|
|
149
|
+
if (localeLinks) el.href = personaHref(a.home, locale, { locales: a.locales });
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
// The filter panel is rendered from the build-time seed, so a persona that registered afterwards
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// queries: list input behaviour · notes under the list · keep one each · split by commas · clear
|
|
2
|
+
// confirm · keyboard hides the button · visualViewport · iPhone keyboard · accessory bar ·
|
|
3
|
+
// scrolling opens the keyboard · tap to stop scroll · plural notes
|
|
4
|
+
//
|
|
5
|
+
// The behaviour of <ListInput>, lifted from Name Picker (proven on a real iPhone 14 Pro, 2026-10-21)
|
|
6
|
+
// and Team Generator, where it was the same code with one id changed.
|
|
7
|
+
//
|
|
8
|
+
// ── THE ONE RULE ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Every change this module makes to the list is `value` + a bubbling `input` event — the event the
|
|
11
|
+
// persona already listens to. It never calls into the persona and the persona never calls into it.
|
|
12
|
+
// When the persona sets the value itself (an example list), it dispatches `input` too, and the notes
|
|
13
|
+
// follow. Every entry reaches the page through `textContent` only, never innerHTML, so a line that
|
|
14
|
+
// happens to be markup is shown as the characters it is.
|
|
15
|
+
//
|
|
16
|
+
// ── THE KEYBOARD AND THE THUMB ───────────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// 1. THE PRIMARY BUTTON STAYS FULLY VISIBLE. iOS does not resize the layout viewport for its
|
|
19
|
+
// keyboard; only the VISUAL viewport shrinks, and Safari draws a strip above the keys (‹ › Done,
|
|
20
|
+
// AutoFill). The target is `visible bottom − COMFORT` — UIKit's input-accessory bar (44pt) plus
|
|
21
|
+
// 12px of air — checked on visualViewport resize AND scroll, and again 120/360/720ms after focus,
|
|
22
|
+
// only while the box has focus and a keyboard is plainly open (the visible height is well short of
|
|
23
|
+
// what it was the last time the box was NOT focused). Desktops never meet this code.
|
|
24
|
+
//
|
|
25
|
+
// 2. A SCROLL IS NOT A TAP. What opens the keyboard by mistake is iOS's tap-to-focus: a short drag that
|
|
26
|
+
// starts on the box, or a touch that stops the page's momentum. So a touch that MOVED more than a
|
|
27
|
+
// few pixels, or began within ~120ms of a page scroll, has its `touchend` cancelled — which cancels
|
|
28
|
+
// the synthetic click and the focus and nothing else; touchstart/touchmove stay passive.
|
|
29
|
+
|
|
30
|
+
import { parse, keepOneEach, splitByCommas } from "@justai/core/list";
|
|
31
|
+
|
|
32
|
+
/** UIKit's input-accessory bar (44pt) + the 12px of air above it. */
|
|
33
|
+
export const COMFORT = 44 + 12;
|
|
34
|
+
const MOVE_SLOP = 10; // px: more than this between touchstart and touchend is a drag
|
|
35
|
+
const COAST_MS = 120; // ms: a touch this soon after a page scroll is stopping momentum
|
|
36
|
+
|
|
37
|
+
type Words = Record<string, string>;
|
|
38
|
+
|
|
39
|
+
let rules: Intl.PluralRules | null = null;
|
|
40
|
+
let rulesLang = "";
|
|
41
|
+
const lang = (): string => document.documentElement.lang || "en";
|
|
42
|
+
const fmt = (v: string | number): string => (typeof v === "number" ? new Intl.NumberFormat(lang()).format(v) : v);
|
|
43
|
+
|
|
44
|
+
/** `{name}` placeholders filled from `vars`; `n` chooses the plural form (`key_one`, `key_other`…). */
|
|
45
|
+
export function say(t: Words, key: string, vars: Record<string, string | number> = {}, n?: number): string {
|
|
46
|
+
let s: string | undefined;
|
|
47
|
+
if (n !== undefined) {
|
|
48
|
+
if (!rules || rulesLang !== lang()) { rules = new Intl.PluralRules(lang()); rulesLang = lang(); }
|
|
49
|
+
s = t[`${key}_${rules.select(n)}`] ?? t[`${key}_other`];
|
|
50
|
+
}
|
|
51
|
+
s ??= t[key] ?? key;
|
|
52
|
+
return s.replace(/\{(\w+)\}/g, (_, k: string) => (k in vars ? fmt(vars[k]) : `{${k}}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Set the list as if the person had typed it: value + a bubbling `input`. */
|
|
56
|
+
export function setList(box: HTMLTextAreaElement, text: string): void {
|
|
57
|
+
box.value = text;
|
|
58
|
+
box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const note = (text: string, warn = false, action?: [string, string]): HTMLElement => {
|
|
62
|
+
const p = document.createElement("p");
|
|
63
|
+
p.className = warn ? "note jlist-note warn" : "note jlist-note";
|
|
64
|
+
const s = document.createElement("span");
|
|
65
|
+
s.textContent = text;
|
|
66
|
+
p.append(s);
|
|
67
|
+
if (action) {
|
|
68
|
+
const b = document.createElement("button");
|
|
69
|
+
b.type = "button"; b.className = "jlist-link"; b.dataset.act = action[0]; b.textContent = action[1];
|
|
70
|
+
p.append(b);
|
|
71
|
+
}
|
|
72
|
+
return p;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const wire = (root: HTMLElement) => {
|
|
76
|
+
const t: Words = JSON.parse(root.dataset.words || "{}");
|
|
77
|
+
const box = root.querySelector<HTMLTextAreaElement>("#names")!;
|
|
78
|
+
const clear = root.querySelector<HTMLButtonElement>("#clear")!;
|
|
79
|
+
const notes = root.querySelector<HTMLElement>("#notes")!;
|
|
80
|
+
const sheet = root.querySelector<HTMLDialogElement>("#clearSheet")!;
|
|
81
|
+
|
|
82
|
+
function render(): void {
|
|
83
|
+
const p = parse(box.value), out: HTMLElement[] = [];
|
|
84
|
+
if (p.duplicates.length === 1) {
|
|
85
|
+
const d = p.duplicates[0];
|
|
86
|
+
out.push(note(say(t, "dupOne", { name: d.text, count: d.count }), true, ["keep", t.keepOne]));
|
|
87
|
+
} else if (p.duplicates.length > 1) {
|
|
88
|
+
out.push(note(say(t, "dups", { n: p.duplicates.length }, p.duplicates.length), true, ["keep", t.keepOne]));
|
|
89
|
+
}
|
|
90
|
+
if (p.commaHint) out.push(note(t.commaHint, false, ["split", t.splitAct]));
|
|
91
|
+
if (p.overflow) out.push(note(say(t, "overflow", { n: p.overflow }, p.overflow), true));
|
|
92
|
+
if (p.shortened) out.push(note(say(t, "shortened", { n: p.shortened }, p.shortened), true));
|
|
93
|
+
if (p.entries.length === 0 && t.emptyNote) out.push(note(t.emptyNote));
|
|
94
|
+
notes.replaceChildren(...out);
|
|
95
|
+
clear.hidden = box.value.trim() === "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
box.addEventListener("input", render);
|
|
99
|
+
|
|
100
|
+
notes.addEventListener("click", (e) => {
|
|
101
|
+
const act = (e.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act;
|
|
102
|
+
if (act === "keep") setList(box, keepOneEach(box.value));
|
|
103
|
+
if (act === "split") setList(box, splitByCommas(box.value));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// CLEAR ASKS FIRST. The sheet's own Cancel takes focus (the destructive genre's rule), and only an
|
|
107
|
+
// explicit "Clear list" empties the box — Escape or a tap on the backdrop leaves it as it was.
|
|
108
|
+
clear.addEventListener("click", () => {
|
|
109
|
+
sheet.returnValue = "";
|
|
110
|
+
sheet.showModal();
|
|
111
|
+
document.getElementById("clearSheet-cancel")?.focus();
|
|
112
|
+
});
|
|
113
|
+
sheet.addEventListener("close", () => {
|
|
114
|
+
if (sheet.returnValue === "confirm") { setList(box, ""); box.focus(); }
|
|
115
|
+
else clear.focus();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── 1. the primary button above the keyboard ──
|
|
119
|
+
const vv = window.visualViewport;
|
|
120
|
+
const docEl = document.documentElement;
|
|
121
|
+
const keepId = root.dataset.keep;
|
|
122
|
+
if (vv) {
|
|
123
|
+
const size = () => docEl.style.setProperty("--vvh", `${Math.round(vv.height)}px`);
|
|
124
|
+
let tall = vv.height; // the visible height the last time the box was not focused
|
|
125
|
+
const seen = () => { if (document.activeElement !== box) tall = vv.height; };
|
|
126
|
+
const keyboardOpen = () => vv.height < Math.max(tall, window.innerHeight) - 120;
|
|
127
|
+
let raf = 0;
|
|
128
|
+
const keep = () => {
|
|
129
|
+
cancelAnimationFrame(raf);
|
|
130
|
+
raf = requestAnimationFrame(() => {
|
|
131
|
+
const target = keepId ? document.getElementById(keepId) : null;
|
|
132
|
+
if (!target || document.activeElement !== box || !keyboardOpen()) return;
|
|
133
|
+
const bottom = vv.offsetTop + vv.height - COMFORT;
|
|
134
|
+
const over = target.getBoundingClientRect().bottom - bottom;
|
|
135
|
+
if (over > 1) window.scrollBy({ top: over, behavior: "auto" });
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
size();
|
|
139
|
+
vv.addEventListener("resize", () => { seen(); size(); keep(); });
|
|
140
|
+
box.addEventListener("blur", () => { window.setTimeout(seen, 400); }); // after the keyboard has gone
|
|
141
|
+
vv.addEventListener("scroll", keep);
|
|
142
|
+
box.addEventListener("focus", () => { for (const ms of [120, 360, 720]) window.setTimeout(keep, ms); });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 2. a scroll is not a tap ──
|
|
146
|
+
let lastScroll = 0;
|
|
147
|
+
window.addEventListener("scroll", () => { lastScroll = performance.now(); }, { passive: true });
|
|
148
|
+
let start: { x: number; y: number; coasting: boolean } | null = null;
|
|
149
|
+
let moved = false;
|
|
150
|
+
box.addEventListener("touchstart", (e) => {
|
|
151
|
+
if (document.activeElement === box || e.touches.length !== 1) { start = null; return; }
|
|
152
|
+
const p = e.touches[0];
|
|
153
|
+
start = { x: p.clientX, y: p.clientY, coasting: performance.now() - lastScroll < COAST_MS };
|
|
154
|
+
moved = false;
|
|
155
|
+
}, { passive: true });
|
|
156
|
+
box.addEventListener("touchmove", (e) => {
|
|
157
|
+
if (!start) return;
|
|
158
|
+
const p = e.touches[0];
|
|
159
|
+
if (Math.hypot(p.clientX - start.x, p.clientY - start.y) > MOVE_SLOP) moved = true;
|
|
160
|
+
}, { passive: true });
|
|
161
|
+
box.addEventListener("touchend", (e) => {
|
|
162
|
+
if (start && (moved || start.coasting)) e.preventDefault();
|
|
163
|
+
start = null;
|
|
164
|
+
}, { passive: false });
|
|
165
|
+
|
|
166
|
+
render();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-list-input]")) wire(root);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// THE ADDRESS AN APEX HANDS A VISITOR WHEN IT SENDS THEM TO A PERSONA — one rule, one copy.
|
|
2
|
+
//
|
|
3
|
+
// queries: persona link · app.zone card href · ref=app.zone · lang parameter · locale link ·
|
|
4
|
+
// clean locale url · page with redirect in search console · parameterised url indexed ·
|
|
5
|
+
// how does a storefront link to a persona in the reader's language
|
|
6
|
+
//
|
|
7
|
+
// ── WHAT IT REPLACES, AND WHY ───────────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// A storefront used to link to `https://<home>/?ref=<apex>&lang=<xx>`. The persona's root then read
|
|
10
|
+
// `lang` as a hint and replaced the location with `/<xx>/?ref=…&lang=…` in the browser. For a person
|
|
11
|
+
// that is one extra hop. For a search engine it is a page whose HTML is the default language, whose
|
|
12
|
+
// canonical is the root, and whose script goes somewhere else — three statements that disagree.
|
|
13
|
+
//
|
|
14
|
+
// Measured in Search Console for app.zone on 2026-10-11: 232 of those addresses filed as "Page with
|
|
15
|
+
// redirect", 67 indexed as pages of their own, and for 49 of the 67 the real `/<xx>/` page was still
|
|
16
|
+
// waiting to be crawled. The parameterised address had taken the localized page's place.
|
|
17
|
+
//
|
|
18
|
+
// So the link is the page itself: `https://<home>/` for the default language, `https://<home>/<xx>/`
|
|
19
|
+
// for any other. No query string. `ref` had no reader — the arrival's own `document.referrer` already
|
|
20
|
+
// says which storefront it came from.
|
|
21
|
+
//
|
|
22
|
+
// ── THE ONE THING THIS CANNOT KNOW BY ITSELF ────────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// Whether the persona HAS that language. A persona without `/<xx>/` answers 404 there, where the old
|
|
25
|
+
// hint was simply ignored. So:
|
|
26
|
+
// · `locales` given (the persona's own list) → the localized page only if it is in the list.
|
|
27
|
+
// · `locales` not given → the caller has vouched for it (see `localeLinks`
|
|
28
|
+
// on Catalogue: an apex opts in when its citizens
|
|
29
|
+
// all speak the apex's languages).
|
|
30
|
+
// A language code that is not a plain BCP-47 tag never reaches a path at all.
|
|
31
|
+
import { localePath } from "@justai/ui/locale";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} home the persona's host, e.g. "merge-pdf.app.zone" (a scheme or path is ignored)
|
|
35
|
+
* @param {string} lang the page's language
|
|
36
|
+
* @param {{ defaultLocale?: string, locales?: readonly string[] | null }} [opts]
|
|
37
|
+
* @returns {string} an absolute https URL with no query string and no fragment
|
|
38
|
+
*/
|
|
39
|
+
export function personaHref(home, lang, opts) {
|
|
40
|
+
const def = (opts && opts.defaultLocale) || "en";
|
|
41
|
+
const host = String(home || "").trim().replace(/^[a-z]+:\/\//i, "").replace(/[/?#].*$/, "").toLowerCase();
|
|
42
|
+
const code = String(lang || "").trim().toLowerCase();
|
|
43
|
+
const known = opts && Array.isArray(opts.locales) ? opts.locales : null;
|
|
44
|
+
const speaks = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(code) && (!known || known.indexOf(code) !== -1);
|
|
45
|
+
return `https://${host}${localePath(speaks ? code : def, def)}`;
|
|
46
|
+
}
|