@justai/cuts 0.39.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.39.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>
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. -->