@justai/cuts 0.131.1 → 0.133.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/Posts.astro +144 -7
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.131.1",
4
- "description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.133.0",
4
+ "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
package/src/Posts.astro CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { tagline } from "./tagline.js";
2
+ import { tagline, TAGLINE } from "./tagline.js";
3
3
  // ░░ POSTS cut — a persona's feed, revealed by scrolling past the Kernel. ░░
4
4
  //
5
5
  // The feed's markup, its styling, AND its runtime loader were byte-near-identical across every persona
@@ -85,7 +85,50 @@ const { handle, name, lang, footer, avatarBg, avatarBgLight, avatarSrc, avatarBa
85
85
  // The override wins where it is given; otherwise the family's own line, in this page's language.
86
86
  // `Astro.currentLocale` is the fallback for a caller that passes neither, so an older persona that
87
87
  // has not been bumped yet still closes its feed in the right language rather than in English.
88
- const foot = footer ?? tagline(lang ?? Astro.currentLocale ?? "en");
88
+ // queries: posts cut language · English posts on a Turkish page · translated feed on the home page ·
89
+ // why does the feed not follow the page language · show original on a persona's home
90
+ //
91
+ // ── THE PAGE'S LANGUAGE, AND NOW THE FEED'S TOO ────────────────────────────────────────────────
92
+ //
93
+ // One value, two jobs. It already picked the house tagline; it now also asks the console for the feed
94
+ // in that language. They must be the same value or the footer and the posts above it disagree about
95
+ // what page this is.
96
+ //
97
+ // This was a real defect and builder's own finishing checklist names it — *"a post in English on a
98
+ // Turkish page"*. Measured on qr before the fix: `<html lang="tr">`, the bio Turkish, the timestamp
99
+ // reading *"3 hf. önce"*, and every post underneath in English, linking to the English permalink.
100
+ //
101
+ // The PAGE decides, not localStorage. On a localized persona home the language is in the URL
102
+ // (`/tr/`), so it is settled before any script runs — and the same rule the feed permalink pages
103
+ // learned the hard way: a page whose language came from its address must not be second-guessed by
104
+ // what the visitor last picked somewhere else.
105
+ //
106
+ // ── `Astro.currentLocale` IS UNDEFINED HERE, ON EVERY PERSONA IN THIS FLEET ─────────────────────
107
+ //
108
+ // It was written as the fallback and it has never once fired. The fleet routes languages through a
109
+ // `[lang]` PARAMETER, not Astro's i18n config, so `currentLocale` is undefined in every persona that
110
+ // has one — `Sheet.astro` says exactly this, in this same package, and the two comments contradicted
111
+ // each other with the wrong one load-bearing.
112
+ //
113
+ // MEASURED 2026-08-21, and it is not theoretical: `time.app.zone/tr/` and `tabata.app.zone/tr/` were
114
+ // closing their feeds with *"made to matter"* — the ENGLISH house line, on a Turkish page — because
115
+ // neither passes `lang`. Fifteen of the twenty-five personas that render this cut are in that state.
116
+ //
117
+ // So there are two answers, one per surface, and neither is "ask fifteen repos to remember":
118
+ // the footer → rendered by the server, so it is RE-LABELLED at runtime from `<html lang>`, which
119
+ // is the pattern this package already uses for the words a cut owns
120
+ // the feed → fetched by the loader, which runs at runtime and can simply read `<html lang>`
121
+ // `lang` still wins wherever a persona states it: a stated fact beats an inferred one.
122
+ const feedLang = lang ?? "en";
123
+ const foot = footer ?? tagline(feedLang);
124
+ // Only a STATED language rides on the section. An inferred "en" here would look identical to a
125
+ // persona that really is English, and the loader could no longer tell the difference.
126
+ const statedLang = lang ?? undefined;
127
+ // The house line is re-labelled at runtime ONLY when the persona did not state its language and did
128
+ // not override the sentence. A consumer's word always wins — the same rule `data-chrome` keeps.
129
+ const RELABEL = !lang && !footer
130
+ ? `(function(){try{var T=${JSON.stringify(TAGLINE)},l=(document.documentElement.lang||"en").toLowerCase().split("-")[0],e=document.querySelector("[data-house-line]");if(e&&T[l])e.textContent=T[l];}catch(_){}})();`
131
+ : "";
89
132
  const vars = [
90
133
  avatarBg && `--feed-av-bg:${avatarBg}`,
91
134
  avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
@@ -97,6 +140,7 @@ const vars = [
97
140
  id="posts"
98
141
  data-persona={handle}
99
142
  data-name={name}
143
+ data-lang={statedLang}
100
144
  data-av={avatarSrc ?? "/icon.svg"}
101
145
  data-av-dark={avatarSrcDark}
102
146
  data-av-bare={avatarBare ? "" : undefined}
@@ -105,7 +149,7 @@ const vars = [
105
149
  <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
106
150
  <div id="feed"></div>
107
151
  <footer class="z-foot">
108
- <a href="https://justai.pro">justai.pro</a> · {foot}
152
+ <a href="https://justai.pro">justai.pro</a> · <span data-house-line={!lang && !footer ? "" : undefined}>{foot}</span>
109
153
  {/* the change-your-mind door: hidden until the shell's consent script labels + reveals it
110
154
  (Base owns the behavior and the localized label; this cut only owns the placement). On a
111
155
  page with no analytics — and so no consent banner — it simply never appears. */}
@@ -113,6 +157,8 @@ const vars = [
113
157
  </footer>
114
158
  </section>
115
159
 
160
+ {RELABEL && <script is:inline set:html={RELABEL}></script>}
161
+
116
162
  <style>
117
163
  /* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
118
164
  connects every comment & reply (they're all replies to the post). ---- */
@@ -271,6 +317,12 @@ const vars = [
271
317
  a.pt { text-decoration: none; }
272
318
  a.pt:hover { text-decoration: underline; }
273
319
  .pbd { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 4px; }
320
+ /* The translation notice — dim, small, directly under the words it is about, and carrying the ONE
321
+ link back to what the persona actually wrote, so the link is the only lit thing in the row.
322
+ Matches the feed permalink page exactly: the two surfaces render one conversation. */
323
+ .tnote { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-block-start: 6px; font-size: var(--fs-2xs); color: var(--text-dim); }
324
+ .tnote a { color: var(--confirm-accent, var(--accent)); text-decoration: none; }
325
+ .tnote a:hover { text-decoration: underline; }
274
326
  .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
275
327
  .pact svg { width: 17px; height: 17px; }
276
328
  .pact span { cursor: pointer; transition: color 0.2s; }
@@ -308,13 +360,37 @@ const vars = [
308
360
  // so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
309
361
  const PERSONA = section.dataset.persona || "";
310
362
  const NAME = section.dataset.name || "";
363
+ // THE PAGE'S LANGUAGE. `data-lang` is present only when the persona STATED it; otherwise the
364
+ // document's own `lang` is read, which is the one place every persona in this fleet agrees —
365
+ // `Astro.currentLocale` is undefined under `[lang]` routing and cannot be the fallback it looks
366
+ // like. Not localStorage either: on a localized home the language is in the URL, settled before
367
+ // this ran, and a link shared in Turkish must open in Turkish for whoever follows it.
368
+ const LANG = (section.dataset.lang || document.documentElement.lang || "en").toLowerCase().split("-")[0];
311
369
  if (!PERSONA) return;
312
370
  // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
313
371
  const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
314
372
 
315
373
  // Twitter-style: clicking anywhere on a post (except a real link) opens its feed page.
374
+ // The "show original" link is handled first and swallowed — it is a control, not a destination.
316
375
  feed.addEventListener("click", (e) => {
317
376
  const t = e.target as HTMLElement;
377
+ const tog = t.closest("[data-toggle-original]") as HTMLElement | null;
378
+ if (tog) {
379
+ const art = tog.closest(".post");
380
+ const body = art?.querySelector(".pbd") as HTMLElement | null;
381
+ const state = art?.querySelector("[data-tstate]") as HTMLElement | null;
382
+ // Only take over the click once everything it needs is in hand. If the markup ever changes
383
+ // shape the link stays a link and still reaches the original — a control that degrades to
384
+ // working beats one that silently does nothing.
385
+ if (!body || !state) return;
386
+ e.preventDefault();
387
+ const showing = tog.textContent === tog.dataset.showTranslation;
388
+ body.textContent = showing ? tog.dataset.translation! : tog.dataset.original!;
389
+ body.setAttribute("lang", (showing ? tog.dataset.lang : tog.dataset.originalLang) || "");
390
+ tog.textContent = showing ? tog.dataset.showOriginal! : tog.dataset.showTranslation!;
391
+ state.textContent = showing ? tog.dataset.translated! : tog.dataset.originalLabel!;
392
+ return;
393
+ }
318
394
  if (t.closest("a")) return;
319
395
  const art = t.closest(".post[data-href]") as HTMLElement | null;
320
396
  if (art?.dataset.href) location.assign(art.dataset.href);
@@ -339,7 +415,40 @@ const vars = [
339
415
  const pact = `<div class="pact"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8.5a5.5 5.5 0 0 0-9-4.2A5.5 5.5 0 0 0 3 8.5c0 5 9 11 9 11s9-6 9-11z"/></svg></span><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.3 9.6 9.6 0 0 1-4-.9L3 21l1.1-5A8.4 8.4 0 1 1 21 11.5z"/></svg></span></div>`;
340
416
 
341
417
  type C = { body: string; createdAt: string | null; authorHandle: string; authorName: string; authorColor: string | null; isPersona: boolean; authorAvatar: string | null; authorGlyph: string | null; personaUrl: string | null };
342
- type P = { id: string; body: string; createdAt: string | null; comments: C[] };
418
+ // `lang` is what `body` IS; `translated` says a machine made it. Both are optional so this cut
419
+ // still renders against a console that predates them — the fleet installs its packages on its own
420
+ // schedule, and a persona that has not been bumped must not lose its feed.
421
+ type P = { id: string; body: string; lang?: string; originalLang?: string; originalBody?: string | null; translated?: boolean; createdAt: string | null; comments: C[] };
422
+
423
+ // queries: translated automatically · show original · machine translation notice on the home feed
424
+ //
425
+ // THE SAME LINE THE PERMALINK PAGE SHOWS, because they render one conversation and must not
426
+ // disagree about it. The original is canonical, marked, and one tap away — that pairing IS the
427
+ // doctrine (dna/entities/localization.md), not a courtesy.
428
+ //
429
+ // Thirteen strings rather than a lookup through the persona's own dictionary: this cut ships to
430
+ // every persona and cannot reach into any of their locale files. ~400 bytes, said once, for the
431
+ // whole fleet — the alternative is the same sentence copied into thirteen files times thirty
432
+ // repos, which is the shape `tagline.js` already exists to prevent.
433
+ const TRANSLATED: Record<string, string> = {
434
+ en: "Translated automatically", tr: "Otomatik olarak çevrildi", es: "Traducido automáticamente",
435
+ de: "Automatisch übersetzt", fr: "Traduit automatiquement", pt: "Traduzido automaticamente",
436
+ it: "Tradotto automaticamente", nl: "Automatisch vertaald", ru: "Переведено автоматически",
437
+ ja: "自動翻訳", zh: "自动翻译", ar: "تُرجم آليًا", hi: "स्वचालित रूप से अनूदित",
438
+ };
439
+ const SHOW_ORIGINAL: Record<string, string> = {
440
+ en: "Show original", tr: "Orijinali göster", es: "Ver original", de: "Original anzeigen",
441
+ fr: "Afficher l'original", pt: "Ver original", it: "Mostra originale", nl: "Origineel weergeven",
442
+ ru: "Показать оригинал", ja: "原文を表示", zh: "显示原文", ar: "عرض النص الأصلي", hi: "मूल दिखाएँ",
443
+ };
444
+ // The way BACK. It is a toggle, not a door: swapping in place keeps the reader on the feed they
445
+ // were reading, in the language they set — the alternative walks them to the permalink and hands
446
+ // them the whole page in English for the sake of one paragraph.
447
+ const SHOW_TRANSLATION: Record<string, string> = {
448
+ en: "Show translation", tr: "Çeviriyi göster", es: "Ver traducción", de: "Übersetzung anzeigen",
449
+ fr: "Afficher la traduction", pt: "Ver tradução", it: "Mostra traduzione", nl: "Vertaling weergeven",
450
+ ru: "Показать перевод", ja: "翻訳を表示", zh: "显示翻译", ar: "عرض الترجمة", hi: "अनुवाद दिखाएँ",
451
+ };
343
452
 
344
453
  // Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
345
454
  // localized to the visitor's language and recomputed at read time — so they age honestly.
@@ -405,9 +514,34 @@ const vars = [
405
514
  // Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
406
515
  // clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
407
516
  const n = String(p.id || "").split(":").pop() || "";
408
- const perma = n ? `/feed/${n}` : "";
517
+ // queries: clicking a post loses my language · the permalink is English · navigation language
518
+ //
519
+ // EVERY NAVIGATION STAYS IN THE READER'S LANGUAGE — the founder's rule, and it is stronger than
520
+ // "link to the translation when there is one". Set a persona to Italian and EVERY page of it is
521
+ // Italian; a post with no Italian gives the original words with Italian chrome around them, and
522
+ // `/feed/{n}/it` renders exactly that rather than 404ing.
523
+ //
524
+ // The one URL that carries no segment is the post read in the language it was written in: there
525
+ // is no "translation into the original", so the permalink is already that page.
526
+ const orig = p.originalLang || "en";
527
+ const tr = !!p.translated && !!p.originalBody;
528
+ const base = n ? `/feed/${n}` : "";
529
+ const perma = base && LANG !== orig ? `${base}/${LANG}` : base;
409
530
  const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
410
- return `<article class="post"${perma ? ` data-href="${perma}"` : ""}><div class="trow"><div class="avc"><span class="pa">${AV}</span>${cs.length ? '<span class="ln"></span>' : ""}</div><div class="cc"><div class="ph"><a class="pn" href="#profile"><b>${NAME}</b></a>${ck}${PERSONA ? `<span class="phandle">@${esc(PERSONA)}</span>` : ""}<span class="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
531
+ // The way back, in the language of the person reading it. A real <a> so the delegated
532
+ // click-anywhere handler above lets it through — it returns early on any link.
533
+ // Both texts ride in the markup so the swap is instant and needs no second request. The link
534
+ // keeps a real href — without scripts it still reaches the original's own page.
535
+ const note = tr
536
+ ? `<p class="tnote" dir="auto"><span data-tstate>${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}</span><span class="dot">·</span>` +
537
+ `<a href="${base}" data-toggle-original data-original="${esc(p.originalBody)}" data-translation="${esc(p.body)}"` +
538
+ ` data-show-original="${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}" data-show-translation="${esc(SHOW_TRANSLATION[LANG] ?? SHOW_TRANSLATION.en)}"` +
539
+ ` data-translated="${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}" data-original-label="${esc(orig.toUpperCase())} · ${esc(NAME).replace(/<[^>]*>/g, "")}"` +
540
+ ` data-original-lang="${esc(orig)}" data-lang="${esc(p.lang || LANG)}">${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}</a></p>`
541
+ : "";
542
+ // `dir="auto"` on the body: an Arabic post inside an LTR page — and the reverse — must lay
543
+ // itself out from its own first strong character, not from the page around it.
544
+ return `<article class="post"${perma ? ` data-href="${perma}"` : ""}><div class="trow"><div class="avc"><span class="pa">${AV}</span>${cs.length ? '<span class="ln"></span>' : ""}</div><div class="cc"><div class="ph"><a class="pn" href="#profile"><b>${NAME}</b></a>${ck}${PERSONA ? `<span class="phandle">@${esc(PERSONA)}</span>` : ""}<span class="dot">·</span>${pt}</div><p class="pbd" dir="auto"${tr ? ` lang="${esc(p.lang)}"` : ""}>${esc(p.body)}</p>${note}${pact}</div></div>${inner}</article>`;
411
545
  };
412
546
 
413
547
  let done = false;
@@ -415,7 +549,10 @@ const vars = [
415
549
  if (done) return;
416
550
  done = true;
417
551
  try {
418
- const res = await fetch("https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA));
552
+ // `lang` rides in the query because it IS the content here — api.justai.pro is CDN'd with the
553
+ // query string in the cache key, so this is one more entry rather than one more origin hit.
554
+ const res = await fetch(
555
+ "https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA) + "&lang=" + encodeURIComponent(LANG));
419
556
  if (!res.ok) return;
420
557
  const data = await res.json();
421
558
  feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");