@justai/cuts 0.28.1 → 0.30.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.28.1",
4
- "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.30.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",
@@ -20,10 +20,11 @@
20
20
  "./Toolbar.astro": "./src/Toolbar.astro",
21
21
  "./Profile.astro": "./src/Profile.astro",
22
22
  "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
23
+ "./Sheet.astro": "./src/Sheet.astro",
23
24
  "./ground.css": "./src/ground.css"
24
25
  },
25
26
  "peerDependencies": {
26
- "@justai/ui": ">=0.2.0",
27
+ "@justai/ui": ">=0.3.0",
27
28
  "astro": ">=5"
28
29
  },
29
30
  "publishConfig": {
@@ -136,7 +136,7 @@ const { id, title, body, actionLabel, cancelLabel, destructive = false, ask = fa
136
136
  whisper of its own colour instead (currentColor: accent, red, or text alike) */
137
137
  .confirm-act:focus-visible { box-shadow: none; outline: none; background: color-mix(in srgb, currentColor 12%, transparent); }
138
138
  /* iOS system red — the one colour that means "this ends something" on every persona alike */
139
- .confirm-act.destructive { color: #ff453a; }
139
+ .confirm-act.destructive { color: var(--danger); }
140
140
  /* the ASK genre: the invitation is the preferred path (Apple's bold preferredAction)… */
141
141
  .confirm-act.preferred { font-weight: 700; }
142
142
  /* …and the safe choice answers in a calm, full-contrast regular voice — equal size, equal tap,
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  // The language picker — the shared app.zone chrome, extracted from the eleven byte-identical copies
3
- // (they differed only in comments). The markup, the style and the dropdown behaviour are the SAME for
4
- // every persona; only the DATA differs, so the data is passed in, never imported.
3
+ // (they differed only in comments). The markup, the style and the presentation are the SAME for every
4
+ // persona; only the DATA differs, so the data is passed in, never imported.
5
5
  //
6
6
  // It deliberately does NOT own the locale list. @justai/ui/locale is explicit that the list belongs to
7
7
  // the consuming repo's src/i18n — this ecosystem has been bitten twice by a second copy of it — so the
@@ -9,6 +9,17 @@
9
9
  // component holds the shape; the persona holds the list. It localize-routes to /xx/ (localePath),
10
10
  // remembers the choice in localStorage (the @justai/cuts head router reads it to auto-route next
11
11
  // visit), and preserves the section hash (Profile/Kernel/Posts) across the switch.
12
+ //
13
+ // 2026-07-26 — WHY THIS IS A SHEET AND NO LONGER A DROPDOWN. It used to be a panel absolutely
14
+ // positioned under its button: a desktop metaphor, in an ecosystem whose personas are born
15
+ // mobile/app-souled. Thirteen rows with a search field is not a menu, it is a list you choose from,
16
+ // and iOS presents that from the bottom edge — grabber, drag-to-dismiss. <Sheet> owns all of that at
17
+ // EVERY screen width (there is no wide-screen variant, deliberately — see its header); this file is
18
+ // back to being only what it always claimed to be: the persona's list, in the ecosystem's shape.
19
+ import Sheet from "./Sheet.astro";
20
+ // The persona owns the LIST; the framework owns the ORDER. See @justai/ui/locale — one sequence
21
+ // across the fleet, so a language sits in the same place on every persona a visitor meets.
22
+ import { orderLocales } from "@justai/ui/locale";
12
23
  interface Props {
13
24
  current: string;
14
25
  locales: readonly string[];
@@ -18,22 +29,30 @@ interface Props {
18
29
  searchLabel: string; // the persona's t.chromeLangSearch
19
30
  }
20
31
  const { current, locales, localeNames, localePath, label, searchLabel } = Astro.props;
32
+ const ordered = orderLocales(locales);
21
33
  ---
22
34
 
23
35
  <div class="picker" data-picker>
24
- <button class="trigger" type="button" aria-haspopup="listbox" aria-expanded="false" data-trigger aria-label={`${label}: ${current.toUpperCase()}`} title={label}>{current.toUpperCase()}</button>
36
+ {/* `data-sheet-open` is the whole wiring — <Sheet> finds the trigger; no script here opens it. */}
37
+ <button
38
+ class="trigger"
39
+ type="button"
40
+ data-sheet-open="lang-sheet"
41
+ data-trigger
42
+ aria-haspopup="dialog"
43
+ aria-expanded="false"
44
+ aria-label={`${label}: ${current.toUpperCase()}`}
45
+ title={label}>{current.toUpperCase()}</button>
25
46
 
26
- <div class="menu" role="listbox" data-menu hidden>
47
+ <Sheet id="lang-sheet" title={label} detent="large">
27
48
  {/* `name` and not `id`: a form field with neither makes the browser complain that it cannot
28
49
  autofill (Chrome's Issues panel flags it on every page of the fleet, since this cut is on
29
50
  every page). `name` answers it without the one risk an `id` would carry — an id must be
30
- unique in a document, so a persona that ever renders two pickers would ship invalid HTML,
31
- and a shared cut must be safe to place twice. Prefixed `lang-` so it cannot collide with a
32
- persona's own field of the same idea. The value is never submitted: this input lives in no
33
- form and filters the list client-side. */}
51
+ unique in a document. The value is never submitted: this input lives in no form and filters
52
+ the list client-side. */}
34
53
  <input type="search" class="search" name="lang-search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
35
- <div class="list" data-list>
36
- {locales.map((l) => (
54
+ <div class="list" role="listbox" aria-label={label} data-list>
55
+ {ordered.map((l) => (
37
56
  <a
38
57
  href={localePath(l)}
39
58
  role="option"
@@ -50,11 +69,15 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
50
69
  </a>
51
70
  ))}
52
71
  </div>
53
- </div>
72
+ </Sheet>
54
73
  </div>
55
74
 
56
75
  <style>
57
- .picker { position: relative; }
76
+ /* No `position: relative` any more, and that absence is the point: nothing here is positioned
77
+ against the trigger. <Sheet> presents in the top layer, so the picker can no longer be clipped
78
+ by an overflow on the toolbar the way an absolutely-positioned dropdown could be. */
79
+ .picker { display: contents; }
80
+
58
81
  /* most minimalist: just the 2-letter code, flush to the right edge */
59
82
  .trigger {
60
83
  display: inline-grid; place-items: center; min-width: 36px; height: 36px;
@@ -66,47 +89,43 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
66
89
  .trigger:hover { color: var(--text); }
67
90
  .trigger:active { transform: scale(0.9); }
68
91
 
69
- .menu {
70
- position: absolute;
71
- inset-block-start: calc(100% + 8px);
72
- inset-inline-end: 0;
73
- width: 230px;
74
- /* Near-opaque: the toolbar's own backdrop-filter breaks this menu's, so don't rely on it
75
- for legibility — the blur is a bonus when it works, the solid fill is the guarantee. */
76
- background: color-mix(in srgb, var(--bg) 97%, transparent);
77
- border: 1px solid var(--border);
78
- border-radius: var(--radius-md);
79
- box-shadow: 0 14px 40px -20px rgba(0, 0, 0, 0.2);
80
- backdrop-filter: blur(40px) saturate(1.6);
81
- -webkit-backdrop-filter: blur(40px) saturate(1.6);
82
- padding: 8px;
83
- z-index: 200;
84
- animation: pop 0.2s var(--ease-out) both;
85
- }
86
- @keyframes pop { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }
87
-
92
+ /* iOS's search field: a filled pill rather than an outlined box, sitting above the list. */
88
93
  .search {
89
- width: 100%; font: inherit; font-size: var(--fs-base); color: var(--text);
90
- background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
91
- padding: 9px 12px; outline: none; margin-block-end: 6px;
94
+ width: 100%;
95
+ font: inherit; font-size: var(--fs-base); color: var(--text);
96
+ background: color-mix(in srgb, var(--text) 7%, transparent);
97
+ border: 1px solid transparent; border-radius: 10px;
98
+ padding: 10px 12px; outline: none;
99
+ margin: 0 0 6px;
92
100
  }
93
- .list { max-height: min(54vh, 360px); overflow-y: auto; scrollbar-width: thin; }
101
+ .search::placeholder { color: var(--text-dim); }
102
+ .search:focus { border-color: color-mix(in srgb, var(--accent) 55%, transparent); }
103
+
104
+ .list { padding-block-end: 2px; }
94
105
  .item {
95
- display: flex; align-items: center; justify-content: space-between;
106
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
107
+ /* 44px — the platform's touch target. The old 9px padding made a mouse's row, and row height is
108
+ the clearest single tell of a web dropdown wearing an app's clothes. */
109
+ min-height: 44px;
110
+ padding: 0 12px; border-radius: 10px;
96
111
  text-decoration: none; color: var(--text); font-size: var(--fs-sm);
97
- padding: 9px 12px; border-radius: var(--radius-sm);
98
- transition: background 0.15s;
112
+ transition: background 0.12s var(--ease-out);
99
113
  }
100
- .item:hover { background: var(--surface-2); }
114
+ /* :active before :hover — a finger never hovers, and the feedback must not be desktop-only. */
115
+ .item:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
116
+ @media (hover: hover) { .item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } }
117
+ .item:focus-visible { outline: none; background: color-mix(in srgb, var(--accent) 16%, transparent); }
101
118
  .item.active { color: var(--accent); font-weight: 600; }
102
- .item svg { width: 16px; height: 16px; flex: none; }
119
+ .item svg { width: 17px; height: 17px; flex: none; }
103
120
  </style>
104
121
 
105
122
  <script>
123
+ // What is left here is only what the LIST does: filter, and remember the choice. Opening,
124
+ // dismissing and dragging all belong to <Sheet>.
106
125
  const picker = document.querySelector<HTMLElement>("[data-picker]");
107
126
  if (picker) {
108
127
  const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
109
- const menu = picker.querySelector<HTMLElement>("[data-menu]")!;
128
+ const sheet = document.getElementById("lang-sheet") as HTMLDialogElement | null;
110
129
  const search = picker.querySelector<HTMLInputElement>("[data-search]")!;
111
130
  const items = Array.from(picker.querySelectorAll<HTMLAnchorElement>(".item"));
112
131
 
@@ -116,26 +135,21 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
116
135
  it.style.display = (it.dataset.name ?? "").includes(q) ? "" : "none";
117
136
  }
118
137
  }
119
- function open() {
120
- menu.hidden = false;
138
+ search.addEventListener("input", filter);
139
+
140
+ trigger.addEventListener("click", () => {
121
141
  trigger.setAttribute("aria-expanded", "true");
122
142
  search.value = "";
123
143
  filter();
124
- setTimeout(() => search.focus(), 0);
125
- }
126
- function close() {
127
- menu.hidden = true;
128
- trigger.setAttribute("aria-expanded", "false");
129
- }
130
-
131
- trigger.addEventListener("click", () => (menu.hidden ? open() : close()));
132
- document.addEventListener("click", (e) => {
133
- if (!picker.contains(e.target as Node)) close();
144
+ // Focus the search ONLY where a hardware keyboard is already present. On a touch device,
145
+ // focusing would throw the software keyboard over the list you opened the sheet to read — iOS
146
+ // shows the list first and lets you reach for search. This is NOT a desktop/mobile branch of
147
+ // the EXPERIENCE (the sheet is identical everywhere); it is a fact about input hardware, which
148
+ // is why it asks about the pointer rather than about the viewport.
149
+ if (matchMedia("(pointer: fine)").matches) setTimeout(() => search.focus(), 60);
134
150
  });
135
- document.addEventListener("keydown", (e) => {
136
- if (e.key === "Escape") close();
137
- });
138
- search.addEventListener("input", filter);
151
+ sheet?.addEventListener("close", () => trigger.setAttribute("aria-expanded", "false"));
152
+
139
153
  // Remember the chosen language for auto-routing on the next visit.
140
154
  for (const it of items) {
141
155
  it.addEventListener("click", (e) => {
@@ -0,0 +1,275 @@
1
+ ---
2
+ // THE GROUND rides with the sheet. Without this line every token below resolves to nothing on a
3
+ // page that defines none, `color-mix(in srgb, var(--bg) 93%, transparent)` becomes an invalid
4
+ // declaration, and the panel presents as a transparent hole with unstyled text — which is exactly
5
+ // what the harness caught on the bare page the first time this file was written. Law 1 is not a
6
+ // promise a component makes, it is a file it imports.
7
+ import "./ground.css";
8
+ /**
9
+ * <Sheet> — the ecosystem's SHEET, in iOS grammar.
10
+ *
11
+ * WHY THIS EXISTS (founder, 2026-07-26): justai carries an Apple iOS app experience to the web. A
12
+ * persona is born mobile/app-souled and desktop is the EXTENSION — so the question here is never
13
+ * "is it mobile compatible", it is "does it have desktop compatibility". A panel absolutely
14
+ * positioned under its button is a DESKTOP metaphor: it is what the language picker and the profile
15
+ * menu both were, and on a phone it reads as a website, not an app.
16
+ *
17
+ * WHAT APPLE DOES: a sheet is what a screen presents ON TOP of itself — a list to choose from, a
18
+ * short set of actions, a form. It arrives from the bottom edge with a grabber you can pull. An
19
+ * anchored menu was tried alongside this for one day and removed; see the catalogue in
20
+ * .justai/expert/the-components-the-framework-offers.md for why the ecosystem has ONE shape here.
21
+ *
22
+ * ── ONE BEHAVIOUR AT EVERY WIDTH ─────────────────────────────────────────────────────────────
23
+ *
24
+ * The first version of this file carried a `@media (min-width: 768px)` branch that turned the sheet
25
+ * into a panel anchored to its control, on the reasoning that UIKit adapts a sheet into a popover on
26
+ * regular width. The founder rejected it on sight, and he was right — it contradicted the very
27
+ * thesis written six lines above:
28
+ *
29
+ * "neden desktop da tavrini degistiriyorsun. Mobile neyse desktop tavri da ayni olacak? …
30
+ * default behaviour mobile. yani ekran cozunurlugu yuksek olsa bile ux deneyimi mobile."
31
+ *
32
+ * "Desktop is the EXTENSION" cannot mean "desktop gets a different component". A visitor on a large
33
+ * screen is not a different visitor. So there is NO width branch here, and there must not be one
34
+ * added: the sheet rises from the bottom edge at 390px and at 3840px alike.
35
+ *
36
+ * What a wide screen changes is only how much room surrounds it. **The persona's COLUMN is the
37
+ * active view** — that is the founder's frame, and it is what the panel is sized to: 480px, which is
38
+ * not a chosen number but the fleet's measured one (every persona's page column is `max-width:
39
+ * 480px`). On a phone `min(100%, 480px)` is the whole screen; on a desk it is the persona's own
40
+ * width, centred, with the page dimmed around it. One rule, both cases, no query.
41
+ *
42
+ * NATIVE <dialog>, not a scrim div: the top layer, ::backdrop, Escape and focus containment are the
43
+ * platform's already, and the fleet's ConfirmSheet set that precedent.
44
+ *
45
+ * NO HOMEWORK (law 1): any element carrying `data-sheet-open="<id>"` opens this sheet — the consumer
46
+ * writes no JavaScript, and every token this file consumes is grounded in ground.css.
47
+ *
48
+ * WHAT IT DELIBERATELY DOES NOT DO: iOS scales the presenting screen back behind a sheet. On the web
49
+ * that means transforming the page under a position:fixed toolbar, which breaks the toolbar's
50
+ * containing block. The dim and the blur carry the depth instead; the scale-back is skipped
51
+ * knowingly rather than approximated badly.
52
+ */
53
+ interface Props {
54
+ /** DOM id — the handle `data-sheet-open` points at. */
55
+ id: string;
56
+ /** Shown at the top of the panel, beside the grabber. Omit for a bare sheet. */
57
+ title?: string;
58
+ /** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
59
+ detent?: "medium" | "large";
60
+ }
61
+ const { id, title, detent = "medium" } = Astro.props;
62
+ ---
63
+
64
+ <dialog class="sheet" id={id} data-detent={detent} aria-label={title}>
65
+ {/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
66
+ hands focus to the first focusable descendant — which for a picker is the search field, and on
67
+ a phone that throws the software keyboard over the list the visitor opened the sheet to read.
68
+ The dialog still owns the focus (Escape, containment, screen-reader context all intact); it
69
+ simply starts on the sheet rather than inside a control the visitor did not reach for. */}
70
+ <div class="sheet-panel" data-panel tabindex="-1">
71
+ {/* The grabber is the affordance AND the drag handle — iOS puts them in the same 44pt band, so
72
+ the thing you see is the thing you can grab. Hidden from AX: Escape and the backdrop are the
73
+ keyboard/assistive paths, and a decorative pill announced as a control is noise. */}
74
+ <div class="sheet-grip" data-grip aria-hidden="true"><span></span></div>
75
+ {title && <h2 class="sheet-title">{title}</h2>}
76
+ <div class="sheet-body"><slot /></div>
77
+ </div>
78
+ </dialog>
79
+
80
+ <style>
81
+ .sheet {
82
+ /* The dialog is the full-screen STAGE; the PANEL is the sheet.
83
+ `position: fixed` is written out rather than inherited: the UA gives a dialog
84
+ `position: absolute`, and an absolutely-positioned top-layer element resolves against the
85
+ initial containing block — which is document-relative, so on a scrolled page the sheet would
86
+ be placed at coordinates the visitor is no longer looking at. */
87
+ position: fixed;
88
+ inset: 0;
89
+ width: 100%;
90
+ height: 100%;
91
+ max-width: none;
92
+ max-height: none;
93
+ margin: 0;
94
+ padding: 0;
95
+ border: 0;
96
+ background: none;
97
+ overflow: visible;
98
+ /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
99
+ display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
100
+ UA rule that hides it while closed — a sheet permanently visible on every page. */
101
+ display: none;
102
+ }
103
+ .sheet[open] {
104
+ display: flex;
105
+ align-items: flex-end;
106
+ justify-content: center;
107
+ }
108
+
109
+ /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
110
+ display:none, and a property that had no previous computed value does not transition — the
111
+ "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
112
+ all. That silent no-op is the class of failure the harness exists to catch. */
113
+ .sheet::backdrop {
114
+ background: rgba(0, 0, 0, 0.4);
115
+ -webkit-backdrop-filter: blur(3px);
116
+ backdrop-filter: blur(3px);
117
+ opacity: 1;
118
+ transition: opacity 0.3s var(--ease-out);
119
+ }
120
+ @starting-style {
121
+ .sheet[open]::backdrop { opacity: 0; }
122
+ }
123
+
124
+ .sheet-panel {
125
+ display: flex;
126
+ flex-direction: column;
127
+ /* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
128
+ is the active view: 480px is the fleet's measured page width, not a chosen breakpoint. On a
129
+ phone this is the whole screen; on a desk it is the persona's own width, centred. */
130
+ width: min(100%, var(--sheet-width, 480px));
131
+ max-height: min(92dvh, 100dvh - 24px);
132
+ /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
133
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
134
+ -webkit-backdrop-filter: blur(30px) saturate(1.5);
135
+ backdrop-filter: blur(30px) saturate(1.5);
136
+ border-top: 1px solid var(--border);
137
+ border-radius: 20px 20px 0 0;
138
+ /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
139
+ iOS decelerates into place instead — an overshoot spring belongs to something that scales,
140
+ not to something that travels to an edge. */
141
+ transform: translateY(0);
142
+ transition: transform 0.34s var(--ease-out);
143
+ will-change: transform;
144
+ }
145
+ @starting-style {
146
+ .sheet[open] .sheet-panel { transform: translateY(100%); }
147
+ }
148
+ /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
149
+ a control. Focus a visitor MOVES lands on the rows, which do show one. */
150
+ .sheet-panel:focus { outline: none; }
151
+ /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
152
+ every pointermove is what makes a web sheet feel like a web page. */
153
+ .sheet[data-dragging] .sheet-panel { transition: none; }
154
+
155
+ .sheet-grip {
156
+ flex: none;
157
+ display: grid;
158
+ place-items: center;
159
+ height: 28px;
160
+ cursor: grab;
161
+ touch-action: none; /* the browser must not claim the vertical gesture we are reading */
162
+ }
163
+ .sheet-grip span {
164
+ width: 36px;
165
+ height: 5px;
166
+ border-radius: 2.5px;
167
+ background: color-mix(in srgb, var(--text) 22%, transparent);
168
+ }
169
+ .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
170
+
171
+ .sheet-title {
172
+ flex: none;
173
+ margin: 0;
174
+ padding: 2px 20px 10px;
175
+ font-size: var(--fs-md);
176
+ font-weight: 700;
177
+ letter-spacing: -0.01em;
178
+ color: var(--text);
179
+ text-align: center;
180
+ }
181
+
182
+ .sheet-body {
183
+ flex: 1 1 auto;
184
+ min-height: 0;
185
+ overflow-y: auto;
186
+ /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
187
+ overscroll-behavior: contain;
188
+ -webkit-overflow-scrolling: touch;
189
+ /* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
190
+ its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
191
+ exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
192
+ reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
193
+ number that makes the clipping impossible rather than unlikely. */
194
+ padding: 6px 12px max(14px, env(safe-area-inset-bottom));
195
+ }
196
+
197
+ /* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
198
+ now, because the gesture that changes it does too: the grabber is never hidden. */
199
+ .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
200
+
201
+ @media (prefers-reduced-motion: reduce) {
202
+ .sheet-panel, .sheet::backdrop { transition: none; }
203
+ .sheet-panel { transform: none; opacity: 1; }
204
+ }
205
+ </style>
206
+
207
+ <script>
208
+ // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
209
+ // an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
210
+ //
211
+ // Note what is NOT here any more: a matchMedia guard. The drag worked only below 768px while this
212
+ // component still had a desktop branch; now that there is one behaviour, there is one code path,
213
+ // and a mouse can pull the grabber exactly as a finger does.
214
+ (function () {
215
+ const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
216
+ const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
217
+ const wired = new WeakSet<HTMLDialogElement>();
218
+
219
+ function wire(sheet: HTMLDialogElement) {
220
+ if (wired.has(sheet) || !sheet.showModal) return;
221
+ wired.add(sheet);
222
+ const panel = sheet.querySelector<HTMLElement>("[data-panel]");
223
+ const grip = sheet.querySelector<HTMLElement>("[data-grip]");
224
+ if (!panel) return;
225
+
226
+ // A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
227
+ sheet.addEventListener("click", (e) => { if (e.target === sheet) sheet.close(); });
228
+ sheet.addEventListener("close", () => {
229
+ sheet.removeAttribute("data-expanded");
230
+ panel.style.transform = "";
231
+ });
232
+
233
+ // ── drag: the grabber follows the pointer, and the release decides ──
234
+ let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
235
+ grip?.addEventListener("pointerdown", (e: PointerEvent) => {
236
+ id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
237
+ grip.setPointerCapture(id);
238
+ sheet.setAttribute("data-dragging", "");
239
+ });
240
+ grip?.addEventListener("pointermove", (e: PointerEvent) => {
241
+ if (id !== e.pointerId) return;
242
+ dy = e.clientY - y0;
243
+ // Downward drag travels 1:1. Upward is RUBBER-BANDED rather than free: pulling up past the
244
+ // detent must feel like resistance, not like a second scroll surface.
245
+ panel.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy / 4}px)`;
246
+ });
247
+ const release = (e: PointerEvent) => {
248
+ if (id !== e.pointerId) return;
249
+ const dt = Math.max(1, e.timeStamp - t0);
250
+ const v = dy / dt;
251
+ sheet.removeAttribute("data-dragging");
252
+ panel.style.transform = "";
253
+ id = null;
254
+ if (dy > panel.offsetHeight * DRAG_CLOSE || v > FLICK) sheet.close();
255
+ // A decisive pull upwards promotes the medium detent to large — iOS's two-detent sheet.
256
+ else if (dy < -40) sheet.setAttribute("data-expanded", "");
257
+ };
258
+ grip?.addEventListener("pointerup", release);
259
+ grip?.addEventListener("pointercancel", release);
260
+ }
261
+
262
+ document.addEventListener("click", (e) => {
263
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
264
+ if (!t) return;
265
+ const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
266
+ if (!sheet) return;
267
+ e.preventDefault();
268
+ wire(sheet);
269
+ if (!sheet.open) sheet.showModal();
270
+ // Land the focus on the PANEL, not on whatever control happens to be first inside it — the
271
+ // keyboard-over-the-list failure described on the tabindex attribute above.
272
+ sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
273
+ });
274
+ })();
275
+ </script>
package/src/ground.css CHANGED
@@ -21,8 +21,10 @@
21
21
  * (the same values feed's public-persona.css carries). Change ui's tokens and this file must
22
22
  * follow — the harness pins the skeleton halves equal.
23
23
  *
24
- * Imported by Base.astro AND ConfirmSheet.astro (the one cut consumed standalone), so the ground
25
- * travels with whichever of the two reaches a page first; the bundler dedupes when both do.
24
+ * Imported by Base.astro and by every cut a persona can mount WITHOUT the shell — ConfirmSheet,
25
+ * Sheet, Menu. The ground travels with whichever of them reaches a page first; the bundler dedupes
26
+ * when several do. A new standalone-capable cut must add the import as its first line: the harness
27
+ * checks the tokens statically, but only a bare-page render proves it.
26
28
  */
27
29
 
28
30
  :where(:root) {
@@ -52,6 +54,12 @@
52
54
  --surface: rgba(255, 255, 255, 0.04);
53
55
  --surface-2: rgba(255, 255, 255, 0.07);
54
56
  --accent: #0a84ff;
57
+ /* THE ecosystem's destructive red — Apple's system red, and one of it. Written here rather than
58
+ in each component because a confirmation sheet and a Sign Out row saying "this ends something"
59
+ in two different reds is exactly the drift the ConfirmSheet's own header warns about. It is
60
+ scheme-aware: Apple lightens red on dark backgrounds (#FF453A) and deepens it on light
61
+ (#FF3B30), and a single value would read wrong on one of them. */
62
+ --danger: #ff453a;
55
63
  }
56
64
 
57
65
  @media (prefers-color-scheme: light) {
@@ -62,6 +70,7 @@
62
70
  --border: rgba(0, 0, 0, 0.08);
63
71
  --surface: rgba(255, 255, 255, 0.7);
64
72
  --surface-2: rgba(0, 0, 0, 0.05);
73
+ --danger: #ff3b30;
65
74
  }
66
75
  }
67
76