@justai/cuts 0.74.0 → 0.76.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.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@justai/core": "^0.9.0",
39
+ "@justai/core": "^0.10.0",
40
40
  "@justai/ui": "^0.4.0",
41
41
  "@justai/atrium": "^0.1.9"
42
42
  }
@@ -2,10 +2,46 @@
2
2
  // Home affordance → app.zone (the platform / App Store home). Standard on every app:
3
3
  // always visible at the top-left of the toolbar. Also tidies the ?ref / ?lang hints
4
4
  // from the URL so shared links stay clean.
5
+ //
6
+ // IT IS THE ZONE'S MARK NOW, NOT A HOUSE. The generic roof-and-walls said "home" in the abstract and
7
+ // nothing about WHERE home is — every app in the fleet drew the same house, so a visitor who had not
8
+ // already seen app.zone learned nothing from it. The four-bracket mark names the destination.
9
+ //
10
+ // INLINE, and it has to be. A shared component cannot reference `/appzone-mark.svg`: every persona
11
+ // serves its own host, so that path would resolve to a file each one had to carry. Inline also buys
12
+ // what an <img> forecloses — an <img> ignores `currentColor`, and this mark has to be able to speak
13
+ // in the toolbar's ink.
14
+ //
15
+ // MONOCHROME AT REST, ITS OWN COLOURS ON HOVER. app.zone's own toolbar does the same thing against a
16
+ // different state: there the mark is ink until the Profile cut is current, because there the mark IS
17
+ // the persona. Here it can never become current — it is a link OUT — so the idea is mapped onto the
18
+ // only state this control has. At rest it sits level with the row it stands in; engage with it and
19
+ // the zone shows itself. `:focus-visible` counts as the same event: somebody arriving by Tab should
20
+ // be shown what a pointer shows.
21
+ // On touch there is no hover and it stays ink, which is the correct resting appearance rather than a
22
+ // degraded one. Press feedback is untouched: `:active`'s scale applies to the whole anchor.
23
+ //
24
+ // HOUSE-LIGHTING: EXCEPTION — inline, and unlit. The mark carries double inner shadows in
25
+ // app.zone's own appzone-mark.svg; at 20px a `stdDeviation` of 3.84 in a 928 box is 0.08px, so the
26
+ // lighting was never visible at this size, and in one flat ink there is nothing to shade. The
27
+ // geometry here is a second copy of the one in that file, which is authored by hand and generated by
28
+ // nothing — a redraw has to be carried across to this component as well.
5
29
  ---
6
30
 
7
31
  <a class="home" href="https://app.zone" data-home aria-label="app.zone" title="app.zone">
8
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5"/><path d="M5 9.5V20h14V9.5"/></svg>
32
+ <svg class="azmark" viewBox="-8.5 0 928 928" aria-hidden="true">
33
+ <path class="arm a1" d="M360.365 0C372.599 0 382.517 9.91778 382.517 22.152V97.7555C382.517 109.99 372.599 119.907 360.365 119.907H203.398C157.082 119.907 119.537 157.453 119.537 203.769V353.64C119.537 370.244 106.077 383.704 89.4732 383.704H22.152C9.91778 383.704 0 373.786 0 361.552V175.633C0 78.6337 78.6338 0 175.634 0H360.365Z"/>
34
+ <path class="arm a2" d="M550.635 2.37305C538.401 2.37305 528.483 12.2908 528.483 24.525V100.129C528.483 112.363 538.401 122.28 550.635 122.28H707.602C753.918 122.28 791.463 159.826 791.463 206.142V356.013C791.463 372.617 804.923 386.077 821.527 386.077H888.848C901.082 386.077 911 376.159 911 363.925V178.006C911 81.0068 832.366 2.37305 735.366 2.37305H550.635Z"/>
35
+ <path class="arm a3" d="M550.635 928.009C538.401 928.009 528.483 918.092 528.483 905.857V830.254C528.483 818.02 538.401 808.102 550.635 808.102H707.602C753.918 808.102 791.463 770.556 791.463 724.241V574.369C791.463 557.765 804.923 544.305 821.527 544.305H888.848C901.082 544.305 911 554.223 911 566.457V752.376C911 849.376 832.366 928.009 735.366 928.009H550.635Z"/>
36
+ <path class="arm a4" d="M360.365 928.009C372.599 928.009 382.517 918.092 382.517 905.857V830.254C382.517 818.02 372.599 808.102 360.365 808.102L203.398 808.102C157.082 808.102 119.537 770.556 119.537 724.241L119.537 574.369C119.537 557.765 106.077 544.305 89.4732 544.305H22.152C9.91778 544.305 0 554.223 0 566.457L0 752.376C0 849.376 78.6338 928.009 175.634 928.009L360.365 928.009Z"/>
37
+ <rect class="ctr" x="348.103" y="367.09" width="203.323" height="203.323" rx="16.614"/>
38
+ <defs>
39
+ <linearGradient id="azHome1" x1="435.524" y1="-60.9179" x2="6.32917" y2="377.375" gradientUnits="userSpaceOnUse"><stop stop-color="#CCDA3E"/><stop offset="1" stop-color="#5FA82C"/></linearGradient>
40
+ <linearGradient id="azHome2" x1="460.049" y1="-96.9153" x2="904.671" y2="379.748" gradientUnits="userSpaceOnUse"><stop stop-color="#F2694A"/><stop offset="1" stop-color="#E04A30"/></linearGradient>
41
+ <linearGradient id="azHome3" x1="425.634" y1="1029.67" x2="904.671" y2="550.635" gradientUnits="userSpaceOnUse"><stop stop-color="#F8C24A"/><stop offset="1" stop-color="#EFA229"/></linearGradient>
42
+ <linearGradient id="azHome4" x1="450.555" y1="993.674" x2="6.32921" y2="550.635" gradientUnits="userSpaceOnUse"><stop stop-color="#5285DC"/><stop offset="1" stop-color="#2E62C6"/></linearGradient>
43
+ </defs>
44
+ </svg>
9
45
  </a>
10
46
 
11
47
  <style>
@@ -17,7 +53,26 @@
17
53
  }
18
54
  .home:hover { color: var(--text); }
19
55
  .home:active { transform: scale(0.9); }
20
- .home svg { width: 20px; height: 20px; }
56
+ .home svg { width: 20px; height: 20px; display: block; }
57
+
58
+ /* At rest every part of the mark is the row's ink, so the silhouette reads as one shape rather than
59
+ a logo parked in a toolbar. The centre is FILLED rather than knocked out: in the coloured drawing
60
+ it is a counter-shape separating four saturated arms, and in one colour there is nothing to
61
+ separate — a hole there would be a gap the mark does not have. */
62
+ .azmark .arm,
63
+ .azmark .ctr { fill: currentColor; }
64
+
65
+ .home:hover .azmark .a1, .home:focus-visible .azmark .a1 { fill: url(#azHome1); }
66
+ .home:hover .azmark .a2, .home:focus-visible .azmark .a2 { fill: url(#azHome2); }
67
+ .home:hover .azmark .a3, .home:focus-visible .azmark .a3 { fill: url(#azHome3); }
68
+ .home:hover .azmark .a4, .home:focus-visible .azmark .a4 { fill: url(#azHome4); }
69
+ /* The centre follows the ground, and both values are the project's own: #F5F1E8 is the centre of
70
+ Logo Black, drawn for dark grounds, and #2B2724 the centre of Logo White, drawn for pale ones.
71
+ Dark is the un-queried default because the fleet's tokens are dark-first. */
72
+ .home:hover .azmark .ctr, .home:focus-visible .azmark .ctr { fill: #F5F1E8; }
73
+ @media (prefers-color-scheme: light) {
74
+ .home:hover .azmark .ctr, .home:focus-visible .azmark .ctr { fill: #2B2724; }
75
+ }
21
76
  </style>
22
77
 
23
78
  <script>
@@ -93,7 +93,7 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
93
93
  shape — relying on Done alone "implies that completing the task is the only way to exit the
94
94
  sheet" — and the leading edge of the bar is where iOS puts the way out. The kernel inside stops
95
95
  drawing its own while it is somebody's guest; one exit, in the place a person already looks. */}
96
- <Sheet id={`${id}-sheet`} title={title} detent="large" done={false} cancel="Cancel">
96
+ <Sheet id={`${id}-sheet`} title={title} detent="large" done={false} cancel>
97
97
  <div class="jfp" id={id} data-picker={id} data-src={src}>
98
98
  {/* Empty until the first open. `title` is the accessible name of the document inside — a frame
99
99
  announced as "frame" is the one control on the page that says nothing about itself. */}
package/src/Sheet.astro CHANGED
@@ -69,7 +69,10 @@ interface Props {
69
69
  //
70
70
  // Default off: a sheet that only SHOWS something already has a grabber and a backdrop, and a
71
71
  // labelled exit on a surface with nothing to abandon is a control asking a question nobody has.
72
- cancel?: string | false;
72
+ //
73
+ // `true` asks for THE ECOSYSTEM'S WORD, translated into the page's language by this cut — which
74
+ // is what a consumer wants almost every time. Pass a string only to say something else on purpose.
75
+ cancel?: string | boolean;
73
76
  /** Shown at the top of the panel, beside the grabber. Omit for a bare sheet. */
74
77
  title?: string;
75
78
  /** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
@@ -110,14 +113,26 @@ interface Props {
110
113
  // Hence the rule this cut applies: **offered at `large`, absent at `medium`.** A consumer that
111
114
  // wants it anyway passes a label; one that wants it gone passes `done={false}`.
112
115
  //
113
- // It is a LABEL rather than a boolean because the fleet speaks thirteen languages, and a cut that
114
- // hardcoded the English would be a component making its consumer's copy decisions.
115
- done?: string | false;
116
+ // It used to be a LABEL rather than a boolean, reasoned as "the fleet speaks thirteen languages,
117
+ // and a cut that hardcoded the English would be a component making its consumer's copy decisions".
118
+ // That was backwards, and its own default proved it: `"Done"`, hardcoded, in English. Handing the
119
+ // word out does not avoid the decision — it makes it thirteen times per repo and leaves the wrong
120
+ // answer as the default. `Done` is the ECOSYSTEM'S word, and the cut now carries it (law 1).
121
+ done?: string | boolean;
116
122
  }
117
123
  const { id, title, detent = "medium", done, cancel, floorToParent = true } = Astro.props;
118
124
  // Default: shown at large, absent at medium. `false` removes it, a string replaces the word.
119
- const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done" : null));
120
- const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
125
+ //
126
+ // THREE ANSWERS, AND THE MIDDLE ONE IS THE POINT. `true` asks for the ecosystem's word — rendered
127
+ // English here and corrected to the page's language by the script below. A STRING is a consumer
128
+ // making a deliberate copy decision ("Not now", "Discard") and is left exactly as written. `false`
129
+ // removes the control. Only the ecosystem's own word carries `data-chrome`, so a consumer's never
130
+ // gets overwritten.
131
+ const doneDefault = done === undefined && detent === "large";
132
+ const doneLabel = done === false ? null : (doneDefault || done === true ? "Done" : (done as string | undefined) ?? null);
133
+ const cancelLabel = cancel === false || cancel === undefined ? null : (cancel === true ? "Cancel" : cancel);
134
+ const cancelIsOurs = cancel === true;
135
+ const doneIsOurs = doneDefault || done === true;
121
136
  ---
122
137
 
123
138
  {/* aria-labelledby over aria-label when there IS a title, so the accessible name and the visible
@@ -155,7 +170,7 @@ const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
155
170
  NO HOMEWORK: a consumer writes markup and two attributes. No script, and the sheet still
156
171
  works exactly as before for the consumers that never declare a page. */}
157
172
  {cancelLabel && (
158
- <button class="sheet-cancel" id={`${id}-cancel`} type="button" data-sheet-cancel>{cancelLabel}</button>
173
+ <button class="sheet-cancel" id={`${id}-cancel`} type="button" data-sheet-cancel data-chrome={cancelIsOurs ? "cancel" : undefined}>{cancelLabel}</button>
159
174
  )}
160
175
  <button class="sheet-back" id={`${id}-back`} type="button" data-sheet-back hidden>
161
176
  <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>
@@ -165,7 +180,7 @@ const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
165
180
  {/* `id`-suffixed for the same reason the title is: a surface that translates at RUNTIME needs a
166
181
  stable hook to re-label it from its own dictionary. */}
167
182
  {doneLabel && (
168
- <button class="sheet-done" id={`${id}-done`} type="button" data-sheet-done>{doneLabel}</button>
183
+ <button class="sheet-done" id={`${id}-done`} type="button" data-sheet-done data-chrome={doneIsOurs ? "done" : undefined}>{doneLabel}</button>
169
184
  )}
170
185
  <div class="sheet-body"><slot /></div>
171
186
  </div>
@@ -227,12 +242,20 @@ const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
227
242
  :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
228
243
  }
229
244
 
230
- /* MIRRORS DONE ACROSS THE BAR — same band, same weight, opposite edge — and it is not tinted
231
- like a commit: leaving without answering is an ordinary thing to do, not the reason you came. */
245
+ /* queries: cancel colour · should Cancel be tinted · cancel looks disabled · grey cancel ·
246
+ accent on the sheet bar · cancel vs done weight
247
+ MIRRORS DONE ACROSS THE BAR — same band, opposite edge, SAME TINT, LIGHTER WEIGHT.
248
+
249
+ This was grey (`--text-dim`), argued as "leaving without answering is an ordinary thing to do,
250
+ not the reason you came". True about the hierarchy, wrong about the means: on iOS a bar button
251
+ is TINTED, and grey text on a bar is what the platform uses to say UNAVAILABLE. Mail, Calendar
252
+ and Photos all put Cancel in the tint colour beside a bolder Done — the hierarchy is carried by
253
+ WEIGHT, 500 against 600, which is the one channel that does not collide with an existing
254
+ meaning. A dimmed way out reads as a door that will not open. */
232
255
  .sheet-cancel {
233
256
  position: absolute; inset-block-start: 0; inset-inline-start: 0;
234
257
  background: none; border: 0; cursor: pointer; padding: 12px 16px;
235
- font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--text-dim);
258
+ font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--accent);
236
259
  }
237
260
  .sheet-cancel:active { opacity: 0.6; }
238
261
  .sheet-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
@@ -388,6 +411,8 @@ const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
388
411
  </style>
389
412
 
390
413
  <script>
414
+ import { chromeCopy } from "@justai/core";
415
+
391
416
  // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
392
417
  // an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
393
418
  //
@@ -395,6 +420,37 @@ const cancelLabel = cancel === false || cancel === undefined ? null : cancel;
395
420
  // component still had a desktop branch; now that there is one behaviour, there is one code path,
396
421
  // and a mouse can pull the grabber exactly as a finger does.
397
422
  (function () {
423
+ // queries: sheet says Cancel in English · the picker's cancel is not translated · Done stays
424
+ // English · translate the sheet bar · why does the cut translate itself
425
+ //
426
+ // THE BAR SAYS ITS OWN WORDS, IN THE PAGE'S LANGUAGE.
427
+ //
428
+ // Found on an Arabic page: every word in the picker in Arabic, and `Cancel` in English in the
429
+ // sheet's own bar — because the cut rendered the literal and left translating it to whoever
430
+ // mounted it.
431
+ //
432
+ // WHY AT RUNTIME rather than at build. There is no locale to read at build. The fleet routes
433
+ // languages through a `[lang]` PARAMETER, not Astro's i18n config, so `Astro.currentLocale` is
434
+ // undefined in every persona that has one — and the two surfaces that need this most (`files`
435
+ // and `/me`) are ONE bundle serving thirteen languages, so no build-time answer could be right
436
+ // for them anyway. `<html lang>` is the one place every one of them agrees.
437
+ //
438
+ // A CONSUMER'S WORD ALWAYS WINS. `<Sheet cancel="Not now">` is a deliberate choice and this must
439
+ // not overwrite it — so only the DEFAULTS carry `data-chrome`, and only those are rewritten.
440
+ // That is also what keeps the runtime-translating surfaces working: they re-label by id from
441
+ // their own dictionary, which is the older contract and still the more specific one.
442
+ const say = () => {
443
+ const w = chromeCopy(document.documentElement.lang);
444
+ for (const el of document.querySelectorAll<HTMLElement>("[data-chrome]")) {
445
+ const word = w[el.dataset.chrome as "cancel" | "done"];
446
+ if (word) el.textContent = word;
447
+ }
448
+ };
449
+ say();
450
+ // The child personas set `<html lang>` from their own storage AFTER this module runs — a sheet
451
+ // translated only at load would be correct on a host and English inside a guest. Cheaper than a
452
+ // MutationObserver and exact: the attribute cannot change without the element being replaced.
453
+ new MutationObserver(say).observe(document.documentElement, { attributeFilter: ["lang"] });
398
454
  const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
399
455
  const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
400
456
  const wired = new WeakSet<HTMLDialogElement>();