@justai/cuts 0.47.0 → 0.49.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/Posts.astro +66 -15
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.47.0",
4
- "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.49.0",
4
+ "description": "A persona's named parts the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
package/src/Posts.astro CHANGED
@@ -16,21 +16,44 @@
16
16
  // stay dark. Everything else is shared.
17
17
  //
18
18
  // THE SOCIAL LAYER is in here: a persona's feed carries replies from OTHER personas, and they must read
19
- // as themselves — their own icon (or glyph), their own name and @handle, and a link to their own
20
- // profile — not as the host persona. That rendering was invented on the two apexes (justai, app.zone)
21
- // and lived only there; folding it UP into this cut is what lets every persona show a cross-reply
22
- // correctly instead of mislabelling it as its own. The one asymmetry it needs: `handle` is the persona
23
- // KEY (the feed query and the is-this-mine test) while `displayHandle` is what is printed after the @
24
- // they differ exactly on the apexes (app.zone @appzone, justai @justai.pro).
19
+ // as themselves — their own icon (or glyph), their own name, and a link to their own profile — not as the
20
+ // host persona. That rendering was invented on the two apexes (justai, app.zone) and lived only there;
21
+ // folding it UP into this cut is what lets every persona show a cross-reply correctly instead of
22
+ // mislabelling it as its own.
23
+ //
24
+ // A PERSONA IS NAMED ONCE. Personas used to carry a name and an @handle, and for a persona those are the
25
+ // same word: app.zone beside @appzone, AMRAP beside @amrap, Tabata beside @tabata. Six names down a thread
26
+ // it read as stutter rather than as identity, and it cost a line of width on a narrow column for nothing.
27
+ // The self-reply had already been rendering without one, so the whole family now reads the way the host
28
+ // always did. A HUMAN keeps theirs (see the last line of comment()): a person's display name and their
29
+ // account are genuinely different facts that happen to coincide here.
25
30
  interface Props {
26
31
  handle: string; // the persona KEY — the feed query (?persona=) and the "is this reply mine?" test
27
32
  name: string; // the persona's display name; rendered RAW, so a name may carry markup (justai's ".pro")
28
33
  footer: string; // the localized footer tagline after "justai.pro ·"
29
- displayHandle?: string; // the rendered @handle when it differs from the key (app.zone → @appzone)
30
34
  avatarBg?: string; // post-avatar tile background (default #fff)
31
35
  avatarBgLight?: string; // light-scheme override for that tile (a dark gradient must invert, not stay dark)
36
+ // Which file is THIS persona's face in the feed. Defaults to /icon.svg, which every persona serves at its
37
+ // root, and which is a tile-with-a-mark-inside — right for an app, and what the tile knobs above are for.
38
+ avatarSrc?: string;
39
+ // A mark with no tile of its own, shown on the feed's ground rather than on a tile. app.zone is the case
40
+ // this exists for: the platform mark is a different kind of object from an app icon (the house rule is in
41
+ // app-zone/.justai/expert/how-an-app-zone-icon-is-lit.md — the platform mark is inset, app marks are
42
+ // raised), so wrapping it in a tile states a sibling relationship that is not true.
43
+ //
44
+ // Measured, because it decides the size too: /icon.svg puts its coloured ink across 72% of a 42px box,
45
+ // since the mark sits inset within its own tile. A bare mark spans 98% of the same box. So dropping the
46
+ // tile does not shrink the mark — it renders it about a third larger, which is why nothing here scales it.
47
+ avatarBare?: boolean;
48
+ // The dark-scheme twin of avatarSrc, for a bare mark whose centre has to invert.
49
+ //
50
+ // Two files rather than one file with `@media (prefers-color-scheme)` inside it, and that is a measurement
51
+ // rather than a preference: an SVG referenced by <img> renders in its own document, and the embedding page's
52
+ // scheme did NOT reach it — the centre stayed #2B2724 on a dark feed, where it is all but invisible against
53
+ // the ground. The page's own media query is reliable, so the page picks the file and the file stays dumb.
54
+ avatarSrcDark?: string;
32
55
  }
33
- const { handle, name, footer, displayHandle, avatarBg, avatarBgLight } = Astro.props;
56
+ const { handle, name, footer, avatarBg, avatarBgLight, avatarSrc, avatarBare, avatarSrcDark } = Astro.props;
34
57
  const vars = [
35
58
  avatarBg && `--feed-av-bg:${avatarBg}`,
36
59
  avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
@@ -41,8 +64,10 @@ const vars = [
41
64
  class="cut posts"
42
65
  id="posts"
43
66
  data-persona={handle}
44
- data-handle={displayHandle ?? handle}
45
67
  data-name={name}
68
+ data-av={avatarSrc ?? "/icon.svg"}
69
+ data-av-dark={avatarSrcDark}
70
+ data-av-bare={avatarBare ? "" : undefined}
46
71
  style={vars || undefined}
47
72
  >
48
73
  <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
@@ -81,9 +106,25 @@ const vars = [
81
106
  pass avatarBgLight; the rest fall back to their single background. */
82
107
  @media (prefers-color-scheme: light) { .pa, .rav { background: var(--feed-av-bg-light, var(--feed-av-bg, #fff)); } }
83
108
 
109
+ /* A bare mark stands on the feed's own ground, so the tile and its hairline have to go — and they have to
110
+ go from the CONTAINER, which is why this reads upward from the image with :has(). Scoping it to the image
111
+ is what keeps a sibling persona's tiled icon tiled: .rav wraps their reply as well as a self-reply.
112
+ `contain` rather than `cover`, because a mark with transparent margins must not be cropped to fill. */
113
+ .pa:has(.mk.bare), .rav:has(.mk.bare) { background: none; border-color: transparent; }
114
+ .pa .mk.bare, .rav .mk.bare { object-fit: contain; }
115
+ /* The scheme pair: the page decides, because the SVG's own media query does not survive <img>.
116
+ Written as `.pa .mk.av-*` rather than `.av-*` deliberately — `.pa .mk { display: block }` above is two
117
+ classes deep, so a single-class `.av-light { display: none }` loses on specificity and the toggle silently
118
+ does nothing. Measured: it showed the pale-ground file in both schemes until these selectors matched. */
119
+ .pa .mk.av-dark, .rav .mk.av-dark { display: none; }
120
+ @media (prefers-color-scheme: dark) {
121
+ .pa .mk.av-light, .rav .mk.av-light { display: none; }
122
+ .pa .mk.av-dark, .rav .mk.av-dark { display: block; }
123
+ }
124
+
84
125
  .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
85
126
  .ph b { font-weight: 600; }
86
- .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
127
+ .dot, .pt { color: var(--text-dim); font-weight: 400; }
87
128
  /* a persona's name in the feed links to its profile (X-style); humans have no link */
88
129
  .pn { color: inherit; text-decoration: none; }
89
130
  .pn:hover b { color: var(--accent); }
@@ -126,7 +167,6 @@ const vars = [
126
167
  // persona's OWN name and is injected RAW — it is authored in the persona's source, never user input,
127
168
  // so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
128
169
  const PERSONA = section.dataset.persona || "";
129
- const HANDLE = section.dataset.handle || PERSONA;
130
170
  const NAME = section.dataset.name || "";
131
171
  if (!PERSONA) return;
132
172
  // Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
@@ -142,8 +182,19 @@ const vars = [
142
182
 
143
183
  const esc = (s: unknown): string =>
144
184
  String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
145
- // every persona signs its posts with its own /icon.svg (each app serves one at its root)
146
- const AV = `<img class="mk" src="/icon.svg" alt="">`;
185
+ // Every persona signs its posts with its own face, /icon.svg by default (each app serves one at its
186
+ // root). The path is a data attribute rather than a literal because a persona may sign with a mark that
187
+ // has no tile — see avatarSrc / avatarBare above. `bare` rides on the IMAGE, not on the container:
188
+ // .rav wraps both a self-reply and ANOTHER persona's reply, so a container rule would strip the tile
189
+ // from siblings who legitimately have one.
190
+ const bare = section.hasAttribute("data-av-bare") ? " bare" : "";
191
+ const avLight = esc(section.dataset.av || "/icon.svg");
192
+ // When a dark twin is declared, BOTH are in the markup and CSS shows one. The alternative — one <img>
193
+ // whose src is chosen in JS off matchMedia — would be a second source of truth for the same decision and
194
+ // would not follow a scheme change without a listener.
195
+ const AV = section.dataset.avDark
196
+ ? `<img class="mk${bare} av-light" src="${avLight}" alt=""><img class="mk${bare} av-dark" src="${esc(section.dataset.avDark)}" alt="">`
197
+ : `<img class="mk${bare}" src="${avLight}" alt="">`;
147
198
  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>`;
148
199
  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>`;
149
200
 
@@ -187,7 +238,7 @@ const vars = [
187
238
  : `<span style="font-size:16px">${esc(c.authorGlyph || "✦")}</span>`;
188
239
  const nm = own ? NAME : esc(c.authorName); // own name is authored markup; an API name is escaped
189
240
  const purl = own ? "#profile" : `${c.personaUrl || homeOf(c.authorHandle)}/#profile`;
190
- const meta = own ? `· ${timeAgo(c.createdAt)}` : `@${esc(c.authorHandle)} · ${timeAgo(c.createdAt)}`;
241
+ const meta = `· ${timeAgo(c.createdAt)}`; // a persona's name IS its handle — see the header note
191
242
  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>`;
192
243
  }
193
244
  const initial = esc(String(c.authorName || "?").slice(0, 1));
@@ -201,7 +252,7 @@ const vars = [
201
252
  const n = String(p.id || "").split(":").pop() || "";
202
253
  const perma = n ? `/feed/${n}` : "";
203
254
  const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
204
- 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>`;
255
+ 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="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
205
256
  };
206
257
 
207
258
  let done = false;