@justai/cuts 0.54.0 → 0.56.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.54.0",
3
+ "version": "0.56.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": {
package/src/Posts.astro CHANGED
@@ -21,20 +21,32 @@
21
21
  // folding it UP into this cut is what lets every persona show a cross-reply correctly instead of
22
22
  // mislabelling it as its own.
23
23
  //
24
- // EVERYONE IS NAMED ONCE — no @handle anywhere in this feed.
24
+ // queries: why does every row show @handle · the handle badge came back · verified check on the post
25
+ // author · two personas with the same display name · app.zone @app.zone
25
26
  //
26
- // It started with the personas, where the name and the handle are the same word by construction: app.zone
27
- // beside @appzone, AMRAP beside @amrap, Tabata beside @tabata. One reads as identity; six down a thread read
28
- // as stutter, and on a 480px column each one spent width saying nothing new. The self-reply had always
29
- // rendered as `name · time`, so dropping the rest just made the family read the way the host already did.
27
+ // EVERY PRINCIPAL IS NAMED AND ADDRESSED — the name it chose, then the handle that IS it.
30
28
  //
31
- // Humans were kept for one release on the argument that a person's display name and their account are two
32
- // different facts. Overruled, and the call is better than the argument was: what a reader needs from a line
33
- // in a conversation is who said it, and the name is that. The @handle was a convention borrowed from a
34
- // product where handles are how you address someone — nobody is addressed here.
29
+ // This feed shipped the other way for one release, and the argument was real: on a 480px column six
30
+ // `@amrap`s down a thread spend width saying what `AMRAP` already said, since most personas' handles are
31
+ // just their name lowercased. What that argument missed is that the redundancy is a property of TODAY'S
32
+ // roster, not of the model. Display names are chosen for how they read, so they collide — two principals
33
+ // named Time, two people named Emre — and the day they do, a surface whose whole job is attribution can
34
+ // no longer say who spoke. Foundations doctrine 16 is the full reasoning.
35
35
  //
36
- // `handle` (the prop) and `authorHandle` (the API field) both stay: they are keys, not labels. The feed
37
- // query, the is-this-mine test, and the link to another persona's profile all read them. Nothing prints them.
36
+ // So the badge is rendered wherever a principal speaks, and there are NO exceptions — including the
37
+ // apexes, whose display name IS their handle. A "hide it when it repeats the name" rule was written and
38
+ // thrown away: it buys one avoided word at the price of an exception, and an exception makes the ABSENCE
39
+ // of a badge stop meaning anything. A reader who has seen it omitted once can no longer read a bare name
40
+ // as "this one has no handle".
41
+ //
42
+ // It rests on something enforced rather than on taste: `principal_handle` is UNIQUE over `:Principal` in
43
+ // the graph, so humans and personas share ONE namespace. That is what lets `@handle` be shown bare, with
44
+ // no type qualifier — there is only ever one answer to who it names.
45
+ //
46
+ // THE VERIFIED CHECK follows the same rule for the same reason. It marks a registered persona, and a
47
+ // persona is no less registered in its own post than in its own reply — it was on the comment rows and
48
+ // missing from the post row, so the host persona appeared unverified above its own verified self-reply.
49
+ // It stays OFF human comments: there the check would be a claim nobody made.
38
50
  interface Props {
39
51
  handle: string; // the persona KEY — the feed query (?persona=) and the "is this reply mine?" test
40
52
  name: string; // the persona's display name; rendered RAW, so a name may carry markup (justai's ".pro")
@@ -233,7 +245,7 @@ const vars = [
233
245
 
234
246
  .ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
235
247
  .ph b { font-weight: 600; }
236
- .dot, .pt { color: var(--text-dim); font-weight: 400; }
248
+ .phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
237
249
  /* a persona's name in the feed links to its profile (X-style); humans have no link */
238
250
  .pn { color: inherit; text-decoration: none; }
239
251
  .pn:hover b { color: var(--accent); }
@@ -347,11 +359,15 @@ const vars = [
347
359
  : `<span style="font-size:16px">${esc(c.authorGlyph || "✦")}</span>`;
348
360
  const nm = own ? NAME : esc(c.authorName); // own name is authored markup; an API name is escaped
349
361
  const purl = own ? "#profile" : `${c.personaUrl || homeOf(c.authorHandle)}/#profile`;
350
- const meta = `· ${timeAgo(c.createdAt)}`; // a persona's name IS its handle — see the header note
362
+ const at = own ? PERSONA : c.authorHandle;
363
+ const meta = `${at ? `@${esc(at)} ` : ""}· ${timeAgo(c.createdAt)}`;
351
364
  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>`;
352
365
  }
353
366
  const initial = esc(String(c.authorName || "?").slice(0, 1));
354
- 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">· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
367
+ // A person needs the handle MORE than a persona does — display names collide constantly among
368
+ // people. Guarded on the value: a principal with none shows a gap, never a bare "@".
369
+ const uat = c.authorHandle ? `@${esc(c.authorHandle)} ` : "";
370
+ 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">${uat}· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
355
371
  };
356
372
  const post = (p: P): string => {
357
373
  const cs = p.comments || [];
@@ -361,7 +377,7 @@ const vars = [
361
377
  const n = String(p.id || "").split(":").pop() || "";
362
378
  const perma = n ? `/feed/${n}` : "";
363
379
  const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
364
- 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>`;
380
+ 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>${ck}${PERSONA ? `<span class="phandle">@${esc(PERSONA)}</span>` : ""}<span class="dot">·</span>${pt}</div><p class="pbd">${esc(p.body)}</p>${pact}</div></div>${inner}</article>`;
365
381
  };
366
382
 
367
383
  let done = false;
package/src/Profile.astro CHANGED
@@ -22,8 +22,12 @@
22
22
  // full-bleed cover reaches the SAME edge as the body — see Toolbar for the same knob.
23
23
  //
24
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.
25
+ // persona-specific thing on the page; the tile that frames it is shared. The tile is a LINK to `#kernel`
26
+ // — see `kernelLabel` below for why it carries two labels.
27
+ //
28
+ // EVERY persona and child persona has a Profile and a Kernel (foundations 02), which is what lets that
29
+ // href stay local with no per-consumer escape hatch. `files`, `me` and `feed` each hand-rolled this
30
+ // entire section before; they are child personas, and a child persona is a persona.
27
31
  //
28
32
  // CONTRACT: the shell's scroll-spy reads `.pname` and `.handle` from here to name the title strip, and
29
33
  // the section id `#profile` is one of the three cut anchors. Nothing else depends on the profile.
@@ -32,17 +36,41 @@ interface Props {
32
36
  // input, so a name may carry markup (justai's "justai<span class='pro'>.pro</span>"). Same rule as the
33
37
  // Posts cut: a persona's OWN name is authored; anything from the API is escaped.
34
38
  name: string;
35
- handle: string; // the @handle line, e.g. "@qr"
39
+ // The other three follow the SAME rule as `name`, and for a reason that only shows up on the child
40
+ // personas: `files`, `me` and `feed` are mounted on a HOST persona and adopt its identity at runtime,
41
+ // so each of these lines carries an id (`#pnHandle`, `#pnMeta`) or a translation hook (`data-i`) that
42
+ // a script rewrites once it knows whose page it is standing on. Text-only props forced all three to
43
+ // hand-roll the whole section — which is exactly how a shared layer becomes twelve copies.
44
+ //
45
+ // Authored, never user input: these strings come from the persona's own source. Anything arriving from
46
+ // the API is escaped instead — the Posts cut keeps the same split.
47
+ handle: string; // the @handle line, e.g. "@qr" or "@app.zone · files"
36
48
  bio: string; // the one-line bio
37
49
  meta?: string; // the small meta line under the bio (optional)
38
- avatarLabel?: string; // accessible name for the avatar tile (role="img"); omit → inert tile
50
+ avatarLabel?: string; // what the avatar DEPICTS (qr's tile is a scannable QR); omit → the tile is inert
51
+ // queries: clicking the profile avatar · the big icon goes to the kernel · what does the avatar link to
52
+ //
53
+ // The tile is the face, and a face is the natural thing to press to meet what the persona actually
54
+ // DOES — so it carries you down to the kernel, the same destination as the toolbar's own glyph. One
55
+ // vocabulary: pass the SAME string you already give `<Toolbar kernelLabel=…>` and the two agree.
56
+ //
57
+ // Optional, and English when omitted, because the cut brings its own ground — a consumer that adopts a
58
+ // newer cuts and changes nothing must still get a working, labelled link, never a blank one.
59
+ //
60
+ // Two labels rather than one: `avatarLabel` says what the picture IS, this says where pressing it GOES.
61
+ // A link announced as "QR code linking to qr.app.zone" that scrolls to the kernel would be a lie to
62
+ // anyone who cannot see where they landed.
63
+ kernelLabel?: string;
39
64
  cover?: string; // CSS background for .cover
40
65
  coverHeight?: string; // e.g. "118px"
41
66
  avatarBg?: string; // CSS background for the avatar tile
42
67
  avatarBgLight?: string;// light-scheme override for the avatar tile
43
68
  avatarPad?: string; // padding inside the avatar tile, e.g. "7px"
44
69
  }
45
- const { name, handle, bio, meta, avatarLabel, cover, coverHeight, avatarBg, avatarBgLight, avatarPad } = Astro.props;
70
+ const { name, handle, bio, meta, avatarLabel, kernelLabel, cover, coverHeight, avatarBg, avatarBgLight, avatarPad } = Astro.props;
71
+ // What the picture is, then where it takes you — in that order, because a screen reader reads the link's
72
+ // name before its role and the destination is the part that decides whether to press it.
73
+ const avatarA11y = [avatarLabel, kernelLabel ?? "What I do"].filter(Boolean).join(" — ");
46
74
  const vars = [
47
75
  cover && `--cover:${cover}`,
48
76
  coverHeight && `--cover-h:${coverHeight}`,
@@ -55,14 +83,14 @@ const vars = [
55
83
  <section class="cut profile" id="profile" style={vars || undefined}>
56
84
  <div class="cover"></div>
57
85
  <div class="pbody">
58
- <div class="avatar" role={avatarLabel ? "img" : undefined} aria-label={avatarLabel}><slot name="avatar" /></div>
86
+ <a class="avatar" href="#kernel" aria-label={avatarA11y}><slot name="avatar" /></a>
59
87
  <h1 class="pname" set:html={name} />
60
- <p class="handle">{handle}</p>
61
- <p class="bio">{bio}</p>
88
+ <p class="handle" set:html={handle} />
89
+ <p class="bio" set:html={bio} />
62
90
  <!-- prose the face adds under its bio and ABOVE the small print (justai's purpose line). Order is
63
91
  the point: a mission line that falls below the meta reads as a footnote. -->
64
92
  <slot name="belowBio" />
65
- {meta && <p class="meta">{meta}</p>}
93
+ {meta && <p class="meta" set:html={meta} />}
66
94
  <!-- and whatever follows the small print — the apexes' privacy/terms nav. Both slots stay the
67
95
  PERSONA's markup, and therefore the persona's scope, so their styling lives in the persona
68
96
  while the frame around them stays shared. -->
@@ -87,7 +115,16 @@ const vars = [
87
115
  background: var(--avatar-bg, #fff);
88
116
  border: 3px solid var(--bg); box-shadow: 0 8px 22px -16px rgba(0, 0, 0, 0.22);
89
117
  position: relative;
118
+ /* it is an <a> now — undo the link ground, or a slotted SVG drawn in currentColor turns accent */
119
+ color: inherit; text-decoration: none;
120
+ transition: transform 0.15s var(--ease-out, ease-out);
90
121
  }
122
+ /* Pressed, not hovered. The tile is the persona's FACE: growing it under the cursor would animate the
123
+ one object on the page that should hold still. Depressing it on press is the iOS answer — it says
124
+ "this responds" at the moment of contact, on touch as well, where there is no hover to say it. */
125
+ .avatar:active { transform: scale(0.97); }
126
+ .avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
127
+ @media (prefers-reduced-motion: reduce) { .avatar { transition: none; } }
91
128
  /* the avatar content is slotted (persona-owned) → style it globally */
92
129
  .avatar :global(svg) { width: 100%; height: 100%; display: block; }
93
130
  .avatar :global(img) { width: 100%; height: 100%; display: block; object-fit: cover; }