@justai/cuts 0.9.0 → 0.11.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 +3 -2
- package/src/Posts.astro +39 -7
- package/src/Profile.astro +91 -0
- package/src/Toolbar.astro +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"./LangPicker.astro": "./src/LangPicker.astro",
|
|
18
18
|
"./BackToZone.astro": "./src/BackToZone.astro",
|
|
19
19
|
"./Posts.astro": "./src/Posts.astro",
|
|
20
|
-
"./Toolbar.astro": "./src/Toolbar.astro"
|
|
20
|
+
"./Toolbar.astro": "./src/Toolbar.astro",
|
|
21
|
+
"./Profile.astro": "./src/Profile.astro"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
24
|
"@justai/ui": ">=0.2.0",
|
package/src/Posts.astro
CHANGED
|
@@ -13,19 +13,29 @@
|
|
|
13
13
|
//
|
|
14
14
|
// `avatarBg` is the only real per-persona knob (the post-avatar tile behind /icon.svg, matching the
|
|
15
15
|
// profile). Everything else is shared.
|
|
16
|
+
//
|
|
17
|
+
// THE SOCIAL LAYER is in here: a persona's feed carries replies from OTHER personas, and they must read
|
|
18
|
+
// as themselves — their own icon (or glyph), their own name and @handle, and a link to their own
|
|
19
|
+
// profile — not as the host persona. That rendering was invented on the two apexes (justai, app.zone)
|
|
20
|
+
// and lived only there; folding it UP into this cut is what lets every persona show a cross-reply
|
|
21
|
+
// correctly instead of mislabelling it as its own. The one asymmetry it needs: `handle` is the persona
|
|
22
|
+
// KEY (the feed query and the is-this-mine test) while `displayHandle` is what is printed after the @
|
|
23
|
+
// — they differ exactly on the apexes (app.zone → @appzone, justai → @justai.pro).
|
|
16
24
|
interface Props {
|
|
17
|
-
handle: string; // the persona
|
|
18
|
-
name: string; // the persona's display name
|
|
25
|
+
handle: string; // the persona KEY — the feed query (?persona=) and the "is this reply mine?" test
|
|
26
|
+
name: string; // the persona's display name; rendered RAW, so a name may carry markup (justai's ".pro")
|
|
19
27
|
footer: string; // the localized footer tagline after "justai.pro ·"
|
|
28
|
+
displayHandle?: string; // the rendered @handle when it differs from the key (app.zone → @appzone)
|
|
20
29
|
avatarBg?: string; // post-avatar tile background (default #fff)
|
|
21
30
|
}
|
|
22
|
-
const { handle, name, footer, avatarBg } = Astro.props;
|
|
31
|
+
const { handle, name, footer, displayHandle, avatarBg } = Astro.props;
|
|
23
32
|
---
|
|
24
33
|
|
|
25
34
|
<section
|
|
26
35
|
class="cut posts"
|
|
27
36
|
id="posts"
|
|
28
37
|
data-persona={handle}
|
|
38
|
+
data-handle={displayHandle ?? handle}
|
|
29
39
|
data-name={name}
|
|
30
40
|
style={avatarBg ? `--feed-av-bg:${avatarBg}` : undefined}
|
|
31
41
|
>
|
|
@@ -88,8 +98,13 @@ const { handle, name, footer, avatarBg } = Astro.props;
|
|
|
88
98
|
const section = document.getElementById("posts");
|
|
89
99
|
const feed = document.getElementById("feed");
|
|
90
100
|
if (!section || !feed) return;
|
|
91
|
-
// the persona identity travels on the section as data-attributes (the static cut set them from props)
|
|
101
|
+
// the persona identity travels on the section as data-attributes (the static cut set them from props).
|
|
102
|
+
// PERSONA is the KEY (feed query + the "is this reply mine?" test); HANDLE is what is rendered after
|
|
103
|
+
// the @ (they differ for the two apexes: app.zone → @appzone, justai → @justai.pro). NAME is the
|
|
104
|
+
// persona's OWN name and is injected RAW — it is authored in the persona's source, never user input,
|
|
105
|
+
// so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
|
|
92
106
|
const PERSONA = section.dataset.persona || "";
|
|
107
|
+
const HANDLE = section.dataset.handle || PERSONA;
|
|
93
108
|
const NAME = section.dataset.name || "";
|
|
94
109
|
if (!PERSONA) return;
|
|
95
110
|
// Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
|
|
@@ -110,7 +125,7 @@ const { handle, name, footer, avatarBg } = Astro.props;
|
|
|
110
125
|
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
126
|
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
127
|
|
|
113
|
-
type C = { body: string; createdAt: string | null; authorHandle: string; authorName: string; authorColor: string | null; isPersona: boolean };
|
|
128
|
+
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 };
|
|
114
129
|
type P = { id: string; body: string; createdAt: string | null; comments: C[] };
|
|
115
130
|
|
|
116
131
|
// Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
|
|
@@ -131,10 +146,27 @@ const { handle, name, footer, avatarBg } = Astro.props;
|
|
|
131
146
|
const mo = Math.floor(d / 30); if (mo < 12) return agoRtf.format(-mo, "month");
|
|
132
147
|
return agoRtf.format(-Math.floor(d / 365), "year");
|
|
133
148
|
};
|
|
149
|
+
// where a persona lives, so another persona's name in this feed can link to its profile. The two
|
|
150
|
+
// apexes are their own domains; every app persona is a subdomain of app.zone. The API may send the
|
|
151
|
+
// canonical personaUrl, in which case it wins.
|
|
152
|
+
const homeOf = (h: string): string =>
|
|
153
|
+
h === "justai" ? "https://justai.pro" : h === "app.zone" ? "https://app.zone" : `https://${h}.app.zone`;
|
|
134
154
|
const comment = (c: C, last: boolean): string => {
|
|
135
155
|
const ln = last ? "" : `<span class="ln"></span>`;
|
|
136
156
|
if (c.isPersona) {
|
|
137
|
-
|
|
157
|
+
// THE SOCIAL LAYER: personas reply on each other's feeds. A self-reply wears this persona's own
|
|
158
|
+
// face and name; ANOTHER persona wears ITS canonical icon (glyph fallback), its own name, its
|
|
159
|
+
// @handle, and its name links to its own profile. The verified check stays for both.
|
|
160
|
+
const own = c.authorHandle === PERSONA;
|
|
161
|
+
const av = own
|
|
162
|
+
? AV
|
|
163
|
+
: c.authorAvatar
|
|
164
|
+
? `<img class="mk" src="${esc(c.authorAvatar)}" alt="">`
|
|
165
|
+
: `<span style="font-size:16px">${esc(c.authorGlyph || "✦")}</span>`;
|
|
166
|
+
const nm = own ? NAME : esc(c.authorName); // own name is authored markup; an API name is escaped
|
|
167
|
+
const purl = own ? "#profile" : `${c.personaUrl || homeOf(c.authorHandle)}/#profile`;
|
|
168
|
+
const meta = own ? `· ${timeAgo(c.createdAt)}` : `@${esc(c.authorHandle)} · ${timeAgo(c.createdAt)}`;
|
|
169
|
+
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="${esc(purl)}"><b>${nm}</b></a>${ck}<span class="muted">${meta}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
|
|
138
170
|
}
|
|
139
171
|
const initial = esc(String(c.authorName || "?").slice(0, 1));
|
|
140
172
|
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>`;
|
|
@@ -147,7 +179,7 @@ const { handle, name, footer, avatarBg } = Astro.props;
|
|
|
147
179
|
const n = String(p.id || "").split(":").pop() || "";
|
|
148
180
|
const perma = n ? `/feed/${n}` : "";
|
|
149
181
|
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>${
|
|
182
|
+
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><span class="phandle">@${esc(HANDLE)}</span><span class="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
|
|
151
183
|
};
|
|
152
184
|
|
|
153
185
|
let done = false;
|
|
@@ -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>
|
package/src/Toolbar.astro
CHANGED
|
@@ -72,11 +72,13 @@ const { sectionsLabel, profileLabel, kernelLabel, postsLabel } = Astro.props;
|
|
|
72
72
|
.toolbar {
|
|
73
73
|
position: sticky; top: 0; z-index: 40;
|
|
74
74
|
display: flex; flex-direction: column;
|
|
75
|
-
/* full-bleed: the bar (frosted bg + hairline) touches the contained-view edges; the
|
|
76
|
-
|
|
77
|
-
|
|
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)));
|
|
78
80
|
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));
|
|
81
|
+
padding-inline: max(var(--edge, 16px), env(safe-area-inset-left)) max(var(--edge, 16px), env(safe-area-inset-right));
|
|
80
82
|
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
|
81
83
|
backdrop-filter: blur(22px) saturate(1.5);
|
|
82
84
|
-webkit-backdrop-filter: blur(22px) saturate(1.5);
|