@justai/cuts 0.46.1 → 0.48.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 +2 -2
- package/src/Posts.astro +52 -3
- package/src/Sheet.astro +144 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A persona's named parts
|
|
3
|
+
"version": "0.48.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
|
@@ -29,8 +29,27 @@ interface Props {
|
|
|
29
29
|
displayHandle?: string; // the rendered @handle when it differs from the key (app.zone → @appzone)
|
|
30
30
|
avatarBg?: string; // post-avatar tile background (default #fff)
|
|
31
31
|
avatarBgLight?: string; // light-scheme override for that tile (a dark gradient must invert, not stay dark)
|
|
32
|
+
// Which file is THIS persona's face in the feed. Defaults to /icon.svg, which every persona serves at its
|
|
33
|
+
// root, and which is a tile-with-a-mark-inside — right for an app, and what the tile knobs above are for.
|
|
34
|
+
avatarSrc?: string;
|
|
35
|
+
// A mark with no tile of its own, shown on the feed's ground rather than on a tile. app.zone is the case
|
|
36
|
+
// this exists for: the platform mark is a different kind of object from an app icon (the house rule is in
|
|
37
|
+
// app-zone/.justai/expert/how-an-app-zone-icon-is-lit.md — the platform mark is inset, app marks are
|
|
38
|
+
// raised), so wrapping it in a tile states a sibling relationship that is not true.
|
|
39
|
+
//
|
|
40
|
+
// Measured, because it decides the size too: /icon.svg puts its coloured ink across 72% of a 42px box,
|
|
41
|
+
// since the mark sits inset within its own tile. A bare mark spans 98% of the same box. So dropping the
|
|
42
|
+
// tile does not shrink the mark — it renders it about a third larger, which is why nothing here scales it.
|
|
43
|
+
avatarBare?: boolean;
|
|
44
|
+
// The dark-scheme twin of avatarSrc, for a bare mark whose centre has to invert.
|
|
45
|
+
//
|
|
46
|
+
// Two files rather than one file with `@media (prefers-color-scheme)` inside it, and that is a measurement
|
|
47
|
+
// rather than a preference: an SVG referenced by <img> renders in its own document, and the embedding page's
|
|
48
|
+
// scheme did NOT reach it — the centre stayed #2B2724 on a dark feed, where it is all but invisible against
|
|
49
|
+
// the ground. The page's own media query is reliable, so the page picks the file and the file stays dumb.
|
|
50
|
+
avatarSrcDark?: string;
|
|
32
51
|
}
|
|
33
|
-
const { handle, name, footer, displayHandle, avatarBg, avatarBgLight } = Astro.props;
|
|
52
|
+
const { handle, name, footer, displayHandle, avatarBg, avatarBgLight, avatarSrc, avatarBare, avatarSrcDark } = Astro.props;
|
|
34
53
|
const vars = [
|
|
35
54
|
avatarBg && `--feed-av-bg:${avatarBg}`,
|
|
36
55
|
avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
|
|
@@ -43,6 +62,9 @@ const vars = [
|
|
|
43
62
|
data-persona={handle}
|
|
44
63
|
data-handle={displayHandle ?? handle}
|
|
45
64
|
data-name={name}
|
|
65
|
+
data-av={avatarSrc ?? "/icon.svg"}
|
|
66
|
+
data-av-dark={avatarSrcDark}
|
|
67
|
+
data-av-bare={avatarBare ? "" : undefined}
|
|
46
68
|
style={vars || undefined}
|
|
47
69
|
>
|
|
48
70
|
<!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
|
|
@@ -81,6 +103,22 @@ const vars = [
|
|
|
81
103
|
pass avatarBgLight; the rest fall back to their single background. */
|
|
82
104
|
@media (prefers-color-scheme: light) { .pa, .rav { background: var(--feed-av-bg-light, var(--feed-av-bg, #fff)); } }
|
|
83
105
|
|
|
106
|
+
/* A bare mark stands on the feed's own ground, so the tile and its hairline have to go — and they have to
|
|
107
|
+
go from the CONTAINER, which is why this reads upward from the image with :has(). Scoping it to the image
|
|
108
|
+
is what keeps a sibling persona's tiled icon tiled: .rav wraps their reply as well as a self-reply.
|
|
109
|
+
`contain` rather than `cover`, because a mark with transparent margins must not be cropped to fill. */
|
|
110
|
+
.pa:has(.mk.bare), .rav:has(.mk.bare) { background: none; border-color: transparent; }
|
|
111
|
+
.pa .mk.bare, .rav .mk.bare { object-fit: contain; }
|
|
112
|
+
/* The scheme pair: the page decides, because the SVG's own media query does not survive <img>.
|
|
113
|
+
Written as `.pa .mk.av-*` rather than `.av-*` deliberately — `.pa .mk { display: block }` above is two
|
|
114
|
+
classes deep, so a single-class `.av-light { display: none }` loses on specificity and the toggle silently
|
|
115
|
+
does nothing. Measured: it showed the pale-ground file in both schemes until these selectors matched. */
|
|
116
|
+
.pa .mk.av-dark, .rav .mk.av-dark { display: none; }
|
|
117
|
+
@media (prefers-color-scheme: dark) {
|
|
118
|
+
.pa .mk.av-light, .rav .mk.av-light { display: none; }
|
|
119
|
+
.pa .mk.av-dark, .rav .mk.av-dark { display: block; }
|
|
120
|
+
}
|
|
121
|
+
|
|
84
122
|
.ph { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); flex-wrap: wrap; }
|
|
85
123
|
.ph b { font-weight: 600; }
|
|
86
124
|
.phandle, .dot, .pt { color: var(--text-dim); font-weight: 400; }
|
|
@@ -142,8 +180,19 @@ const vars = [
|
|
|
142
180
|
|
|
143
181
|
const esc = (s: unknown): string =>
|
|
144
182
|
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
145
|
-
//
|
|
146
|
-
|
|
183
|
+
// Every persona signs its posts with its own face, /icon.svg by default (each app serves one at its
|
|
184
|
+
// root). The path is a data attribute rather than a literal because a persona may sign with a mark that
|
|
185
|
+
// has no tile — see avatarSrc / avatarBare above. `bare` rides on the IMAGE, not on the container:
|
|
186
|
+
// .rav wraps both a self-reply and ANOTHER persona's reply, so a container rule would strip the tile
|
|
187
|
+
// from siblings who legitimately have one.
|
|
188
|
+
const bare = section.hasAttribute("data-av-bare") ? " bare" : "";
|
|
189
|
+
const avLight = esc(section.dataset.av || "/icon.svg");
|
|
190
|
+
// When a dark twin is declared, BOTH are in the markup and CSS shows one. The alternative — one <img>
|
|
191
|
+
// whose src is chosen in JS off matchMedia — would be a second source of truth for the same decision and
|
|
192
|
+
// would not follow a scheme change without a listener.
|
|
193
|
+
const AV = section.dataset.avDark
|
|
194
|
+
? `<img class="mk${bare} av-light" src="${avLight}" alt=""><img class="mk${bare} av-dark" src="${esc(section.dataset.avDark)}" alt="">`
|
|
195
|
+
: `<img class="mk${bare}" src="${avLight}" alt="">`;
|
|
147
196
|
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
197
|
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
198
|
|
package/src/Sheet.astro
CHANGED
|
@@ -118,6 +118,28 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
118
118
|
the thing you see is the thing you can grab. Hidden from AX: Escape and the backdrop are the
|
|
119
119
|
keyboard/assistive paths, and a decorative pill announced as a control is noise. */}
|
|
120
120
|
<div class="sheet-grip" data-grip aria-hidden="true"><span></span></div>
|
|
121
|
+
{/* queries: back button in a sheet · sheet navigation · go deeper without opening another sheet
|
|
122
|
+
· in-sheet push · a second sheet loses the context · sheet breadcrumb
|
|
123
|
+
|
|
124
|
+
DEPTH WITHOUT A SECOND SHEET.
|
|
125
|
+
|
|
126
|
+
Stacking works — an opened sheet takes the one below as a height floor, so it never reads as
|
|
127
|
+
a retreat. What stacking cannot do is keep you ORIENTED: a full-height sheet over a
|
|
128
|
+
full-height sheet covers the thing you came from, and the only way back is a dismissal that
|
|
129
|
+
looks exactly like leaving altogether.
|
|
130
|
+
|
|
131
|
+
So a sheet may hold PAGES, and push between them. `<button data-sheet-push="versions">`
|
|
132
|
+
moves to the element carrying `data-sheet-page="versions"`; this chevron appears with the
|
|
133
|
+
previous page's title beside it and pops one level. Escape and the backdrop pop too, and
|
|
134
|
+
only close the sheet at the root — the platform behaviour a person already expects from a
|
|
135
|
+
navigation stack anywhere else.
|
|
136
|
+
|
|
137
|
+
NO HOMEWORK: a consumer writes markup and two attributes. No script, and the sheet still
|
|
138
|
+
works exactly as before for the consumers that never declare a page. */}
|
|
139
|
+
<button class="sheet-back" id={`${id}-back`} type="button" data-sheet-back hidden>
|
|
140
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
|
|
141
|
+
<span data-sheet-back-label></span>
|
|
142
|
+
</button>
|
|
121
143
|
{title && <h2 class="sheet-title" id={`${id}-title`}>{title}</h2>}
|
|
122
144
|
{/* `id`-suffixed for the same reason the title is: a surface that translates at RUNTIME needs a
|
|
123
145
|
stable hook to re-label it from its own dictionary. */}
|
|
@@ -143,6 +165,36 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
143
165
|
|
|
144
166
|
/* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
|
|
145
167
|
place a thumb looks for "I am finished here". */
|
|
168
|
+
/* The back control mirrors Done across the header: same band, same weight, same restraint. It is
|
|
169
|
+
the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
|
|
170
|
+
chevron sits tight against the label the way iOS sets a back item. */
|
|
171
|
+
.sheet-back {
|
|
172
|
+
position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
|
|
173
|
+
display: inline-flex; align-items: center; gap: 2px;
|
|
174
|
+
border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
|
|
175
|
+
color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
|
|
176
|
+
max-width: 42%;
|
|
177
|
+
}
|
|
178
|
+
.sheet-back[hidden] { display: none; }
|
|
179
|
+
.sheet-back svg { width: 20px; height: 20px; flex: none; }
|
|
180
|
+
.sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
181
|
+
.sheet-back:active { opacity: 0.6; }
|
|
182
|
+
.sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
|
|
183
|
+
|
|
184
|
+
/* queries: sheet page transition · in-sheet push animation · the pages slide
|
|
185
|
+
|
|
186
|
+
Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
|
|
187
|
+
what makes the sheet grow and shrink around the content the way a navigation stack does. The
|
|
188
|
+
slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
|
|
189
|
+
:global([data-sheet-page]) { display: none; }
|
|
190
|
+
:global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
|
|
191
|
+
:global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
|
|
192
|
+
@keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
|
|
193
|
+
@keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
|
|
194
|
+
@media (prefers-reduced-motion: reduce) {
|
|
195
|
+
:global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
.sheet-done {
|
|
147
199
|
position: absolute; inset-block-start: 0; inset-inline-end: 0;
|
|
148
200
|
background: none; border: 0; cursor: pointer; padding: 12px 16px;
|
|
@@ -339,6 +391,91 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
339
391
|
else panel.style.removeProperty("min-height");
|
|
340
392
|
}
|
|
341
393
|
|
|
394
|
+
// ── in-sheet navigation ────────────────────────────────────────────────────────────────────
|
|
395
|
+
// queries: sheet push pop · back button in a sheet · data-sheet-push · escape pops instead of
|
|
396
|
+
// closing · in-sheet navigation stack
|
|
397
|
+
//
|
|
398
|
+
// A STACK PER SHEET, held on the element rather than in a module variable, because two sheets
|
|
399
|
+
// can be open at once and each has its own history. Empty means "at the root", which is also the
|
|
400
|
+
// state every sheet that never declares a page is permanently in — that is what makes this
|
|
401
|
+
// addition free for the consumers who do not use it.
|
|
402
|
+
const stacks = new WeakMap<HTMLDialogElement, string[]>();
|
|
403
|
+
|
|
404
|
+
const pageOf = (sheet: HTMLDialogElement, key: string) =>
|
|
405
|
+
sheet.querySelector<HTMLElement>(`[data-sheet-page="${CSS.escape(key)}"]`);
|
|
406
|
+
|
|
407
|
+
// The name a person reads on the way back. The page's own `data-sheet-page-title` if it has one,
|
|
408
|
+
// otherwise the sheet's heading — which is the honest answer at the root, since that IS where
|
|
409
|
+
// back goes.
|
|
410
|
+
const titleAt = (sheet: HTMLDialogElement, key: string | undefined) =>
|
|
411
|
+
(key ? pageOf(sheet, key)?.dataset.sheetPageTitle : null)
|
|
412
|
+
?? sheet.querySelector<HTMLElement>(".sheet-title")?.textContent?.trim()
|
|
413
|
+
?? "";
|
|
414
|
+
|
|
415
|
+
function show(sheet: HTMLDialogElement, popping: boolean) {
|
|
416
|
+
const stack = stacks.get(sheet) ?? [];
|
|
417
|
+
const pages = sheet.querySelectorAll<HTMLElement>("[data-sheet-page]");
|
|
418
|
+
if (!pages.length) return;
|
|
419
|
+
const current = stack[stack.length - 1];
|
|
420
|
+
for (const el of pages) {
|
|
421
|
+
const on = current ? el.dataset.sheetPage === current : el.hasAttribute("data-sheet-root");
|
|
422
|
+
el.classList.toggle("is-current", on && !popping);
|
|
423
|
+
el.classList.toggle("is-popping", on && popping);
|
|
424
|
+
}
|
|
425
|
+
const back = sheet.querySelector<HTMLElement>("[data-sheet-back]");
|
|
426
|
+
const label = sheet.querySelector<HTMLElement>("[data-sheet-back-label]");
|
|
427
|
+
if (back) back.hidden = stack.length === 0;
|
|
428
|
+
// The label names WHERE BACK GOES — the level beneath the current one — which is the whole
|
|
429
|
+
// convention iOS set. Naming the current page instead is the mistake that makes a back button
|
|
430
|
+
// read as a title.
|
|
431
|
+
if (label) label.textContent = stack.length ? titleAt(sheet, stack[stack.length - 2]) : "";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function push(sheet: HTMLDialogElement, key: string) {
|
|
435
|
+
if (!pageOf(sheet, key)) return;
|
|
436
|
+
const stack = stacks.get(sheet) ?? [];
|
|
437
|
+
stacks.set(sheet, [...stack, key]);
|
|
438
|
+
show(sheet, false);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function pop(sheet: HTMLDialogElement) {
|
|
442
|
+
const stack = stacks.get(sheet) ?? [];
|
|
443
|
+
if (!stack.length) return false;
|
|
444
|
+
stacks.set(sheet, stack.slice(0, -1));
|
|
445
|
+
show(sheet, true);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
document.addEventListener("click", (e) => {
|
|
450
|
+
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-push]");
|
|
451
|
+
if (t) {
|
|
452
|
+
const sheet = t.closest("dialog.sheet") as HTMLDialogElement | null;
|
|
453
|
+
if (sheet) { e.preventDefault(); push(sheet, t.getAttribute("data-sheet-push") || ""); }
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const b = (e.target as HTMLElement)?.closest?.("[data-sheet-back]");
|
|
457
|
+
if (b) { e.preventDefault(); pop(b.closest("dialog.sheet") as HTMLDialogElement); }
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ESCAPE POPS BEFORE IT CLOSES, and the backdrop does the same. Anywhere else in a browser, Back
|
|
461
|
+
// at depth means "up one level"; a sheet that jumps straight out from three levels down throws
|
|
462
|
+
// away everything the person navigated through, and they cannot tell the two gestures apart
|
|
463
|
+
// beforehand. `preventDefault` on `cancel` is what keeps <dialog> from closing underneath us.
|
|
464
|
+
document.addEventListener("cancel", (e) => {
|
|
465
|
+
const sheet = e.target as HTMLDialogElement;
|
|
466
|
+
if (sheet?.classList?.contains("sheet") && pop(sheet)) e.preventDefault();
|
|
467
|
+
}, true);
|
|
468
|
+
|
|
469
|
+
// A sheet always REOPENS AT ITS ROOT. Coming back to a surface and finding it three levels deep
|
|
470
|
+
// where you left it is the behaviour of a window, not of a sheet — and nobody expects a sheet to
|
|
471
|
+
// remember.
|
|
472
|
+
document.addEventListener("click", (e) => {
|
|
473
|
+
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
|
|
474
|
+
if (!t) return;
|
|
475
|
+
const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
|
|
476
|
+
if (sheet) { stacks.set(sheet, []); show(sheet, false); }
|
|
477
|
+
}, true);
|
|
478
|
+
|
|
342
479
|
function wire(sheet: HTMLDialogElement) {
|
|
343
480
|
if (wired.has(sheet) || !sheet.showModal) return;
|
|
344
481
|
wired.add(sheet);
|
|
@@ -356,11 +493,17 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
356
493
|
if (!panel) return;
|
|
357
494
|
|
|
358
495
|
// A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
|
|
359
|
-
|
|
496
|
+
// A tap on the stage pops one level first, for the same reason Escape does.
|
|
497
|
+
sheet.addEventListener("click", (e) => { if (e.target === sheet && !pop(sheet)) sheet.close(); });
|
|
360
498
|
sheet.addEventListener("close", () => {
|
|
361
499
|
sheet.removeAttribute("data-expanded");
|
|
362
500
|
panel.style.transform = "";
|
|
501
|
+
// Back to the root on the way out, whichever way it was dismissed — see the reopen note.
|
|
502
|
+
if (stacks.get(sheet)?.length) { stacks.set(sheet, []); show(sheet, false); }
|
|
363
503
|
});
|
|
504
|
+
// A sheet that ships pages must show one from the very first frame, or its body is empty until
|
|
505
|
+
// somebody navigates. The root is whichever page carries `data-sheet-root`.
|
|
506
|
+
show(sheet, false);
|
|
364
507
|
|
|
365
508
|
// ── drag: the grabber follows the pointer, and the release decides ──
|
|
366
509
|
let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
|