@justai/cuts 0.131.1 → 0.132.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 +112 -5
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.132.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
@@ -85,7 +85,25 @@ 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
+ const feedLang = lang ?? Astro.currentLocale ?? "en";
106
+ const foot = footer ?? tagline(feedLang);
89
107
  const vars = [
90
108
  avatarBg && `--feed-av-bg:${avatarBg}`,
91
109
  avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
@@ -97,6 +115,7 @@ const vars = [
97
115
  id="posts"
98
116
  data-persona={handle}
99
117
  data-name={name}
118
+ data-lang={feedLang}
100
119
  data-av={avatarSrc ?? "/icon.svg"}
101
120
  data-av-dark={avatarSrcDark}
102
121
  data-av-bare={avatarBare ? "" : undefined}
@@ -271,6 +290,12 @@ const vars = [
271
290
  a.pt { text-decoration: none; }
272
291
  a.pt:hover { text-decoration: underline; }
273
292
  .pbd { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 4px; }
293
+ /* The translation notice — dim, small, directly under the words it is about, and carrying the ONE
294
+ link back to what the persona actually wrote, so the link is the only lit thing in the row.
295
+ Matches the feed permalink page exactly: the two surfaces render one conversation. */
296
+ .tnote { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-block-start: 6px; font-size: var(--fs-2xs); color: var(--text-dim); }
297
+ .tnote a { color: var(--confirm-accent, var(--accent)); text-decoration: none; }
298
+ .tnote a:hover { text-decoration: underline; }
274
299
  .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
275
300
  .pact svg { width: 17px; height: 17px; }
276
301
  .pact span { cursor: pointer; transition: color 0.2s; }
@@ -308,13 +333,34 @@ const vars = [
308
333
  // so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
309
334
  const PERSONA = section.dataset.persona || "";
310
335
  const NAME = section.dataset.name || "";
336
+ // THE PAGE'S LANGUAGE, set by the cut above from the same value that picked the footer. Not
337
+ // localStorage: on a localized home the language is in the URL and was settled before this ran.
338
+ const LANG = section.dataset.lang || "en";
311
339
  if (!PERSONA) return;
312
340
  // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
313
341
  const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
314
342
 
315
343
  // Twitter-style: clicking anywhere on a post (except a real link) opens its feed page.
344
+ // The "show original" link is handled first and swallowed — it is a control, not a destination.
316
345
  feed.addEventListener("click", (e) => {
317
346
  const t = e.target as HTMLElement;
347
+ const tog = t.closest("[data-toggle-original]") as HTMLElement | null;
348
+ if (tog) {
349
+ const art = tog.closest(".post");
350
+ const body = art?.querySelector(".pbd") as HTMLElement | null;
351
+ const state = art?.querySelector("[data-tstate]") as HTMLElement | null;
352
+ // Only take over the click once everything it needs is in hand. If the markup ever changes
353
+ // shape the link stays a link and still reaches the original — a control that degrades to
354
+ // working beats one that silently does nothing.
355
+ if (!body || !state) return;
356
+ e.preventDefault();
357
+ const showing = tog.textContent === tog.dataset.showTranslation;
358
+ body.textContent = showing ? tog.dataset.translation! : tog.dataset.original!;
359
+ body.setAttribute("lang", (showing ? tog.dataset.lang : tog.dataset.originalLang) || "");
360
+ tog.textContent = showing ? tog.dataset.showOriginal! : tog.dataset.showTranslation!;
361
+ state.textContent = showing ? tog.dataset.translated! : tog.dataset.originalLabel!;
362
+ return;
363
+ }
318
364
  if (t.closest("a")) return;
319
365
  const art = t.closest(".post[data-href]") as HTMLElement | null;
320
366
  if (art?.dataset.href) location.assign(art.dataset.href);
@@ -339,7 +385,40 @@ const vars = [
339
385
  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
386
 
341
387
  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[] };
388
+ // `lang` is what `body` IS; `translated` says a machine made it. Both are optional so this cut
389
+ // still renders against a console that predates them — the fleet installs its packages on its own
390
+ // schedule, and a persona that has not been bumped must not lose its feed.
391
+ type P = { id: string; body: string; lang?: string; originalLang?: string; originalBody?: string | null; translated?: boolean; createdAt: string | null; comments: C[] };
392
+
393
+ // queries: translated automatically · show original · machine translation notice on the home feed
394
+ //
395
+ // THE SAME LINE THE PERMALINK PAGE SHOWS, because they render one conversation and must not
396
+ // disagree about it. The original is canonical, marked, and one tap away — that pairing IS the
397
+ // doctrine (dna/entities/localization.md), not a courtesy.
398
+ //
399
+ // Thirteen strings rather than a lookup through the persona's own dictionary: this cut ships to
400
+ // every persona and cannot reach into any of their locale files. ~400 bytes, said once, for the
401
+ // whole fleet — the alternative is the same sentence copied into thirteen files times thirty
402
+ // repos, which is the shape `tagline.js` already exists to prevent.
403
+ const TRANSLATED: Record<string, string> = {
404
+ en: "Translated automatically", tr: "Otomatik olarak çevrildi", es: "Traducido automáticamente",
405
+ de: "Automatisch übersetzt", fr: "Traduit automatiquement", pt: "Traduzido automaticamente",
406
+ it: "Tradotto automaticamente", nl: "Automatisch vertaald", ru: "Переведено автоматически",
407
+ ja: "自動翻訳", zh: "自动翻译", ar: "تُرجم آليًا", hi: "स्वचालित रूप से अनूदित",
408
+ };
409
+ const SHOW_ORIGINAL: Record<string, string> = {
410
+ en: "Show original", tr: "Orijinali göster", es: "Ver original", de: "Original anzeigen",
411
+ fr: "Afficher l'original", pt: "Ver original", it: "Mostra originale", nl: "Origineel weergeven",
412
+ ru: "Показать оригинал", ja: "原文を表示", zh: "显示原文", ar: "عرض النص الأصلي", hi: "मूल दिखाएँ",
413
+ };
414
+ // The way BACK. It is a toggle, not a door: swapping in place keeps the reader on the feed they
415
+ // were reading, in the language they set — the alternative walks them to the permalink and hands
416
+ // them the whole page in English for the sake of one paragraph.
417
+ const SHOW_TRANSLATION: Record<string, string> = {
418
+ en: "Show translation", tr: "Çeviriyi göster", es: "Ver traducción", de: "Übersetzung anzeigen",
419
+ fr: "Afficher la traduction", pt: "Ver tradução", it: "Mostra traduzione", nl: "Vertaling weergeven",
420
+ ru: "Показать перевод", ja: "翻訳を表示", zh: "显示翻译", ar: "عرض الترجمة", hi: "अनुवाद दिखाएँ",
421
+ };
343
422
 
344
423
  // Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
345
424
  // localized to the visitor's language and recomputed at read time — so they age honestly.
@@ -405,9 +484,34 @@ const vars = [
405
484
  // Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
406
485
  // clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
407
486
  const n = String(p.id || "").split(":").pop() || "";
408
- const perma = n ? `/feed/${n}` : "";
487
+ // queries: clicking a post loses my language · the permalink is English · navigation language
488
+ //
489
+ // EVERY NAVIGATION STAYS IN THE READER'S LANGUAGE — the founder's rule, and it is stronger than
490
+ // "link to the translation when there is one". Set a persona to Italian and EVERY page of it is
491
+ // Italian; a post with no Italian gives the original words with Italian chrome around them, and
492
+ // `/feed/{n}/it` renders exactly that rather than 404ing.
493
+ //
494
+ // The one URL that carries no segment is the post read in the language it was written in: there
495
+ // is no "translation into the original", so the permalink is already that page.
496
+ const orig = p.originalLang || "en";
497
+ const tr = !!p.translated && !!p.originalBody;
498
+ const base = n ? `/feed/${n}` : "";
499
+ const perma = base && LANG !== orig ? `${base}/${LANG}` : base;
409
500
  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>`;
501
+ // The way back, in the language of the person reading it. A real <a> so the delegated
502
+ // click-anywhere handler above lets it through — it returns early on any link.
503
+ // Both texts ride in the markup so the swap is instant and needs no second request. The link
504
+ // keeps a real href — without scripts it still reaches the original's own page.
505
+ const note = tr
506
+ ? `<p class="tnote" dir="auto"><span data-tstate>${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}</span><span class="dot">·</span>` +
507
+ `<a href="${base}" data-toggle-original data-original="${esc(p.originalBody)}" data-translation="${esc(p.body)}"` +
508
+ ` data-show-original="${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}" data-show-translation="${esc(SHOW_TRANSLATION[LANG] ?? SHOW_TRANSLATION.en)}"` +
509
+ ` data-translated="${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}" data-original-label="${esc(orig.toUpperCase())} · ${esc(NAME).replace(/<[^>]*>/g, "")}"` +
510
+ ` data-original-lang="${esc(orig)}" data-lang="${esc(p.lang || LANG)}">${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}</a></p>`
511
+ : "";
512
+ // `dir="auto"` on the body: an Arabic post inside an LTR page — and the reverse — must lay
513
+ // itself out from its own first strong character, not from the page around it.
514
+ 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
515
  };
412
516
 
413
517
  let done = false;
@@ -415,7 +519,10 @@ const vars = [
415
519
  if (done) return;
416
520
  done = true;
417
521
  try {
418
- const res = await fetch("https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA));
522
+ // `lang` rides in the query because it IS the content here — api.justai.pro is CDN'd with the
523
+ // query string in the cache key, so this is one more entry rather than one more origin hit.
524
+ const res = await fetch(
525
+ "https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA) + "&lang=" + encodeURIComponent(LANG));
419
526
  if (!res.ok) return;
420
527
  const data = await res.json();
421
528
  feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");