@justai/cuts 0.38.0 → 0.40.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.38.0",
3
+ "version": "0.40.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": {
@@ -22,7 +22,9 @@
22
22
  "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
23
23
  "./Sheet.astro": "./src/Sheet.astro",
24
24
  "./ground.css": "./src/ground.css",
25
- "./FilePicker.astro": "./src/FilePicker.astro"
25
+ "./FilePicker.astro": "./src/FilePicker.astro",
26
+ "./ActionList.astro": "./src/ActionList.astro",
27
+ "./ActionRow.astro": "./src/ActionRow.astro"
26
28
  },
27
29
  "peerDependencies": {
28
30
  "astro": ">=5"
@@ -0,0 +1,68 @@
1
+ ---
2
+ // THE GROUND rides with the list, same as every cut — law 1 is a file it imports, not a promise.
3
+ import "./ground.css";
4
+ /**
5
+ * <ActionList> — a group of verbs, in the shape a phone has taught everyone to read.
6
+ *
7
+ * The inset grouped list: one rounded card, hairline-separated rows, full width. It is what iOS
8
+ * uses for actions in Files, Photos, Mail and Settings, and it is here because a row of side-by-side
9
+ * buttons stops working at exactly the width this ecosystem designs for.
10
+ *
11
+ * ── USE ──────────────────────────────────────────────────────────────────────────────────────
12
+ *
13
+ * <ActionList>
14
+ * <ActionRow id="open" tone="accent" label="Open in qr"><svg slot="icon" …/></ActionRow>
15
+ * <ActionRow id="rename" label="Rename"><svg slot="icon" …/></ActionRow>
16
+ * </ActionList>
17
+ * <ActionList>
18
+ * <ActionRow id="delete" tone="danger" label="Delete"><svg slot="icon" …/></ActionRow>
19
+ * </ActionList>
20
+ *
21
+ * TWO LISTS RATHER THAN ONE IS THE GRAMMAR, not a styling choice. A group break is what separates
22
+ * "things you might do" from "the door with no way back", and on a phone that gap is the only
23
+ * protection standing between a thumb and a delete.
24
+ *
25
+ * ── WHY IT IS A COMPONENT AND NOT A CLASS TO COPY ────────────────────────────────────────────
26
+ *
27
+ * The founder's framing, 2026-07-27: the ecosystem is being built as a mobile app that happens to
28
+ * live on the web, and reusable Apple-grade parts are how that survives contact with a hundred
29
+ * personas. A class name copied into each persona is the O(N) shape this fleet keeps ruling out —
30
+ * one bump here has to be able to reach every sheet that shows a verb.
31
+ */
32
+ interface Props {
33
+ /** An optional group heading, as a phone's settings list has. Sentence case, no shouting. */
34
+ title?: string;
35
+ /** A line under the group, for the thing a person needs to know BEFORE choosing. */
36
+ note?: string;
37
+ }
38
+ const { title, note } = Astro.props;
39
+ ---
40
+
41
+ <div class="jal-group">
42
+ {title && <h3 class="jal-title">{title}</h3>}
43
+ <div class="jal"><slot /></div>
44
+ {note && <p class="jal-note">{note}</p>}
45
+ </div>
46
+
47
+ <style>
48
+ .jal-group { display: flex; flex-direction: column; gap: 7px; }
49
+ /* Inset and grouped: the card is the group, and `overflow: hidden` is what lets a row's own
50
+ background reach the rounded corner instead of squaring it off on press. */
51
+ .jal {
52
+ display: flex;
53
+ flex-direction: column;
54
+ background: var(--surface-2);
55
+ border: 1px solid var(--border);
56
+ border-radius: var(--radius-md);
57
+ overflow: hidden;
58
+ }
59
+ .jal-title {
60
+ font-size: var(--fs-xs);
61
+ font-weight: 600;
62
+ color: var(--text-dim);
63
+ text-transform: uppercase;
64
+ letter-spacing: 0.04em;
65
+ padding-inline: 4px;
66
+ }
67
+ .jal-note { font-size: var(--fs-xs); color: var(--text-dim); padding-inline: 4px; line-height: 1.45; }
68
+ </style>
@@ -0,0 +1,123 @@
1
+ ---
2
+ // THE GROUND rides with the row, same as every cut — law 1 is a file it imports, not a promise.
3
+ import "./ground.css";
4
+ /**
5
+ * <ActionRow> — one verb in an <ActionList>. A full-width row: icon, label, and room to be read.
6
+ *
7
+ * WHY IT IS A ROW AND NOT A BUTTON IN A LINE (founder, 2026-07-27). files' detail sheet put four
8
+ * buttons shoulder to shoulder and two of the four labels came out clipped — "Open i…", "Downl…".
9
+ * The founder named the feeling before the cause:
10
+ *
11
+ * "kullanici mobile cozunurlukte calistigi icin ux deneyimini insanin icini daraltacak sekilde
12
+ * tasarmamamiz gerekiyor … 4 adet butonu yan yana yaparak bu hissi bana verdin"
13
+ *
14
+ * The CSS was the confession: `text-overflow: ellipsis` on a control's own label means the layout
15
+ * had already lost the argument with the space it had. A phone column is ~480px; four equal
16
+ * controls with icons and words do not fit, and no amount of tightening makes them fit.
17
+ *
18
+ * iOS answers this the same way everywhere it matters — Files, Photos, Mail, Settings: verbs live
19
+ * in an INSET GROUPED LIST. Full width each, never truncated, and the list grows without a
20
+ * redesign, which is the property that actually decides it here: this sheet is about to gain
21
+ * Share, versions and restore.
22
+ *
23
+ * ── TONE, AND WHY DESTRUCTIVE IS NOT JUST A COLOUR ───────────────────────────────────────────
24
+ *
25
+ * `accent` is the reason the sheet is open. `danger` is a door you cannot walk back through, and it
26
+ * belongs in a group of its OWN — a row's-width of separation is the cheapest protection there is,
27
+ * and it is why Delete never sits shoulder to shoulder with Rename in a well-made sheet.
28
+ */
29
+ interface Props {
30
+ /** DOM id, so a consumer wires it exactly as it wired a button. */
31
+ id?: string;
32
+ /** The verb. Written in full — this row has the width for it. */
33
+ label: string;
34
+ /** `plain` (default) · `accent` for the reason you came · `danger` for the door with no way back. */
35
+ tone?: "plain" | "accent" | "danger";
36
+ /** A value or hint shown at the trailing edge — a size, a count, a chosen option. */
37
+ detail?: string;
38
+ /** Start hidden; a consumer reveals it when it applies (files does this for "Open in …"). */
39
+ hidden?: boolean;
40
+ // queries: translate an action row · data-i on ActionRow · setting textContent wiped the icon
41
+ //
42
+ // THE LABEL IS ADDRESSABLE, and that is not a convenience.
43
+ //
44
+ // A route-less surface translates in the browser, which means something writes `textContent` onto
45
+ // the element that holds a word. Point that at the ROW and it erases the icon along with the
46
+ // label, because the icon is a child of the same button — a whole class of bug that ships looking
47
+ // like "the icons disappeared in Turkish".
48
+ //
49
+ // So the row exposes its label two ways: `i18n` puts the key on the label span for a dictionary
50
+ // sweep, and the span carries `<id>-label` so a consumer with one string to set can address it
51
+ // without knowing this component's internals.
52
+ /** Key for a runtime translator — lands on the LABEL, never on the row. */
53
+ i18n?: string;
54
+ }
55
+ const { id, label, tone = "plain", detail, hidden, i18n } = Astro.props;
56
+ ---
57
+
58
+ <button class:list={["jal-row", tone !== "plain" && `is-${tone}`]} type="button" id={id} hidden={hidden}>
59
+ <span class="jal-ico" aria-hidden="true"><slot name="icon" /></span>
60
+ <span class="jal-label" id={id ? `${id}-label` : undefined} data-i={i18n}>{label}</span>
61
+ {detail && <span class="jal-detail">{detail}</span>}
62
+ </button>
63
+
64
+ <style>
65
+ /* A ROW, not a button in a line: it takes the full width it is given, so the label is never the
66
+ thing that has to shrink. The 44px floor is the one number iOS never moves — it is a fingertip,
67
+ and `min-height` rather than a fixed height so a reader who raised their text size gets a
68
+ taller row instead of a clipped one. */
69
+ .jal-row {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 12px;
73
+ width: 100%;
74
+ min-height: 44px;
75
+ padding: 11px 14px;
76
+ border: 0;
77
+ background: none;
78
+ color: var(--text);
79
+ font: inherit;
80
+ font-size: var(--fs-sm);
81
+ font-weight: 500;
82
+ text-align: start;
83
+ cursor: pointer;
84
+ /* The separator lives on the row and is removed from the last one by the list — so a row can be
85
+ hidden or revealed without leaving a hairline hanging under nothing. */
86
+ border-block-end: 1px solid var(--border);
87
+ transition: background 0.15s;
88
+ }
89
+ .jal-row:last-child,
90
+ .jal-row[hidden] + .jal-row:last-child { border-block-end: 0; }
91
+ .jal-row:active { background: color-mix(in srgb, var(--text) 8%, transparent); }
92
+ @media (hover: hover) {
93
+ .jal-row:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
94
+ }
95
+ .jal-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
96
+
97
+ .jal-ico {
98
+ display: inline-flex;
99
+ flex: none;
100
+ color: var(--text-dim);
101
+ }
102
+ /* The glyph is cut to a fixed box, so it is sized in px on purpose — it is a mark, not reading
103
+ text, and the box it sits in is a hand size rather than a type size. */
104
+ .jal-ico :global(svg) { width: 20px; height: 20px; display: block; }
105
+
106
+ /* The label takes the room. This is the whole point of the component: nothing here clips. */
107
+ .jal-label { flex: 1; min-width: 0; }
108
+ .jal-detail { color: var(--text-dim); font-size: var(--fs-xs); flex: none; }
109
+
110
+ .jal-row.is-accent { color: var(--accent); font-weight: 600; }
111
+ .jal-row.is-accent .jal-ico { color: var(--accent); }
112
+ .jal-row.is-danger { color: var(--danger); }
113
+ .jal-row.is-danger .jal-ico { color: var(--danger); }
114
+ .jal-row.is-danger:active { background: color-mix(in srgb, var(--danger) 12%, transparent); }
115
+ @media (hover: hover) {
116
+ .jal-row.is-danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
117
+ }
118
+
119
+ .jal-row[disabled] { opacity: 0.4; cursor: default; }
120
+ /* A class-set display beats the user agent's `[hidden] { display: none }` — that has bitten this
121
+ ecosystem twice already, in this very sheet. Said again where the display is set. */
122
+ .jal-row[hidden] { display: none !important; }
123
+ </style>
@@ -116,8 +116,21 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
116
116
  import atrium from "@justai/atrium";
117
117
  import { PRESENTATION_PARAM, PRESENTATION_EMBEDDED } from "@justai/core";
118
118
 
119
+ // queries: file id or path · which one should a persona remember · does renaming break a
120
+ // reference · the durable handle for a file
121
+ //
122
+ // A **path** is where a file is standing right now, and it is how a PERSON says where something
123
+ // is. Rename it and every path to it is a dangling string that still looks perfectly valid. An
124
+ // **id** is the file: the kernel has stamped a `crypto.randomUUID()` on every node since the
125
+ // first write, and `mv` never touches it.
126
+ //
127
+ // So a caller reacting to something a person just did may use a path. A caller REMEMBERING a
128
+ // file across time — a project pointing at its logo, a document being saved back — must use the
129
+ // id, and both are handed over here so that choice is available rather than argued for.
119
130
  interface Picked {
120
131
  path: string;
132
+ /** WHAT IT IS, not where it is standing. Survives a rename and a move — remember this one. */
133
+ id: string;
121
134
  name: string;
122
135
  mime: string;
123
136
  bytes: ArrayBuffer;
@@ -131,8 +144,19 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
131
144
  * What a persona hands over to be kept. `name` is a PROPOSAL — the person may rewrite it and
132
145
  * will choose the folder — so give it the best default you have, extension included.
133
146
  */
134
- interface SaveRequest { name: string; bytes: ArrayBuffer | Uint8Array; mime?: string; title?: string; timeout?: number }
135
- interface Saved { path: string; name: string }
147
+ interface SaveRequest {
148
+ /** A PROPOSAL. The person may rewrite it — the extension is shown beside the field, not in it. */
149
+ name?: string;
150
+ /** The persona's format. Kept out of the name box, the way a desktop save dialog keeps it out. */
151
+ ext?: string;
152
+ /** Save BACK to a file already saved once. NO QUESTION IS ASKED: it was answered the first time. */
153
+ id?: string;
154
+ bytes: ArrayBuffer | Uint8Array;
155
+ mime?: string;
156
+ title?: string;
157
+ timeout?: number;
158
+ }
159
+ interface Saved { path: string; id: string; name: string }
136
160
 
137
161
  for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
138
162
  const id = root.dataset.picker!;
@@ -180,20 +204,26 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
180
204
  // for a file and for handing one over — the lazy frame, the presentation declaration, the
181
205
  // heading, the two ways a sheet can end. Extracted so the second question inherits the first
182
206
  // one's scars instead of being written again and re-earning them.
207
+ // Set the frame's src on first use. THREE callers need it now, and the presentation declaration
208
+ // must be identical in all three — a fourth that copied it slightly wrong is how a page ends up
209
+ // announcing itself as standalone on the one path that forgot to say otherwise.
210
+ function ensureFrame() {
211
+ if (frame.getAttribute("src")) return;
212
+ // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
213
+ // are two different situations, and the same controls do not belong in both — standalone
214
+ // there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
215
+ // the declaration rides in the URL and is available in the first paint. It is presentation
216
+ // only: nothing that must not be forged is decided by it.
217
+ const src = root.dataset.src!;
218
+ frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
219
+ }
220
+
183
221
  async function ask<T>(
184
222
  req: { title?: string },
185
223
  invoke: (bus: any) => Promise<T>,
186
224
  ): Promise<T | null> {
187
225
  const bus = await router();
188
- if (!frame.getAttribute("src")) {
189
- // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
190
- // are two different situations, and the same controls do not belong in both — standalone
191
- // there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
192
- // the declaration rides in the URL and is available in the first paint. It is presentation
193
- // only: nothing that must not be forged is decided by it.
194
- const src = root.dataset.src!;
195
- frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
196
- }
226
+ ensureFrame();
197
227
  // ONE HEADING, and it is the sheet's. A per-call title retitles the sheet rather than being
198
228
  // forwarded into the frame: the kernel renders a heading of its own for callers that have no
199
229
  // sheet around them, and sending it one here printed "Upload logo" twice, one under the other.
@@ -275,13 +305,24 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
275
305
  const b: any = req.bytes;
276
306
  const bytes: ArrayBuffer =
277
307
  b instanceof ArrayBuffer ? b : b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
308
+ const args = { name: req.name, ext: req.ext, id: req.id, bytes, mime: req.mime };
309
+ // SAVING BACK OPENS NO SHEET, and that is the point rather than an optimisation. The person
310
+ // named this file the first time; asking again on every save is not care, it is a persona
311
+ // that cannot remember what it is working on. So this path never touches `ask`.
312
+ //
313
+ // BY ID AND NOT BY PATH: between opening a document and saving it, the person may have
314
+ // renamed or moved it in the very list they are looking at. A path captured at open time
315
+ // would then either miss — a second copy appearing under the old name — or land on whatever
316
+ // has since taken that name, which is worse and completely silent.
317
+ if (req.id) {
318
+ const bus = await router();
319
+ ensureFrame();
320
+ return arrived!
321
+ .then(() => bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 60000 } }))
322
+ .catch(() => null) as Promise<Saved | null>;
323
+ }
278
324
  return ask(req, (bus) =>
279
- bus.proxy({
280
- service: "files",
281
- method: "save",
282
- args: { name: req.name, bytes, mime: req.mime },
283
- options: { timeout: req.timeout ?? 300000 },
284
- }));
325
+ bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 300000 } }));
285
326
  }
286
327
 
287
328
  // queries: read a file the persona already has a path to · FilePicker read · restore an asset a
@@ -302,16 +343,16 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
302
343
  // It opens no sheet, because there is no question. The policy still answers "who is asking" —
303
344
  // same-origin only, exactly as every other capability — so this widens no door: a caller that
304
345
  // could not read here a moment ago still cannot.
305
- async function read(path: string): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
346
+ async function read(ref: string | { path?: string; id?: string }): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
347
+ // A bare string is a PATH, because that is what a string reads as. An id must be said out
348
+ // loud — `read({ id })` — so a caller holding one kind can never pass it as the other.
349
+ const where = typeof ref === "string" ? { path: ref } : ref;
306
350
  const bus = await router();
307
- if (!frame.getAttribute("src")) {
308
- const src = root.dataset.src!;
309
- frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
310
- }
351
+ ensureFrame();
311
352
  const [bytes, stat] = await arrived!
312
353
  .then(() => Promise.all([
313
- bus.proxy({ service: "files", method: "read", args: { path }, options: { timeout: 30000 } }),
314
- bus.proxy({ service: "files", method: "stat", args: { path }, options: { timeout: 30000 } }),
354
+ bus.proxy({ service: "files", method: "read", args: where, options: { timeout: 30000 } }),
355
+ bus.proxy({ service: "files", method: "stat", args: where, options: { timeout: 30000 } }),
315
356
  ]))
316
357
  .catch(() => [null, null]);
317
358
  // A file the person deleted since is simply gone. `null` rather than a throw: a caller
package/src/Toolbar.astro CHANGED
@@ -32,13 +32,19 @@ interface Props {
32
32
  profileLabel: string; // aria/title for the Profile nav icon
33
33
  kernelLabel: string; // aria/title for the Kernel (app) nav icon
34
34
  postsLabel: string; // aria/title for the Posts nav icon
35
+ // The persona's own files. ON by default — the edge mounts `/files` on every persona host, so a
36
+ // persona that had to opt in would be a persona whose files exist and cannot be found. The label
37
+ // has an ecosystem default because a component brings its own ground: a consumer that has not
38
+ // translated it yet gets a working, named control rather than an unlabelled icon.
39
+ files?: boolean;
40
+ filesLabel?: string;
35
41
  // The home affordance points at app.zone, so the two apexes suppress it: app.zone IS that
36
42
  // destination, and justai.pro is above it (sending the parent "home" to a child platform is
37
43
  // the wrong direction). Every other persona keeps it.
38
44
  home?: boolean;
39
45
  }
40
46
  import BackToZone from "./BackToZone.astro";
41
- const { sectionsLabel, profileLabel, kernelLabel, postsLabel, home = true } = Astro.props;
47
+ const { sectionsLabel, profileLabel, kernelLabel, postsLabel, home = true, files = true, filesLabel = "Files" } = Astro.props;
42
48
  ---
43
49
 
44
50
  <header class="toolbar">
@@ -57,6 +63,29 @@ const { sectionsLabel, profileLabel, kernelLabel, postsLabel, home = true } = As
57
63
  </a>
58
64
  <slot name="navExtra" />
59
65
  </nav>
66
+ {files && (
67
+ // queries: files icon in the toolbar · how do I reach my files from a persona · the folder
68
+ // in the toolbar · why is files not in the section nav
69
+ //
70
+ // THE PERSONA'S FILES, ONE TAP FROM ANYWHERE IN IT.
71
+ //
72
+ // Until this, the only way into `<persona>/files` was to be asked for a file — the room
73
+ // where your things live could be entered only by a persona's invitation, which is exactly
74
+ // backwards for a room that is yours.
75
+ //
76
+ // OUTSIDE the sections nav, and deliberately: profile, kernel and posts are parts of THIS
77
+ // page and their links are fragments. `/files` is a different document — a place, not a
78
+ // section — and putting a navigation to elsewhere inside `<nav aria-label="sections">`
79
+ // would tell a screen reader it is one of them. Same reasoning the `leftExtra` slot below
80
+ // already carries, which is why this sits beside it rather than in the group above.
81
+ //
82
+ // ON BY DEFAULT, because the edge mounts `/files` on EVERY persona host: a persona that had
83
+ // to opt in would be a persona whose files exist and cannot be found. One bump here reaches
84
+ // the whole fleet, which is the only shape that survives a thousand of them.
85
+ <a class="tb-nav tb-files" id="navFiles" href="/files" aria-label={filesLabel} title={filesLabel}>
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4L11 8.5h8.5A1.5 1.5 0 0 1 21 10v7a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17z"/></svg>
87
+ </a>
88
+ )}
60
89
  <!-- beside the section nav, but deliberately OUTSIDE it: a link that is not a section of this
61
90
  page (justai.pro carries app.zone's mark here). Keeping it out of <nav aria-label=sections>
62
91
  is the a11y half of the reason it is its own slot. -->