@justai/cuts 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": {
@@ -15,7 +15,9 @@
15
15
  "exports": {
16
16
  "./Base.astro": "./src/Base.astro",
17
17
  "./LangPicker.astro": "./src/LangPicker.astro",
18
- "./BackToZone.astro": "./src/BackToZone.astro"
18
+ "./BackToZone.astro": "./src/BackToZone.astro",
19
+ "./Posts.astro": "./src/Posts.astro",
20
+ "./Toolbar.astro": "./src/Toolbar.astro"
19
21
  },
20
22
  "peerDependencies": {
21
23
  "@justai/ui": ">=0.2.0",
@@ -0,0 +1,177 @@
1
+ ---
2
+ // ░░ POSTS cut — a persona's feed, revealed by scrolling past the Kernel. ░░
3
+ //
4
+ // The feed's markup, its styling, AND its runtime loader were byte-near-identical across every persona
5
+ // (a change once had to touch all of them — that is how the `.cc` collision reached the whole fleet).
6
+ // So the whole section lives here now, once. A persona writes `<Posts handle="qr" name={t.title}
7
+ // footer={t.footer} />` and inherits the feed; a future change to how posts render is O(1).
8
+ //
9
+ // The loader is a normal (bundled) <script>, so it CANNOT see these props directly — it reads them off
10
+ // the `#posts` element's data-attributes, which the static template below is free to set. That keeps the
11
+ // script framework-free in spirit: the day a Next persona (auth's home feed) wants the same feed, this
12
+ // exact logic lifts into @justai/core unchanged. Until then, YAGNI — it stays an Astro cut.
13
+ //
14
+ // `avatarBg` is the only real per-persona knob (the post-avatar tile behind /icon.svg, matching the
15
+ // profile). Everything else is shared.
16
+ interface Props {
17
+ handle: string; // the persona's @handle — the feed query (?persona=) AND the rendered @handle
18
+ name: string; // the persona's display name, rendered on each of its own posts/replies
19
+ footer: string; // the localized footer tagline after "justai.pro ·"
20
+ avatarBg?: string; // post-avatar tile background (default #fff)
21
+ }
22
+ const { handle, name, footer, avatarBg } = Astro.props;
23
+ ---
24
+
25
+ <section
26
+ class="cut posts"
27
+ id="posts"
28
+ data-persona={handle}
29
+ data-name={name}
30
+ style={avatarBg ? `--feed-av-bg:${avatarBg}` : undefined}
31
+ >
32
+ <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
33
+ <div id="feed"></div>
34
+ <footer class="z-foot"><a href="https://justai.pro">justai.pro</a> · {footer}</footer>
35
+ </section>
36
+
37
+ <style>
38
+ /* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
39
+ connects every comment & reply (they're all replies to the post). ---- */
40
+ /* the cut owns its own landing offset (was `#kernel, #posts { scroll-margin-top }` in each persona —
41
+ which orphaned once #posts moved here and carried this component's scope cid instead of the persona's). */
42
+ .posts { border-block-start: 1px solid var(--border); scroll-margin-top: 82px; }
43
+ .post { padding-block: 18px; cursor: pointer; transition: background 0.15s var(--ease-out); }
44
+ .post:hover { background: color-mix(in srgb, var(--text) 3.5%, transparent); }
45
+ .post + .post { border-block-start: 1px solid var(--border); }
46
+
47
+ .trow { display: flex; gap: 12px; }
48
+ .avc { flex: none; width: 42px; display: flex; flex-direction: column; align-items: center; }
49
+ .ln { width: 2px; flex: 1 1 auto; min-height: 12px; margin-block-start: 6px; background: var(--border); border-radius: var(--radius-pill); }
50
+ .cc { flex: 1; min-width: 0; padding-block-end: 14px; }
51
+ .trow.last .cc { padding-block-end: 0; }
52
+
53
+ .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; }
54
+ .uav { flex: none; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 600; color: #fff; background: var(--c, #888); }
55
+ .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; }
56
+ .pa :global(svg), .rav :global(svg) { display: block; }
57
+ .pa .mk, .rav .mk { width: 100%; height: 100%; object-fit: cover; display: block; }
58
+
59
+ .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
60
+ .ph b { font-weight: 600; }
61
+ .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
62
+ /* a persona's name in the feed links to its profile (X-style); humans have no link */
63
+ .pn { color: inherit; text-decoration: none; }
64
+ .pn:hover b { color: var(--accent); }
65
+ a.pt { text-decoration: none; }
66
+ a.pt:hover { text-decoration: underline; }
67
+ .pbd { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 4px; }
68
+ .pact { display: flex; gap: 22px; margin-block-start: 12px; color: var(--text-dim); }
69
+ .pact svg { width: 17px; height: 17px; }
70
+ .pact span { cursor: pointer; transition: color 0.2s; }
71
+ .pact span:hover { color: var(--accent); }
72
+ .ch { display: flex; align-items: center; gap: 4px; font-size: var(--fs-xs); }
73
+ .ch b { font-weight: 600; }
74
+ .ch .muted { color: var(--text-dim); font-weight: 400; }
75
+ .ck { width: 14px; height: 14px; flex: none; }
76
+ .cb { font-size: var(--fs-xs); line-height: 1.45; margin-block-start: 2px; }
77
+
78
+ /* the maker's signature — every persona signs off with justai.pro */
79
+ .z-foot { text-align: center; color: var(--text-dim); font-size: var(--fs-2xs); padding-block: 24px max(16px, env(safe-area-inset-bottom)); }
80
+ .z-foot a { color: var(--accent); text-decoration: none; }
81
+ </style>
82
+
83
+ <script>
84
+ // The persona's feed — loaded at RUNTIME from the console (api.justai.pro), lazily (only when the Posts
85
+ // cut nears the viewport, since the page lands on the Kernel). The renderer reproduces the server
86
+ // markup 1:1, so the design is unchanged; new posts appear with no redeploy. Single source: the console.
87
+ (function () {
88
+ const section = document.getElementById("posts");
89
+ const feed = document.getElementById("feed");
90
+ if (!section || !feed) return;
91
+ // the persona identity travels on the section as data-attributes (the static cut set them from props)
92
+ const PERSONA = section.dataset.persona || "";
93
+ const NAME = section.dataset.name || "";
94
+ if (!PERSONA) return;
95
+ // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
96
+ const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
97
+
98
+ // Twitter-style: clicking anywhere on a post (except a real link) opens its feed page.
99
+ feed.addEventListener("click", (e) => {
100
+ const t = e.target as HTMLElement;
101
+ if (t.closest("a")) return;
102
+ const art = t.closest(".post[data-href]") as HTMLElement | null;
103
+ if (art?.dataset.href) location.assign(art.dataset.href);
104
+ });
105
+
106
+ const esc = (s: unknown): string =>
107
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
108
+ // every persona signs its posts with its own /icon.svg (each app serves one at its root)
109
+ const AV = `<img class="mk" src="/icon.svg" alt="">`;
110
+ 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>`;
111
+ 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>`;
112
+
113
+ type C = { body: string; createdAt: string | null; authorHandle: string; authorName: string; authorColor: string | null; isPersona: boolean };
114
+ type P = { id: string; body: string; createdAt: string | null; comments: C[] };
115
+
116
+ // Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
117
+ // localized to the visitor's language and recomputed at read time — so they age honestly.
118
+ const agoLang = (() => { try { return localStorage.getItem("lang") || document.documentElement.lang || "en"; } catch { return "en"; } })();
119
+ const agoRtf = (() => { try { return new Intl.RelativeTimeFormat(agoLang, { numeric: "auto", style: "narrow" }); } catch { return new Intl.RelativeTimeFormat("en", { numeric: "auto", style: "narrow" }); } })();
120
+ const timeAgo = (iso: string | null): string => {
121
+ if (!iso) return "";
122
+ const t = Date.parse(iso);
123
+ if (Number.isNaN(t)) return "";
124
+ let s = Math.round((Date.now() - t) / 1000);
125
+ if (s < 0) s = 0;
126
+ if (s < 60) return agoRtf.format(-s, "second");
127
+ const m = Math.floor(s / 60); if (m < 60) return agoRtf.format(-m, "minute");
128
+ const h = Math.floor(m / 60); if (h < 24) return agoRtf.format(-h, "hour");
129
+ const d = Math.floor(h / 24); if (d < 7) return agoRtf.format(-d, "day");
130
+ const w = Math.floor(d / 7); if (w < 5) return agoRtf.format(-w, "week");
131
+ const mo = Math.floor(d / 30); if (mo < 12) return agoRtf.format(-mo, "month");
132
+ return agoRtf.format(-Math.floor(d / 365), "year");
133
+ };
134
+ const comment = (c: C, last: boolean): string => {
135
+ const ln = last ? "" : `<span class="ln"></span>`;
136
+ if (c.isPersona) {
137
+ return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="rav">${AV}</span>${ln}</div><div class="cc"><div class="ch"><a class="pn" href="#profile"><b>${esc(NAME)}</b></a>${ck}<span class="muted">· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
138
+ }
139
+ const initial = esc(String(c.authorName || "?").slice(0, 1));
140
+ 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">@${esc(c.authorHandle)} · ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
141
+ };
142
+ const post = (p: P): string => {
143
+ const cs = p.comments || [];
144
+ const inner = cs.map((c, i) => comment(c, i === cs.length - 1)).join("");
145
+ // Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
146
+ // clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
147
+ const n = String(p.id || "").split(":").pop() || "";
148
+ const perma = n ? `/feed/${n}` : "";
149
+ const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
150
+ 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>${esc(NAME)}</b></a><span class="phandle">@${esc(PERSONA)}</span><span class="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
151
+ };
152
+
153
+ let done = false;
154
+ const load = async (): Promise<void> => {
155
+ if (done) return;
156
+ done = true;
157
+ try {
158
+ const res = await fetch("https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA));
159
+ if (!res.ok) return;
160
+ const data = await res.json();
161
+ feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");
162
+ if (scopeAttr) feed.querySelectorAll("*").forEach((el) => el.setAttribute(scopeAttr, ""));
163
+ } catch {
164
+ /* offline → the feed simply stays empty; the rest of the page is unaffected */
165
+ }
166
+ };
167
+
168
+ if ("IntersectionObserver" in window) {
169
+ const io = new IntersectionObserver((es, obs) => {
170
+ if (es.some((e) => e.isIntersecting)) { obs.disconnect(); load(); }
171
+ }, { rootMargin: "400px" });
172
+ io.observe(section);
173
+ } else {
174
+ load();
175
+ }
176
+ })();
177
+ </script>
@@ -0,0 +1,197 @@
1
+ ---
2
+ // ░░ TOOLBAR cut — the persona's sticky chrome: home · section-nav · language · sign-in, plus the
3
+ // scroll-reactive title strip and the scroll-spy that binds the whole page together. ░░
4
+ //
5
+ // This markup + its ~40 lines of CSS + its ~80-line scroll-spy were near-identical in every persona's
6
+ // main component (only the kernel glyph and a couple of optional controls differed). It lives here now,
7
+ // once. A persona writes:
8
+ //
9
+ // <Toolbar sectionsLabel={t.chromeSections} profileLabel={t.chromeProfile}
10
+ // kernelLabel={t.chromeApp} postsLabel={t.chromePosts}>
11
+ // <svg slot="kernelIcon" .../>
12
+ // <LangPicker slot="langPicker" current={lang} locales={locales} ... />
13
+ // </Toolbar>
14
+ //
15
+ // WHAT VARIES, AND WHY IT IS A PROP vs A SLOT (the rule: zero-coupling children are internalised;
16
+ // data-coupled ones are slots):
17
+ // • BackToZone — takes no props → internalised here (the persona no longer imports it).
18
+ // • the three nav aria-labels — resolved strings (like every other cut: Posts, LangPicker) → props.
19
+ // • kernelIcon — the ONE per-persona nav glyph (⚡ / clock / globe …) → required slot.
20
+ // • langPicker — needs the persona's own locale data (the list must NOT live in the shared package —
21
+ // it bit us twice; see @justai/ui/locale) → a slot the persona fills with its <LangPicker/>.
22
+ // • navExtra — optional extra nav item after Posts (e.g. the timers' fullscreen button) → slot.
23
+ // • toolbarRight — optional right-side control before the picker (e.g. time's 24H/AM-PM toggle) → slot.
24
+ //
25
+ // CONTRACT with the page (the same three cut ids the whole shell agrees on): the scroll-spy needs a
26
+ // `#below` wrapper around toolbar+kernel+posts, a `<section id="kernel">` between `#profile` and
27
+ // `#posts`, and reads `.pname`/`.handle` from the Profile for the title strip. It touches NO kernel
28
+ // internals. It is also the page's REVEAL: it sets `body.ready` (the other half of @justai/core's
29
+ // REVEAL_CSS) — so every persona that renders a Toolbar is revealed; move it and the page stays blank.
30
+ interface Props {
31
+ sectionsLabel: string; // aria-label for the section-nav group
32
+ profileLabel: string; // aria/title for the Profile nav icon
33
+ kernelLabel: string; // aria/title for the Kernel (app) nav icon
34
+ postsLabel: string; // aria/title for the Posts nav icon
35
+ }
36
+ import BackToZone from "./BackToZone.astro";
37
+ const { sectionsLabel, profileLabel, kernelLabel, postsLabel } = Astro.props;
38
+ ---
39
+
40
+ <header class="toolbar">
41
+ <div class="tb-row">
42
+ <div class="tb-left">
43
+ <BackToZone />
44
+ <nav class="tb-nav-group" aria-label={sectionsLabel}>
45
+ <a class="tb-nav" id="navProfile" href="#profile" aria-label={profileLabel} title={profileLabel}>
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
47
+ </a>
48
+ <a class="tb-nav" id="navKernel" href="#kernel" aria-label={kernelLabel} title={kernelLabel}>
49
+ <slot name="kernelIcon" />
50
+ </a>
51
+ <a class="tb-nav" id="navPosts" href="#posts" aria-label={postsLabel} title={postsLabel}>
52
+ <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>
53
+ </a>
54
+ <slot name="navExtra" />
55
+ </nav>
56
+ </div>
57
+ <div class="tb-right">
58
+ <slot name="toolbarRight" />
59
+ <slot name="langPicker" />
60
+ <div data-justai-auth></div>
61
+ </div>
62
+ </div>
63
+ <div class="tb-title" id="tbTitle"><span class="tb-title-text" id="tbTitleText"></span></div>
64
+ </header>
65
+ <!-- justai auth — sign-in avatar menu, inherited from auth.justai.pro (the developer writes only the kernel) -->
66
+ <script is:inline defer src="https://auth.justai.pro/sdk.js"></script>
67
+
68
+ <style>
69
+ /* ---- app toolbar — sticky to the top of the active region (kernel + posts); it never
70
+ enters the Profile (it lives inside .below). Flat / knife-cut: a left group and a
71
+ right group, no rounded pills. ---- */
72
+ .toolbar {
73
+ position: sticky; top: 0; z-index: 40;
74
+ display: flex; flex-direction: column;
75
+ /* full-bleed: the bar (frosted bg + hairline) touches the contained-view edges; the
76
+ icons stay aligned with the body via matching inline padding. */
77
+ margin-inline: calc(-1 * max(16px, env(safe-area-inset-left))) calc(-1 * max(16px, env(safe-area-inset-right)));
78
+ padding-block: max(8px, env(safe-area-inset-top)) 8px;
79
+ padding-inline: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right));
80
+ background: color-mix(in srgb, var(--bg) 70%, transparent);
81
+ backdrop-filter: blur(22px) saturate(1.5);
82
+ -webkit-backdrop-filter: blur(22px) saturate(1.5);
83
+ /* a very faint hairline always; it firms up once content scrolls under the bar */
84
+ border-block-end: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
85
+ transition: border-color 0.3s var(--ease-out);
86
+ }
87
+ .toolbar.stuck { border-block-end-color: var(--border); }
88
+ @media (min-width: 768px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
89
+ /* icons row + the scroll-reactive title strip below it */
90
+ .tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
91
+ .tb-title { display: flex; align-items: center; height: 0; overflow: hidden; opacity: 0; transition: height 0.35s var(--ease-out), opacity 0.25s var(--ease-out); }
92
+ .tb-title.show { height: 26px; opacity: 1; }
93
+ .tb-title-text { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.16s var(--ease-out); }
94
+ .tb-title-text.fade { opacity: 0; }
95
+ /* nav lives on the left, beside the back affordance — the middle is intentionally empty */
96
+ .tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
97
+ .tb-nav-group { display: flex; align-items: center; gap: 2px; }
98
+ .tb-right { display: flex; align-items: center; min-width: 0; }
99
+ /* icon-only section nav — current cut highlighted */
100
+ .tb-nav {
101
+ display: grid; place-items: center; width: 38px; height: 36px;
102
+ color: var(--text-dim);
103
+ transition: color 0.2s, opacity 0.2s, transform 0.2s var(--ease-spring);
104
+ }
105
+ .tb-nav:hover { color: var(--text); }
106
+ .tb-nav:active { transform: scale(0.9); }
107
+ .tb-nav.active { color: var(--accent); }
108
+ .tb-nav :global(svg) { width: 19px; height: 19px; }
109
+ </style>
110
+
111
+ <script>
112
+ // Land on the Kernel cut; Profile is above (scroll up), Posts below (scroll down). This is also the
113
+ // page's reveal (body.ready) and its scroll-spy (nav highlight + title strip + hash sync).
114
+ (function () {
115
+ const below = document.getElementById("below");
116
+ const kernel = document.getElementById("kernel");
117
+ const toolbar = document.querySelector(".toolbar");
118
+ const navKernel = document.getElementById("navKernel");
119
+ const navPosts = document.getElementById("navPosts");
120
+ const navProfile = document.getElementById("navProfile");
121
+ const profile = document.getElementById("profile");
122
+ const posts = document.getElementById("posts");
123
+ if (!below || !kernel) return;
124
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
125
+ // update only the fragment, never clobbering the path or query (?data=, ?city= must survive a scroll)
126
+ const setHash = (id: string) => history.replaceState(null, "", location.pathname + location.search + "#" + id);
127
+
128
+ // Land at the top of .below (toolbar + kernel) — skip the Profile, no flash.
129
+ const land = () => {
130
+ // presentation mode (a ?data= link with present:true) owns the viewport — no landing scroll
131
+ if (document.body.dataset.present) return;
132
+ // respect a section hash on load/refresh: #posts / #profile land there; otherwise (#kernel or
133
+ // none) land on the Kernel. behavior:instant overrides the CSS scroll-behavior:smooth.
134
+ const h = location.hash;
135
+ if (h === "#posts" && posts) posts.scrollIntoView({ behavior: "instant", block: "start" });
136
+ else if (h === "#profile" && profile) profile.scrollIntoView({ behavior: "instant", block: "start" });
137
+ else window.scrollTo({ top: below.getBoundingClientRect().top + window.scrollY, behavior: "instant" });
138
+ };
139
+ land();
140
+ window.addEventListener("load", land);
141
+ const reveal = () => document.body.classList.add("ready");
142
+ requestAnimationFrame(reveal);
143
+ setTimeout(reveal, 500);
144
+
145
+ // Toolbar hairline appears once content scrolls under it; the nav highlights whichever
146
+ // cut is currently at the top of the active region (the toolbar line) — Profile (the
147
+ // first cut when you scroll up), App or Posts.
148
+ const cuts = [[profile, navProfile], [kernel, navKernel], [posts, navPosts]].filter(([el, nav]) => el && nav) as [HTMLElement, HTMLElement][];
149
+ // scroll-reactive title strip: the cut at the toolbar line names itself. Profile stays collapsed
150
+ // (its big header is the title); Kernel → app name, Posts → @handle.
151
+ const tbTitle = document.getElementById("tbTitle");
152
+ const tbTitleText = document.getElementById("tbTitleText");
153
+ const pname = (document.querySelector(".pname")?.textContent || "").trim();
154
+ const handle = (document.querySelector(".handle")?.textContent || "").trim();
155
+ const titleFor = (c: [HTMLElement, HTMLElement]) => (c[0] === kernel ? pname : c[0] === posts ? handle : "");
156
+ let lastTitleCut: [HTMLElement, HTMLElement] | null = null, titleInit = true;
157
+ const onScroll = () => {
158
+ if (toolbar) toolbar.classList.toggle("stuck", window.scrollY >= below.offsetTop - 1);
159
+ const line = 84; // ≥ #kernel/#posts scroll-margin-top, so anchor clicks land at/above the line and are detected
160
+ let current = cuts[0];
161
+ for (const c of cuts) if (c[0].getBoundingClientRect().top <= line) current = c;
162
+ for (const c of cuts) c[1].classList.toggle("active", c === current);
163
+ if (current !== lastTitleCut) {
164
+ lastTitleCut = current;
165
+ // scroll-linked URL: reflect the current cut in the hash (replaceState = no jump, no history spam)
166
+ const cid = current[0].id;
167
+ if (cid && "#" + cid !== location.hash) setHash(cid);
168
+ const t = titleFor(current);
169
+ if (titleInit) {
170
+ // first paint: snap the strip into place — no "settling into the room" animation on load/refresh
171
+ titleInit = false;
172
+ if (tbTitle) { tbTitle.style.transition = "none"; tbTitle.classList.toggle("show", !!t); }
173
+ if (t && tbTitleText) tbTitleText.textContent = t;
174
+ if (tbTitle) { void tbTitle.offsetHeight; tbTitle.style.transition = ""; }
175
+ } else {
176
+ if (tbTitle) tbTitle.classList.toggle("show", !!t);
177
+ if (t && tbTitleText && tbTitleText.textContent !== t) {
178
+ tbTitleText.classList.add("fade");
179
+ setTimeout(() => { tbTitleText.textContent = t; tbTitleText.classList.remove("fade"); }, 150);
180
+ }
181
+ }
182
+ }
183
+ };
184
+ window.addEventListener("scroll", onScroll, { passive: true });
185
+ onScroll();
186
+ // nav clicks: scroll programmatically so the sticky toolbar always pins flush. The native anchor
187
+ // + scroll-margin left a gap above the bar when jumping to the Kernel from the Profile (the title
188
+ // strip is still collapsed there, so the bar is shorter than the margin). Kernel → land at .below
189
+ // top (bar pins at 0); Profile/Posts → scrollIntoView.
190
+ cuts.forEach(([el, nav]) => nav.addEventListener("click", (e) => {
191
+ e.preventDefault();
192
+ if (el === kernel) window.scrollTo({ top: below.getBoundingClientRect().top + window.scrollY, behavior: "smooth" });
193
+ else el.scrollIntoView({ behavior: "smooth", block: "start" });
194
+ setHash(el.id);
195
+ }));
196
+ })();
197
+ </script>