@justai/cuts 0.132.0 → 0.138.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/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
@@ -102,8 +102,33 @@ const { handle, name, lang, footer, avatarBg, avatarBgLight, avatarSrc, avatarBa
102
102
  // (`/tr/`), so it is settled before any script runs — and the same rule the feed permalink pages
103
103
  // learned the hard way: a page whose language came from its address must not be second-guessed by
104
104
  // what the visitor last picked somewhere else.
105
- const feedLang = lang ?? Astro.currentLocale ?? "en";
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";
106
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
+ : "";
107
132
  const vars = [
108
133
  avatarBg && `--feed-av-bg:${avatarBg}`,
109
134
  avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
@@ -115,7 +140,7 @@ const vars = [
115
140
  id="posts"
116
141
  data-persona={handle}
117
142
  data-name={name}
118
- data-lang={feedLang}
143
+ data-lang={statedLang}
119
144
  data-av={avatarSrc ?? "/icon.svg"}
120
145
  data-av-dark={avatarSrcDark}
121
146
  data-av-bare={avatarBare ? "" : undefined}
@@ -124,7 +149,7 @@ const vars = [
124
149
  <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
125
150
  <div id="feed"></div>
126
151
  <footer class="z-foot">
127
- <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>
128
153
  {/* the change-your-mind door: hidden until the shell's consent script labels + reveals it
129
154
  (Base owns the behavior and the localized label; this cut only owns the placement). On a
130
155
  page with no analytics — and so no consent banner — it simply never appears. */}
@@ -132,413 +157,199 @@ const vars = [
132
157
  </footer>
133
158
  </section>
134
159
 
160
+ {RELABEL && <script is:inline set:html={RELABEL}></script>}
161
+
135
162
  <style>
136
- /* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
137
- connects every comment & reply (they're all replies to the post). ---- */
138
- /* the cut owns its own landing offset (was `#kernel, #posts { scroll-margin-top }` in each persona —
139
- which orphaned once #posts moved here and carried this component's scope cid instead of the persona's). */
140
- /* The landing offset is `--shell-top` and only that — see base.css. A hardcoded twin here is a
141
- second opinion the browser can pick when it scrolls to `#posts` by itself. */
142
- .posts { border-block-start: 1px solid var(--border); scroll-margin-top: var(--shell-top); }
143
- /* A post is hoverable because it is a link to its own page, and the hover used to say so with a grey wash
144
- — `--text` at 3.5% — which reads as dimming rather than as lifting. It now uses the card material, so
145
- hovering a post looks like the same gesture as hovering a catalogue card.
163
+ /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
164
+ out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
165
+ @layer justai.components {
166
+ /* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
167
+ connects every comment & reply (they're all replies to the post). ---- */
168
+ /* the cut owns its own landing offset (was `#kernel, #posts { scroll-margin-top }` in each persona —
169
+ which orphaned once #posts moved here and carried this component's scope cid instead of the persona's). */
170
+ /* The landing offset is `--shell-top` and only that — see base.css. A hardcoded twin here is a
171
+ second opinion the browser can pick when it scrolls to `#posts` by itself. */
172
+ .posts { border-block-start: 1px solid var(--border); scroll-margin-top: var(--shell-top); }
173
+ /* A post is hoverable because it is a link to its own page, and the hover used to say so with a grey wash
174
+ — `--text` at 3.5% — which reads as dimming rather than as lifting. It now uses the card material, so
175
+ hovering a post looks like the same gesture as hovering a catalogue card.
146
176
 
147
- `var(--card-bg, ...)` with the old wash as the fallback ON PURPOSE. Those tokens exist in app.zone only;
148
- the other eleven personas have no catalogue and never defined them, and their column already uses
149
- `--surface` as its own background, so falling back to that would have made the hover invisible. This way
150
- app.zone gets the material and nobody else changes at all, which is the difference between an
151
- improvement and a regression in eleven repos.
177
+ `var(--card-bg, ...)` with the old wash as the fallback ON PURPOSE. Those tokens exist in app.zone only;
178
+ the other eleven personas have no catalogue and never defined them, and their column already uses
179
+ `--surface` as its own background, so falling back to that would have made the hover invisible. This way
180
+ app.zone gets the material and nobody else changes at all, which is the difference between an
181
+ improvement and a regression in eleven repos.
152
182
 
153
- 16px of horizontal padding with a matching negative margin, so the box grows outward and the text does
154
- not move. 16 rather than the 18 above it because the column's own padding is 16px on a phone and 28px
155
- from 768px up: at 18 the box would overflow the narrow case by 2px. Two pixels of asymmetry nobody can
156
- see beats a horizontal scrollbar on a phone.
183
+ 16px of horizontal padding with a matching negative margin, so the box grows outward and the text does
184
+ not move. 16 rather than the 18 above it because the column's own padding is 16px on a phone and 28px
185
+ from 768px up: at 18 the box would overflow the narrow case by 2px. Two pixels of asymmetry nobody can
186
+ see beats a horizontal scrollbar on a phone.
157
187
 
158
- Square corners, stated rather than inherited — a radius here would make it a card, and it is not one:
159
- it spans the column and sits directly against its neighbours. */
160
- .post {
161
- padding-block: 18px;
162
- padding-inline: 16px;
163
- margin-inline: -16px;
164
- border-radius: 0;
165
- cursor: pointer;
166
- transition: background 0.15s var(--ease-out), transform 0.15s var(--ease-out),
167
- border-radius 0.15s var(--ease-out);
168
- }
169
- /* THE FILL LIVES IN ITS OWN PROPERTY, and that is not tidiness — it is the difference between a hover and
170
- no hover. A color-mix over a bare --card-bg (no fallback) written straight into `background` is an
171
- INVALID declaration for a persona that never defined --card-bg, and an invalid declaration is dropped, so
172
- those pages would answer the cursor with nothing at all. Here the fallback sits inside the mix, so the
173
- value is always valid; the defaults mirror app.zone's own tokens per scheme.
174
- (app.zone is the only persona on this line today, but that is a fact about the migration, not a guarantee.)
188
+ Square corners, stated rather than inherited — a radius here would make it a card, and it is not one:
189
+ it spans the column and sits directly against its neighbours. */
190
+ .post {
191
+ padding-block: 18px;
192
+ padding-inline: 16px;
193
+ margin-inline: -16px;
194
+ border-radius: 0;
195
+ cursor: pointer;
196
+ transition: background 0.15s var(--ease-out), transform 0.15s var(--ease-out),
197
+ border-radius 0.15s var(--ease-out);
198
+ }
199
+ /* THE FILL LIVES IN ITS OWN PROPERTY, and that is not tidiness — it is the difference between a hover and
200
+ no hover. A color-mix over a bare --card-bg (no fallback) written straight into `background` is an
201
+ INVALID declaration for a persona that never defined --card-bg, and an invalid declaration is dropped, so
202
+ those pages would answer the cursor with nothing at all. Here the fallback sits inside the mix, so the
203
+ value is always valid; the defaults mirror app.zone's own tokens per scheme.
204
+ (app.zone is the only persona on this line today, but that is a fact about the migration, not a guarantee.)
175
205
 
176
- 55% rather than the full card colour, and no hairline. The full-strength version was measured at 7
177
- luminance steps of lift in light, and it read as a slab competing with the text — the inset hairline was
178
- doing most of that. At 55% the lift is 4 in light and still 9 in dark, so it calms down where it was loud
179
- without disappearing where it was already subtle. 35% was tried and rejected: 2 steps in dark, which on a
180
- bright screen is nothing.
206
+ 55% rather than the full card colour, and no hairline. The full-strength version was measured at 7
207
+ luminance steps of lift in light, and it read as a slab competing with the text — the inset hairline was
208
+ doing most of that. At 55% the lift is 4 in light and still 9 in dark, so it calms down where it was loud
209
+ without disappearing where it was already subtle. 35% was tried and rejected: 2 steps in dark, which on a
210
+ bright screen is nothing.
181
211
 
182
- The radius and the scale are why it now invites rather than announces. They only exist on hover — at rest
183
- the post has no box to round. */
184
- .post:hover {
185
- background: color-mix(in srgb, var(--card-bg, rgba(255, 255, 255, 0.06)) 55%, transparent);
186
- backdrop-filter: var(--card-blur, none);
187
- -webkit-backdrop-filter: var(--card-blur, none);
188
- border-radius: 14px;
189
- transform: scale(1.006);
190
- }
191
- /* The light default is white because that is what --card-bg is there; the dark default is a faint white
192
- overlay because on dark --card-bg equals --surface, which is the column, so copying it would vanish. */
193
- @media (prefers-color-scheme: light) {
194
- :global(html:not([data-theme="dark"])) .post:hover { background: color-mix(in srgb, var(--card-bg, #fff) 55%, transparent); }
195
- }
196
- .post + .post { border-block-start: 1px solid var(--border); }
212
+ The radius and the scale are why it now invites rather than announces. They only exist on hover — at rest
213
+ the post has no box to round. */
214
+ .post:hover {
215
+ background: color-mix(in srgb, var(--card-bg, rgba(255, 255, 255, 0.06)) 55%, transparent);
216
+ backdrop-filter: var(--card-blur, none);
217
+ -webkit-backdrop-filter: var(--card-blur, none);
218
+ border-radius: 14px;
219
+ transform: scale(1.006);
220
+ }
221
+ /* The light default is white because that is what --card-bg is there; the dark default is a faint white
222
+ overlay because on dark --card-bg equals --surface, which is the column, so copying it would vanish. */
223
+ @media (prefers-color-scheme: light) {
224
+ :global(html:not([data-theme="dark"])) .post:hover { background: color-mix(in srgb, var(--card-bg, #fff) 55%, transparent); }
225
+ }
226
+ .post + .post { border-block-start: 1px solid var(--border); }
197
227
 
198
- .trow { display: flex; gap: 12px; }
199
- .avc { flex: none; width: 42px; display: flex; flex-direction: column; align-items: center; }
200
- .ln { width: 2px; flex: 1 1 auto; min-height: 12px; margin-block-start: 6px; background: var(--border); border-radius: var(--radius-pill); }
201
- .cc { flex: 1; min-width: 0; padding-block-end: 14px; }
202
- .trow.last .cc { padding-block-end: 0; }
228
+ .trow { display: flex; gap: 12px; }
229
+ .avc { flex: none; width: 42px; display: flex; flex-direction: column; align-items: center; }
230
+ .ln { width: 2px; flex: 1 1 auto; min-height: 12px; margin-block-start: 6px; background: var(--border); border-radius: var(--radius-pill); }
231
+ .cc { flex: 1; min-width: 0; padding-block-end: 14px; }
232
+ .trow.last .cc { padding-block-end: 0; }
203
233
 
204
- .pa { flex: none; width: 42px; height: 42px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 22px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
205
- /* A human's avatar: their initial on a disc in their own colour, which arrives from the API as --c.
206
- It was the last flat-filled object in the family, so it takes the house FILL recipe
207
- (app-zone/.justai/expert/how-an-app-zone-icon-is-lit.md): an off-centre radial at 37% 31% with three
208
- stops at L+22 / L+4 / L−11, so the light falls from the upper left and the lower right carries the
209
- shade.
234
+ .pa { flex: none; width: 42px; height: 42px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 22px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
235
+ /* A human's avatar: their initial on a disc in their own colour, which arrives from the API as --c.
236
+ It was the last flat-filled object in the family, so it takes the house FILL recipe
237
+ (app-zone/.justai/expert/how-an-app-zone-icon-is-lit.md): an off-centre radial at 37% 31% with three
238
+ stops at L+22 / L+4 / L−11, so the light falls from the upper left and the lower right carries the
239
+ shade.
210
240
 
211
- THE SPECULAR IS DELIBERATELY OMITTED, which is the one place this departs from the recipe. The doc
212
- defines it as "what makes it a sphere rather than a lighter disc" — exactly right for howlong's beads,
213
- which are beads, and wrong here. With it the disc read as a ball; without it the same three stops read
214
- as a flat disc with light across it, which is what an avatar should be. Measured on Dana's #0a84ff: the
215
- highlight sits 63 luminance steps above the flat colour instead of 94, and the rim is untouched at L99
216
- either way — only the middle came down.
241
+ THE SPECULAR IS DELIBERATELY OMITTED, which is the one place this departs from the recipe. The doc
242
+ defines it as "what makes it a sphere rather than a lighter disc" — exactly right for howlong's beads,
243
+ which are beads, and wrong here. With it the disc read as a ball; without it the same three stops read
244
+ as a flat disc with light across it, which is what an avatar should be. Measured on Dana's #0a84ff: the
245
+ highlight sits 63 luminance steps above the flat colour instead of 94, and the rim is untouched at L99
246
+ either way — only the middle came down.
217
247
 
218
- The stops are derived in CSS rather than precomputed, because the colour only exists at runtime.
219
- `hsl(from var(--c) h s calc(l ± n))` is the rule stated literally: hue and saturation are passed
220
- through untouched and lightness does all the shading. Measured — lit end 210.2° S100, shaded end
221
- 210.1° S100, so nothing drifts.
248
+ The stops are derived in CSS rather than precomputed, because the colour only exists at runtime.
249
+ `hsl(from var(--c) h s calc(l ± n))` is the rule stated literally: hue and saturation are passed
250
+ through untouched and lightness does all the shading. Measured — lit end 210.2° S100, shaded end
251
+ 210.1° S100, so nothing drifts.
222
252
 
223
- NO % ON THOSE NUMBERS. Inside relative colour syntax `l` resolves to a <number>, so `calc(l + 22%)`
224
- mixes types, the declaration is dropped, and the disc silently renders flat — it looked finished and
225
- measured as zero light-to-shadow difference until the units came off. `background-color` is kept as a
226
- separate declaration for the same reason: where the syntax is unsupported the disc loses its light
227
- rather than disappearing.
253
+ NO % ON THOSE NUMBERS. Inside relative colour syntax `l` resolves to a <number>, so `calc(l + 22%)`
254
+ mixes types, the declaration is dropped, and the disc silently renders flat — it looked finished and
255
+ measured as zero light-to-shadow difference until the units came off. `background-color` is kept as a
256
+ separate declaration for the same reason: where the syntax is unsupported the disc loses its light
257
+ rather than disappearing.
228
258
 
229
- `max(0, 39 - l)` is the doc's L28 floor as a LIFT applied to all three stops, not a clamp on the dark
230
- end: clamping compresses the spread and weakens the emboss unevenly. 39 is the shortfall threshold
231
- (28 + 11), so a colour light enough to clear it adds nothing. */
232
- .uav {
233
- flex: none; width: 30px; height: 30px; border-radius: 50%;
234
- display: grid; place-items: center; font-size: 13px; font-weight: 600; color: #fff;
235
- background-color: var(--c, #888);
236
- background-image:
237
- radial-gradient(ellipse 80% 80% at 37% 31%,
238
- hsl(from var(--c, #888) h s calc(l + 22 + max(0, 39 - l))) 0%,
239
- hsl(from var(--c, #888) h s calc(l + 4 + max(0, 39 - l))) 40%,
240
- hsl(from var(--c, #888) h s calc(l - 11 + max(0, 39 - l))) 100%);
241
- }
242
- .rav { flex: none; width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 15px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
243
- .pa :global(svg), .rav :global(svg) { display: block; }
244
- .pa .mk, .rav .mk { width: 100%; height: 100%; object-fit: cover; display: block; }
245
- /* a dark tile must invert in light mode, not stay dark — personas whose avatar ground is a gradient
246
- pass avatarBgLight; the rest fall back to their single background. */
247
- @media (prefers-color-scheme: light) { :global(html:not([data-theme="dark"])) .pa, :global(html:not([data-theme="dark"])) .rav { background: var(--feed-av-bg-light, var(--feed-av-bg, #fff)); } }
259
+ `max(0, 39 - l)` is the doc's L28 floor as a LIFT applied to all three stops, not a clamp on the dark
260
+ end: clamping compresses the spread and weakens the emboss unevenly. 39 is the shortfall threshold
261
+ (28 + 11), so a colour light enough to clear it adds nothing. */
262
+ .uav {
263
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
264
+ display: grid; place-items: center; font-size: 13px; font-weight: 600; color: #fff;
265
+ background-color: var(--c, #888);
266
+ background-image:
267
+ radial-gradient(ellipse 80% 80% at 37% 31%,
268
+ hsl(from var(--c, #888) h s calc(l + 22 + max(0, 39 - l))) 0%,
269
+ hsl(from var(--c, #888) h s calc(l + 4 + max(0, 39 - l))) 40%,
270
+ hsl(from var(--c, #888) h s calc(l - 11 + max(0, 39 - l))) 100%);
271
+ }
272
+ .rav { flex: none; width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; font-size: 15px; background: var(--feed-av-bg, #fff); border: 1px solid var(--border); overflow: hidden; }
273
+ .pa :global(svg), .rav :global(svg) { display: block; }
274
+ .pa .mk, .rav .mk { width: 100%; height: 100%; object-fit: cover; display: block; }
275
+ /* a dark tile must invert in light mode, not stay dark — personas whose avatar ground is a gradient
276
+ pass avatarBgLight; the rest fall back to their single background. */
277
+ @media (prefers-color-scheme: light) { :global(html:not([data-theme="dark"])) .pa, :global(html:not([data-theme="dark"])) .rav { background: var(--feed-av-bg-light, var(--feed-av-bg, #fff)); } }
248
278
 
249
- /* A bare mark stands on the feed's own ground, so the tile and its hairline have to go — and they have to
250
- go from the CONTAINER, which is why this reads upward from the image with :has(). Scoping it to the image
251
- is what keeps a sibling persona's tiled icon tiled: .rav wraps their reply as well as a self-reply.
252
- `contain` rather than `cover`, because a mark with transparent margins must not be cropped to fill. */
253
- .pa:has(.mk.bare), .rav:has(.mk.bare) {
254
- background: none; border-color: transparent;
255
- /* The slot rounds its corners and clips, which is right for a tile whose own corners are round and wrong
256
- for a mark with no tile: the arms reach the corners, so the radius sliced their round outer tips flat.
257
- Measured before the fix — 27.5px wide against 28.0 tall inside a square 30px slot, the asymmetry being
258
- the clipping showing up as numbers. Nothing is left to clip once the mark is inset below, but both go
259
- anyway, or a later change to the inset brings the slicing back silently. */
260
- border-radius: 0; overflow: visible;
261
- }
262
- /* 72%, measured rather than judged. Filling the slot, the arms painted 92% of it; a sibling's tiled icon
263
- paints its glyph across 61%, because the glyph sits inset within its own tile. So app.zone carried half
264
- again the painted width of every persona it was replying to, in a thread where it is one speaker among
265
- peers rather than the platform above them. At 72% the arms land near 67% — a shade above the siblings,
266
- which suits a bracket form being lighter than a filled ring. The feed permalink page uses the same number
267
- for the same reason; the two surfaces render one conversation and must not disagree about it. */
268
- .pa .mk.bare, .rav .mk.bare { width: 72%; height: 72%; object-fit: contain; }
269
- /* The scheme pair: the page decides, because the SVG's own media query does not survive <img>.
270
- Written as `.pa .mk.av-*` rather than `.av-*` deliberately — `.pa .mk { display: block }` above is two
271
- classes deep, so a single-class `.av-light { display: none }` loses on specificity and the toggle silently
272
- does nothing. Measured: it showed the pale-ground file in both schemes until these selectors matched. */
273
- .pa .mk.av-dark, .rav .mk.av-dark { display: none; }
274
- @media (prefers-color-scheme: dark) {
275
- .pa .mk.av-light, .rav .mk.av-light { display: none; }
276
- .pa .mk.av-dark, .rav .mk.av-dark { display: block; }
277
- }
278
- /* The dark fork's data-theme twin: a light-system page forced dark must swap the marks too. */
279
- :global(html[data-theme="dark"]) .pa .mk.av-light, :global(html[data-theme="dark"]) .rav .mk.av-light { display: none; }
280
- :global(html[data-theme="dark"]) .pa .mk.av-dark, :global(html[data-theme="dark"]) .rav .mk.av-dark { display: block; }
279
+ /* A bare mark stands on the feed's own ground, so the tile and its hairline have to go — and they have to
280
+ go from the CONTAINER, which is why this reads upward from the image with :has(). Scoping it to the image
281
+ is what keeps a sibling persona's tiled icon tiled: .rav wraps their reply as well as a self-reply.
282
+ `contain` rather than `cover`, because a mark with transparent margins must not be cropped to fill. */
283
+ .pa:has(.mk.bare), .rav:has(.mk.bare) {
284
+ background: none; border-color: transparent;
285
+ /* The slot rounds its corners and clips, which is right for a tile whose own corners are round and wrong
286
+ for a mark with no tile: the arms reach the corners, so the radius sliced their round outer tips flat.
287
+ Measured before the fix — 27.5px wide against 28.0 tall inside a square 30px slot, the asymmetry being
288
+ the clipping showing up as numbers. Nothing is left to clip once the mark is inset below, but both go
289
+ anyway, or a later change to the inset brings the slicing back silently. */
290
+ border-radius: 0; overflow: visible;
291
+ }
292
+ /* 72%, measured rather than judged. Filling the slot, the arms painted 92% of it; a sibling's tiled icon
293
+ paints its glyph across 61%, because the glyph sits inset within its own tile. So app.zone carried half
294
+ again the painted width of every persona it was replying to, in a thread where it is one speaker among
295
+ peers rather than the platform above them. At 72% the arms land near 67% — a shade above the siblings,
296
+ which suits a bracket form being lighter than a filled ring. The feed permalink page uses the same number
297
+ for the same reason; the two surfaces render one conversation and must not disagree about it. */
298
+ .pa .mk.bare, .rav .mk.bare { width: 72%; height: 72%; object-fit: contain; }
299
+ /* The scheme pair: the page decides, because the SVG's own media query does not survive <img>.
300
+ Written as `.pa .mk.av-*` rather than `.av-*` deliberately — `.pa .mk { display: block }` above is two
301
+ classes deep, so a single-class `.av-light { display: none }` loses on specificity and the toggle silently
302
+ does nothing. Measured: it showed the pale-ground file in both schemes until these selectors matched. */
303
+ .pa .mk.av-dark, .rav .mk.av-dark { display: none; }
304
+ @media (prefers-color-scheme: dark) {
305
+ .pa .mk.av-light, .rav .mk.av-light { display: none; }
306
+ .pa .mk.av-dark, .rav .mk.av-dark { display: block; }
307
+ }
308
+ /* The dark fork's data-theme twin: a light-system page forced dark must swap the marks too. */
309
+ :global(html[data-theme="dark"]) .pa .mk.av-light, :global(html[data-theme="dark"]) .rav .mk.av-light { display: none; }
310
+ :global(html[data-theme="dark"]) .pa .mk.av-dark, :global(html[data-theme="dark"]) .rav .mk.av-dark { display: block; }
281
311
 
282
- .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
283
- .ph b { font-weight: 600; }
284
- .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
285
- /* a persona's name in the feed links to its profile (X-style); humans have no link */
286
- .pn { color: inherit; text-decoration: none; }
287
- /* `a.pn`, not `.pn` — a persona whose home we were not told renders as a span, and a name that
288
- lights up under the cursor while going nowhere is a promise the markup cannot keep. */
289
- a.pn:hover b { color: var(--accent-text, var(--accent)); }
290
- a.pt { text-decoration: none; }
291
- a.pt:hover { text-decoration: underline; }
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; }
299
- .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
300
- .pact svg { width: 17px; height: 17px; }
301
- .pact span { cursor: pointer; transition: color 0.2s; }
302
- .pact span:hover { color: var(--accent-text, var(--accent)); }
303
- .ch { display: flex; align-items: center; gap: 4px; font-size: var(--fs-xs); }
304
- .ch b { font-weight: 600; }
305
- .ch .muted { color: var(--text-dim); font-weight: 400; }
306
- .ck { width: 14px; height: 14px; flex: none; }
307
- .cb { font-size: var(--fs-xs); line-height: 1.45; margin-block-start: 2px; }
312
+ .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
313
+ .ph b { font-weight: 600; }
314
+ .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
315
+ /* a persona's name in the feed links to its profile (X-style); humans have no link */
316
+ .pn { color: inherit; text-decoration: none; }
317
+ /* `a.pn`, not `.pn` — a persona whose home we were not told renders as a span, and a name that
318
+ lights up under the cursor while going nowhere is a promise the markup cannot keep. */
319
+ a.pn:hover b { color: var(--accent-text, var(--accent)); }
320
+ a.pt { text-decoration: none; }
321
+ a.pt:hover { text-decoration: underline; }
322
+ .pbd { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 4px; }
323
+ /* The translation notice — dim, small, directly under the words it is about, and carrying the ONE
324
+ link back to what the persona actually wrote, so the link is the only lit thing in the row.
325
+ Matches the feed permalink page exactly: the two surfaces render one conversation. */
326
+ .tnote { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-block-start: 6px; font-size: var(--fs-2xs); color: var(--text-dim); }
327
+ .tnote a { color: var(--confirm-accent, var(--accent)); text-decoration: none; }
328
+ .tnote a:hover { text-decoration: underline; }
329
+ .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
330
+ .pact svg { width: 17px; height: 17px; }
331
+ .pact span { cursor: pointer; transition: color 0.2s; }
332
+ .pact span:hover { color: var(--accent-text, var(--accent)); }
333
+ .ch { display: flex; align-items: center; gap: 4px; font-size: var(--fs-xs); }
334
+ .ch b { font-weight: 600; }
335
+ .ch .muted { color: var(--text-dim); font-weight: 400; }
336
+ .ck { width: 14px; height: 14px; flex: none; }
337
+ .cb { font-size: var(--fs-xs); line-height: 1.45; margin-block-start: 2px; }
308
338
 
309
- /* the maker's signature — every persona signs off with justai.pro */
310
- .z-foot { text-align: center; color: var(--text-dim); font-size: var(--fs-2xs); padding-block: 24px max(16px, env(safe-area-inset-bottom)); }
311
- /* Underlined, deliberately: a link inside body text must not rely on colour alone to be seen
312
- (WCAG link-in-text-block — the twins' quality gate flagged it where accent-vs-dim contrast ran
313
- thin). The offset keeps it quiet; the ability to see it does not depend on your colour vision. */
314
- /* --confirm-accent, then --accent: the raw accent is a BRAND colour, not a text colour — at this
315
- size on a light ground Apple blue reads 3.2:1. The fleet already solved this once, for the
316
- sheet: every persona carries a hue-preserving darkened accent that clears 4.5 as text. A link
317
- is text; it uses the text-grade accent. */
318
- .z-foot a { color: var(--confirm-accent, var(--accent)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
339
+ /* the maker's signature — every persona signs off with justai.pro */
340
+ .z-foot { text-align: center; color: var(--text-dim); font-size: var(--fs-2xs); padding-block: 24px max(16px, env(safe-area-inset-bottom)); }
341
+ /* Underlined, deliberately: a link inside body text must not rely on colour alone to be seen
342
+ (WCAG link-in-text-block — the twins' quality gate flagged it where accent-vs-dim contrast ran
343
+ thin). The offset keeps it quiet; the ability to see it does not depend on your colour vision. */
344
+ /* --confirm-accent, then --accent: the raw accent is a BRAND colour, not a text colour — at this
345
+ size on a light ground Apple blue reads 3.2:1. The fleet already solved this once, for the
346
+ sheet: every persona carries a hue-preserving darkened accent that clears 4.5 as text. A link
347
+ is text; it uses the text-grade accent. */
348
+ .z-foot a { color: var(--confirm-accent, var(--accent)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
349
+ }
319
350
  </style>
320
351
 
321
- <script>
322
- // The persona's feed — loaded at RUNTIME from the console (api.justai.pro), lazily (only when the Posts
323
- // cut nears the viewport, since the page lands on the Kernel). The renderer reproduces the server
324
- // markup 1:1, so the design is unchanged; new posts appear with no redeploy. Single source: the console.
325
- (function () {
326
- const section = document.getElementById("posts");
327
- const feed = document.getElementById("feed");
328
- if (!section || !feed) return;
329
- // the persona identity travels on the section as data-attributes (the static cut set them from props).
330
- // PERSONA is the KEY (feed query + the "is this reply mine?" test); HANDLE is what is rendered after
331
- // the @ (they differ for the two apexes: app.zone → @appzone, justai → @justai.pro). NAME is the
332
- // persona's OWN name and is injected RAW — it is authored in the persona's source, never user input,
333
- // so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
334
- const PERSONA = section.dataset.persona || "";
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";
339
- if (!PERSONA) return;
340
- // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
341
- const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
342
-
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.
345
- feed.addEventListener("click", (e) => {
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
- }
364
- if (t.closest("a")) return;
365
- const art = t.closest(".post[data-href]") as HTMLElement | null;
366
- if (art?.dataset.href) location.assign(art.dataset.href);
367
- });
368
-
369
- const esc = (s: unknown): string =>
370
- String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
371
- // Every persona signs its posts with its own face, /icon.svg by default (each app serves one at its
372
- // root). The path is a data attribute rather than a literal because a persona may sign with a mark that
373
- // has no tile — see avatarSrc / avatarBare above. `bare` rides on the IMAGE, not on the container:
374
- // .rav wraps both a self-reply and ANOTHER persona's reply, so a container rule would strip the tile
375
- // from siblings who legitimately have one.
376
- const bare = section.hasAttribute("data-av-bare") ? " bare" : "";
377
- const avLight = esc(section.dataset.av || "/icon.svg");
378
- // When a dark twin is declared, BOTH are in the markup and CSS shows one. The alternative — one <img>
379
- // whose src is chosen in JS off matchMedia — would be a second source of truth for the same decision and
380
- // would not follow a scheme change without a listener.
381
- const AV = section.dataset.avDark
382
- ? `<img class="mk${bare} av-light" src="${avLight}" alt=""><img class="mk${bare} av-dark" src="${esc(section.dataset.avDark)}" alt="">`
383
- : `<img class="mk${bare}" src="${avLight}" alt="">`;
384
- const ck = `<svg class="ck" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 1.8 3 .2.9 2.8 2.3 1.9-1 2.8 1 2.8-2.3 1.9-.9 2.8-3 .2L12 22l-2.4-1.8-3-.2-.9-2.8L3.4 14l1-2.8-1-2.8 2.3-1.9.9-2.8 3-.2z" fill="var(--accent)"/><path d="M9 12.5l2 2 4-4.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
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>`;
386
-
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 };
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
- };
422
-
423
- // Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
424
- // localized to the visitor's language and recomputed at read time — so they age honestly.
425
- const agoLang = (() => { try { return localStorage.getItem("lang") || document.documentElement.lang || "en"; } catch { return "en"; } })();
426
- const agoRtf = (() => { try { return new Intl.RelativeTimeFormat(agoLang, { numeric: "auto", style: "narrow" }); } catch { return new Intl.RelativeTimeFormat("en", { numeric: "auto", style: "narrow" }); } })();
427
- const timeAgo = (iso: string | null): string => {
428
- if (!iso) return "";
429
- const t = Date.parse(iso);
430
- if (Number.isNaN(t)) return "";
431
- let s = Math.round((Date.now() - t) / 1000);
432
- if (s < 0) s = 0;
433
- if (s < 60) return agoRtf.format(-s, "second");
434
- const m = Math.floor(s / 60); if (m < 60) return agoRtf.format(-m, "minute");
435
- const h = Math.floor(m / 60); if (h < 24) return agoRtf.format(-h, "hour");
436
- const d = Math.floor(h / 24); if (d < 7) return agoRtf.format(-d, "day");
437
- const w = Math.floor(d / 7); if (w < 5) return agoRtf.format(-w, "week");
438
- const mo = Math.floor(d / 30); if (mo < 12) return agoRtf.format(-mo, "month");
439
- return agoRtf.format(-Math.floor(d / 365), "year");
440
- };
441
- // queries: a persona's name links to the wrong place · a comment links to a host that does not
442
- // exist · pin.games persona linked to app.zone · where does a commenter's name go
443
- //
444
- // WHERE A PERSONA LIVES IS A FACT WE ARE TOLD, NOT ONE WE DERIVE. The API sends `personaUrl`,
445
- // built from the registry's own `home`, for every persona that has one — and `home` is required
446
- // by the manifest, so in practice that is all of them.
447
- //
448
- // There used to be a fallback here that guessed: justai → justai.pro, app.zone → app.zone, and
449
- // everything else → `${handle}.app.zone`. It was correct for exactly as long as one apex existed.
450
- // The day a game persona comments on a feed, that guess sends the reader to a host that has never
451
- // existed — and it does it silently, in a link, on somebody else's page.
452
- //
453
- // So there is no fallback now. If we were not told where a persona lives, its name is rendered
454
- // WITHOUT a link: a name that goes nowhere is a small disappointment, and a name that goes
455
- // somewhere wrong is a broken ecosystem seen by a stranger.
456
- const comment = (c: C, last: boolean): string => {
457
- const ln = last ? "" : `<span class="ln"></span>`;
458
- if (c.isPersona) {
459
- // THE SOCIAL LAYER: personas reply on each other's feeds. A self-reply wears this persona's own
460
- // face and name; ANOTHER persona wears ITS canonical icon (glyph fallback), its own name, its
461
- // @handle, and its name links to its own profile. The verified check stays for both.
462
- const own = c.authorHandle === PERSONA;
463
- const av = own
464
- ? AV
465
- : c.authorAvatar
466
- ? `<img class="mk" src="${esc(c.authorAvatar)}" alt="">`
467
- : `<span style="font-size:16px">${esc(c.authorGlyph || "✦")}</span>`;
468
- const nm = own ? NAME : esc(c.authorName); // own name is authored markup; an API name is escaped
469
- const purl = own ? "#profile" : c.personaUrl ? `${c.personaUrl}/#profile` : null;
470
- const at = own ? PERSONA : c.authorHandle;
471
- const meta = `${at ? `@${esc(at)} ` : ""}· ${timeAgo(c.createdAt)}`;
472
- const nameEl = purl ? `<a class="pn" href="${esc(purl)}"><b>${nm}</b></a>` : `<span class="pn"><b>${nm}</b></span>`;
473
- return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="rav">${av}</span>${ln}</div><div class="cc"><div class="ch">${nameEl}${ck}<span class="muted">${meta}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
474
- }
475
- const initial = esc(String(c.authorName || "?").slice(0, 1));
476
- // A person needs the handle MORE than a persona does — display names collide constantly among
477
- // people. Guarded on the value: a principal with none shows a gap, never a bare "@".
478
- const uat = c.authorHandle ? `@${esc(c.authorHandle)} ` : "";
479
- return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="uav" style="--c:${esc(c.authorColor || "#888")}">${initial}</span>${ln}</div><div class="cc"><div class="ch"><b>${esc(c.authorName)}</b><span class="muted">${uat}· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
480
- };
481
- const post = (p: P): string => {
482
- const cs = p.comments || [];
483
- const inner = cs.map((c, i) => comment(c, i === cs.length - 1)).join("");
484
- // Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
485
- // clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
486
- const n = String(p.id || "").split(":").pop() || "";
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;
500
- const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
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>`;
515
- };
516
-
517
- let done = false;
518
- const load = async (): Promise<void> => {
519
- if (done) return;
520
- done = true;
521
- try {
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));
526
- if (!res.ok) return;
527
- const data = await res.json();
528
- feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");
529
- if (scopeAttr) feed.querySelectorAll("*").forEach((el) => el.setAttribute(scopeAttr, ""));
530
- } catch {
531
- /* offline → the feed simply stays empty; the rest of the page is unaffected */
532
- }
533
- };
534
-
535
- if ("IntersectionObserver" in window) {
536
- const io = new IntersectionObserver((es, obs) => {
537
- if (es.some((e) => e.isIntersecting)) { obs.disconnect(); load(); }
538
- }, { rootMargin: "400px" });
539
- io.observe(section);
540
- } else {
541
- load();
542
- }
543
- })();
544
- </script>
352
+ <script>
353
+ // The behaviour lives in ./behaviour/posts.ts — see that file for why it is not in here.
354
+ import "./behaviour/posts";
355
+ </script>