@justai/cuts 0.8.0 → 0.10.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 +4 -2
- package/src/Posts.astro +3 -1
- package/src/Profile.astro +91 -0
- package/src/Toolbar.astro +199 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"./Base.astro": "./src/Base.astro",
|
|
17
17
|
"./LangPicker.astro": "./src/LangPicker.astro",
|
|
18
18
|
"./BackToZone.astro": "./src/BackToZone.astro",
|
|
19
|
-
"./Posts.astro": "./src/Posts.astro"
|
|
19
|
+
"./Posts.astro": "./src/Posts.astro",
|
|
20
|
+
"./Toolbar.astro": "./src/Toolbar.astro",
|
|
21
|
+
"./Profile.astro": "./src/Profile.astro"
|
|
20
22
|
},
|
|
21
23
|
"peerDependencies": {
|
|
22
24
|
"@justai/ui": ">=0.2.0",
|
package/src/Posts.astro
CHANGED
|
@@ -37,7 +37,9 @@ const { handle, name, footer, avatarBg } = Astro.props;
|
|
|
37
37
|
<style>
|
|
38
38
|
/* ---- POSTS cut — a conversation: ONE line descends from the post's avatar and
|
|
39
39
|
connects every comment & reply (they're all replies to the post). ---- */
|
|
40
|
-
|
|
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; }
|
|
41
43
|
.post { padding-block: 18px; cursor: pointer; transition: background 0.15s var(--ease-out); }
|
|
42
44
|
.post:hover { background: color-mix(in srgb, var(--text) 3.5%, transparent); }
|
|
43
45
|
.post + .post { border-block-start: 1px solid var(--border); }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
// ░░ PROFILE cut — the persona's face: a cover, an avatar, its name · @handle · bio · meta. ░░
|
|
3
|
+
//
|
|
4
|
+
// Structurally identical in every persona; only the CONTENT and a handful of colours differ. It lives
|
|
5
|
+
// here now. A persona writes:
|
|
6
|
+
//
|
|
7
|
+
// <Profile name={t.title} handle="@qr" bio={t.bio} meta={t.meta}
|
|
8
|
+
// avatarLabel="QR code linking to qr.app.zone"
|
|
9
|
+
// cover="linear-gradient(135deg, …)" avatarBg="#fff" avatarPad="7px">
|
|
10
|
+
// <Fragment slot="avatar" set:html={avatarSvg} />
|
|
11
|
+
// </Profile>
|
|
12
|
+
//
|
|
13
|
+
// PROPS carry resolved strings (same convention as Posts/Toolbar/LangPicker — never the whole `t`).
|
|
14
|
+
// The per-persona COLOURS ride in as CSS custom properties (set inline from props), so the shared
|
|
15
|
+
// scoped CSS can theme itself without a variant per persona:
|
|
16
|
+
// --cover the cover background (any CSS <image>: a linear- or radial-gradient)
|
|
17
|
+
// --cover-h cover height (default 132px; the clock personas use 118px)
|
|
18
|
+
// --avatar-bg the avatar tile background (a colour OR a gradient)
|
|
19
|
+
// --avatar-bg-light optional light-scheme override for the tile (whatismyip inverts its icon ground)
|
|
20
|
+
// --avatar-pad padding inside the tile (a QR needs a quiet zone; an icon that bleeds needs 0)
|
|
21
|
+
// --edge inherited from the persona's page padding (16px default; time pads at 20px) so the
|
|
22
|
+
// full-bleed cover reaches the SAME edge as the body — see Toolbar for the same knob.
|
|
23
|
+
//
|
|
24
|
+
// The AVATAR content is a slot (an <img>, or a build-time SVG via set:html) because it is the most
|
|
25
|
+
// persona-specific thing on the page; the tile that frames it is shared. `avatarLabel` gives the tile
|
|
26
|
+
// its accessible name (role="img"); omit it and the tile is inert.
|
|
27
|
+
//
|
|
28
|
+
// CONTRACT: the shell's scroll-spy reads `.pname` and `.handle` from here to name the title strip, and
|
|
29
|
+
// the section id `#profile` is one of the three cut anchors. Nothing else depends on the profile.
|
|
30
|
+
interface Props {
|
|
31
|
+
name: string; // the persona name (the <h1>)
|
|
32
|
+
handle: string; // the @handle line, e.g. "@qr"
|
|
33
|
+
bio: string; // the one-line bio
|
|
34
|
+
meta?: string; // the small meta line under the bio (optional)
|
|
35
|
+
avatarLabel?: string; // accessible name for the avatar tile (role="img"); omit → inert tile
|
|
36
|
+
cover?: string; // CSS background for .cover
|
|
37
|
+
coverHeight?: string; // e.g. "118px"
|
|
38
|
+
avatarBg?: string; // CSS background for the avatar tile
|
|
39
|
+
avatarBgLight?: string;// light-scheme override for the avatar tile
|
|
40
|
+
avatarPad?: string; // padding inside the avatar tile, e.g. "7px"
|
|
41
|
+
}
|
|
42
|
+
const { name, handle, bio, meta, avatarLabel, cover, coverHeight, avatarBg, avatarBgLight, avatarPad } = Astro.props;
|
|
43
|
+
const vars = [
|
|
44
|
+
cover && `--cover:${cover}`,
|
|
45
|
+
coverHeight && `--cover-h:${coverHeight}`,
|
|
46
|
+
avatarBg && `--avatar-bg:${avatarBg}`,
|
|
47
|
+
avatarBgLight && `--avatar-bg-light:${avatarBgLight}`,
|
|
48
|
+
avatarPad && `--avatar-pad:${avatarPad}`,
|
|
49
|
+
].filter(Boolean).join(";");
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<section class="cut profile" id="profile" style={vars || undefined}>
|
|
53
|
+
<div class="cover"></div>
|
|
54
|
+
<div class="pbody">
|
|
55
|
+
<div class="avatar" role={avatarLabel ? "img" : undefined} aria-label={avatarLabel}><slot name="avatar" /></div>
|
|
56
|
+
<h1 class="pname">{name}</h1>
|
|
57
|
+
<p class="handle">{handle}</p>
|
|
58
|
+
<p class="bio">{bio}</p>
|
|
59
|
+
{meta && <p class="meta">{meta}</p>}
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
/* ---- PROFILE cut ---- */
|
|
65
|
+
.profile { padding-block-end: 8px; }
|
|
66
|
+
.cover {
|
|
67
|
+
height: var(--cover-h, 132px);
|
|
68
|
+
/* full-bleed to the persona's page edge (see --edge on Toolbar) */
|
|
69
|
+
margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
|
|
70
|
+
background: var(--cover, linear-gradient(135deg, var(--accent), transparent));
|
|
71
|
+
position: relative;
|
|
72
|
+
}
|
|
73
|
+
@media (min-width: 768px) { .cover { margin-inline: -28px; } }
|
|
74
|
+
.avatar {
|
|
75
|
+
width: 76px; height: 76px; border-radius: var(--radius-lg); margin-block-start: -40px;
|
|
76
|
+
display: grid; place-items: center; padding: var(--avatar-pad, 0);
|
|
77
|
+
background: var(--avatar-bg, #fff);
|
|
78
|
+
border: 3px solid var(--bg); box-shadow: 0 8px 22px -16px rgba(0, 0, 0, 0.22);
|
|
79
|
+
position: relative;
|
|
80
|
+
}
|
|
81
|
+
/* the avatar content is slotted (persona-owned) → style it globally */
|
|
82
|
+
.avatar :global(svg) { width: 100%; height: 100%; display: block; }
|
|
83
|
+
.avatar :global(img) { width: 100%; height: 100%; display: block; object-fit: cover; }
|
|
84
|
+
@media (prefers-color-scheme: light) {
|
|
85
|
+
.avatar { border-color: #fff; background: var(--avatar-bg-light, var(--avatar-bg, #fff)); }
|
|
86
|
+
}
|
|
87
|
+
.pname { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -0.02em; margin-block-start: 12px; }
|
|
88
|
+
.handle { color: var(--text-dim); font-size: var(--fs-xs); margin-block-start: 2px; }
|
|
89
|
+
.bio { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 12px; }
|
|
90
|
+
.meta { color: var(--text-dim); font-size: var(--fs-2xs); margin-block-start: 10px; }
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,199 @@
|
|
|
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 icons stay
|
|
76
|
+
aligned with the body via matching inline padding. --edge is the persona's page inline padding
|
|
77
|
+
(16px default; a persona that pads its body differently — e.g. time at 20px — sets --edge so the
|
|
78
|
+
bar bleeds to the SAME edge as its content instead of a hardcoded 16px). */
|
|
79
|
+
margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
|
|
80
|
+
padding-block: max(8px, env(safe-area-inset-top)) 8px;
|
|
81
|
+
padding-inline: max(var(--edge, 16px), env(safe-area-inset-left)) max(var(--edge, 16px), env(safe-area-inset-right));
|
|
82
|
+
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
|
83
|
+
backdrop-filter: blur(22px) saturate(1.5);
|
|
84
|
+
-webkit-backdrop-filter: blur(22px) saturate(1.5);
|
|
85
|
+
/* a very faint hairline always; it firms up once content scrolls under the bar */
|
|
86
|
+
border-block-end: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
|
87
|
+
transition: border-color 0.3s var(--ease-out);
|
|
88
|
+
}
|
|
89
|
+
.toolbar.stuck { border-block-end-color: var(--border); }
|
|
90
|
+
@media (min-width: 768px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
|
|
91
|
+
/* icons row + the scroll-reactive title strip below it */
|
|
92
|
+
.tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
93
|
+
.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); }
|
|
94
|
+
.tb-title.show { height: 26px; opacity: 1; }
|
|
95
|
+
.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); }
|
|
96
|
+
.tb-title-text.fade { opacity: 0; }
|
|
97
|
+
/* nav lives on the left, beside the back affordance — the middle is intentionally empty */
|
|
98
|
+
.tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
99
|
+
.tb-nav-group { display: flex; align-items: center; gap: 2px; }
|
|
100
|
+
.tb-right { display: flex; align-items: center; min-width: 0; }
|
|
101
|
+
/* icon-only section nav — current cut highlighted */
|
|
102
|
+
.tb-nav {
|
|
103
|
+
display: grid; place-items: center; width: 38px; height: 36px;
|
|
104
|
+
color: var(--text-dim);
|
|
105
|
+
transition: color 0.2s, opacity 0.2s, transform 0.2s var(--ease-spring);
|
|
106
|
+
}
|
|
107
|
+
.tb-nav:hover { color: var(--text); }
|
|
108
|
+
.tb-nav:active { transform: scale(0.9); }
|
|
109
|
+
.tb-nav.active { color: var(--accent); }
|
|
110
|
+
.tb-nav :global(svg) { width: 19px; height: 19px; }
|
|
111
|
+
</style>
|
|
112
|
+
|
|
113
|
+
<script>
|
|
114
|
+
// Land on the Kernel cut; Profile is above (scroll up), Posts below (scroll down). This is also the
|
|
115
|
+
// page's reveal (body.ready) and its scroll-spy (nav highlight + title strip + hash sync).
|
|
116
|
+
(function () {
|
|
117
|
+
const below = document.getElementById("below");
|
|
118
|
+
const kernel = document.getElementById("kernel");
|
|
119
|
+
const toolbar = document.querySelector(".toolbar");
|
|
120
|
+
const navKernel = document.getElementById("navKernel");
|
|
121
|
+
const navPosts = document.getElementById("navPosts");
|
|
122
|
+
const navProfile = document.getElementById("navProfile");
|
|
123
|
+
const profile = document.getElementById("profile");
|
|
124
|
+
const posts = document.getElementById("posts");
|
|
125
|
+
if (!below || !kernel) return;
|
|
126
|
+
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
|
127
|
+
// update only the fragment, never clobbering the path or query (?data=, ?city= must survive a scroll)
|
|
128
|
+
const setHash = (id: string) => history.replaceState(null, "", location.pathname + location.search + "#" + id);
|
|
129
|
+
|
|
130
|
+
// Land at the top of .below (toolbar + kernel) — skip the Profile, no flash.
|
|
131
|
+
const land = () => {
|
|
132
|
+
// presentation mode (a ?data= link with present:true) owns the viewport — no landing scroll
|
|
133
|
+
if (document.body.dataset.present) return;
|
|
134
|
+
// respect a section hash on load/refresh: #posts / #profile land there; otherwise (#kernel or
|
|
135
|
+
// none) land on the Kernel. behavior:instant overrides the CSS scroll-behavior:smooth.
|
|
136
|
+
const h = location.hash;
|
|
137
|
+
if (h === "#posts" && posts) posts.scrollIntoView({ behavior: "instant", block: "start" });
|
|
138
|
+
else if (h === "#profile" && profile) profile.scrollIntoView({ behavior: "instant", block: "start" });
|
|
139
|
+
else window.scrollTo({ top: below.getBoundingClientRect().top + window.scrollY, behavior: "instant" });
|
|
140
|
+
};
|
|
141
|
+
land();
|
|
142
|
+
window.addEventListener("load", land);
|
|
143
|
+
const reveal = () => document.body.classList.add("ready");
|
|
144
|
+
requestAnimationFrame(reveal);
|
|
145
|
+
setTimeout(reveal, 500);
|
|
146
|
+
|
|
147
|
+
// Toolbar hairline appears once content scrolls under it; the nav highlights whichever
|
|
148
|
+
// cut is currently at the top of the active region (the toolbar line) — Profile (the
|
|
149
|
+
// first cut when you scroll up), App or Posts.
|
|
150
|
+
const cuts = [[profile, navProfile], [kernel, navKernel], [posts, navPosts]].filter(([el, nav]) => el && nav) as [HTMLElement, HTMLElement][];
|
|
151
|
+
// scroll-reactive title strip: the cut at the toolbar line names itself. Profile stays collapsed
|
|
152
|
+
// (its big header is the title); Kernel → app name, Posts → @handle.
|
|
153
|
+
const tbTitle = document.getElementById("tbTitle");
|
|
154
|
+
const tbTitleText = document.getElementById("tbTitleText");
|
|
155
|
+
const pname = (document.querySelector(".pname")?.textContent || "").trim();
|
|
156
|
+
const handle = (document.querySelector(".handle")?.textContent || "").trim();
|
|
157
|
+
const titleFor = (c: [HTMLElement, HTMLElement]) => (c[0] === kernel ? pname : c[0] === posts ? handle : "");
|
|
158
|
+
let lastTitleCut: [HTMLElement, HTMLElement] | null = null, titleInit = true;
|
|
159
|
+
const onScroll = () => {
|
|
160
|
+
if (toolbar) toolbar.classList.toggle("stuck", window.scrollY >= below.offsetTop - 1);
|
|
161
|
+
const line = 84; // ≥ #kernel/#posts scroll-margin-top, so anchor clicks land at/above the line and are detected
|
|
162
|
+
let current = cuts[0];
|
|
163
|
+
for (const c of cuts) if (c[0].getBoundingClientRect().top <= line) current = c;
|
|
164
|
+
for (const c of cuts) c[1].classList.toggle("active", c === current);
|
|
165
|
+
if (current !== lastTitleCut) {
|
|
166
|
+
lastTitleCut = current;
|
|
167
|
+
// scroll-linked URL: reflect the current cut in the hash (replaceState = no jump, no history spam)
|
|
168
|
+
const cid = current[0].id;
|
|
169
|
+
if (cid && "#" + cid !== location.hash) setHash(cid);
|
|
170
|
+
const t = titleFor(current);
|
|
171
|
+
if (titleInit) {
|
|
172
|
+
// first paint: snap the strip into place — no "settling into the room" animation on load/refresh
|
|
173
|
+
titleInit = false;
|
|
174
|
+
if (tbTitle) { tbTitle.style.transition = "none"; tbTitle.classList.toggle("show", !!t); }
|
|
175
|
+
if (t && tbTitleText) tbTitleText.textContent = t;
|
|
176
|
+
if (tbTitle) { void tbTitle.offsetHeight; tbTitle.style.transition = ""; }
|
|
177
|
+
} else {
|
|
178
|
+
if (tbTitle) tbTitle.classList.toggle("show", !!t);
|
|
179
|
+
if (t && tbTitleText && tbTitleText.textContent !== t) {
|
|
180
|
+
tbTitleText.classList.add("fade");
|
|
181
|
+
setTimeout(() => { tbTitleText.textContent = t; tbTitleText.classList.remove("fade"); }, 150);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
187
|
+
onScroll();
|
|
188
|
+
// nav clicks: scroll programmatically so the sticky toolbar always pins flush. The native anchor
|
|
189
|
+
// + scroll-margin left a gap above the bar when jumping to the Kernel from the Profile (the title
|
|
190
|
+
// strip is still collapsed there, so the bar is shorter than the margin). Kernel → land at .below
|
|
191
|
+
// top (bar pins at 0); Profile/Posts → scrollIntoView.
|
|
192
|
+
cuts.forEach(([el, nav]) => nav.addEventListener("click", (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
if (el === kernel) window.scrollTo({ top: below.getBoundingClientRect().top + window.scrollY, behavior: "smooth" });
|
|
195
|
+
else el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
196
|
+
setHash(el.id);
|
|
197
|
+
}));
|
|
198
|
+
})();
|
|
199
|
+
</script>
|