@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.9.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'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
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
- 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>`;
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>${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>`;
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
- 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)));
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);