@justai/cuts 0.138.0 → 0.140.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.
@@ -105,67 +105,63 @@ const on = value && options.some((o) => o.value === value) ? value : options[0]?
105
105
  </div>
106
106
 
107
107
  <style>
108
- /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
109
- out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
110
- @layer justai.components {
111
- /* queries: segmented control styles · the chosen segment · ios segmented look
112
- ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
113
- which is what makes a segmented control read as "these are the options" rather than as a row of
114
- buttons where one happens to be coloured. */
115
- .jseg {
116
- /* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
117
- SIX pixels wider than its parent unless something has already said `border-box` — and on a
118
- bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
119
- homework takes when nobody notices: a control that fits everywhere it was tried. */
120
- box-sizing: border-box;
121
- display: inline-flex;
122
- align-items: stretch;
123
- gap: 2px;
124
- padding: 2px;
125
- border-radius: var(--radius-md);
126
- background: var(--surface-2);
127
- border: 1px solid var(--border);
128
- /* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
129
- this is what makes it true for the eye. */
130
- isolation: isolate;
131
- }
132
- /* queries: segmented control height · 44px touch target · the segments are too small
133
- TWO SIZES, AND THE DIFFERENCE IS WHAT THE CONTROL IS.
134
- `full` is a VIEW'S OWN SWITCH — a thumb's first target on the screen, so it meets the fleet's
135
- 44px like every row and every action does. `inline` is a TOOLBAR CHIP, sitting among other
136
- chrome at chrome's size; forcing 44 there would make a toolbar taller than its content, which
137
- is why iOS ships its own segmented controls shorter in exactly that position.
138
- Found by a consumer's spec measuring 33px where its page promised 44 — a number the component
139
- should have owned rather than each page discovering separately. */
140
- .jseg[data-width="full"] { display: flex; width: 100%; }
141
- .jseg[data-width="full"] .jseg-item { flex: 1; min-height: 44px; }
108
+ /* queries: segmented control styles · the chosen segment · ios segmented look
109
+ ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
110
+ which is what makes a segmented control read as "these are the options" rather than as a row of
111
+ buttons where one happens to be coloured. */
112
+ .jseg {
113
+ /* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
114
+ SIX pixels wider than its parent unless something has already said `border-box` — and on a
115
+ bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
116
+ homework takes when nobody notices: a control that fits everywhere it was tried. */
117
+ box-sizing: border-box;
118
+ display: inline-flex;
119
+ align-items: stretch;
120
+ gap: 2px;
121
+ padding: 2px;
122
+ border-radius: var(--radius-md);
123
+ background: var(--surface-2);
124
+ border: 1px solid var(--border);
125
+ /* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
126
+ this is what makes it true for the eye. */
127
+ isolation: isolate;
128
+ }
129
+ /* queries: segmented control height · 44px touch target · the segments are too small
130
+ TWO SIZES, AND THE DIFFERENCE IS WHAT THE CONTROL IS.
131
+ `full` is a VIEW'S OWN SWITCH — a thumb's first target on the screen, so it meets the fleet's
132
+ 44px like every row and every action does. `inline` is a TOOLBAR CHIP, sitting among other
133
+ chrome at chrome's size; forcing 44 there would make a toolbar taller than its content, which
134
+ is why iOS ships its own segmented controls shorter in exactly that position.
135
+ Found by a consumer's spec measuring 33px where its page promised 44 — a number the component
136
+ should have owned rather than each page discovering separately. */
137
+ .jseg[data-width="full"] { display: flex; width: 100%; }
138
+ .jseg[data-width="full"] .jseg-item { flex: 1; min-height: 44px; }
142
139
 
143
- .jseg-item {
144
- box-sizing: border-box;
145
- display: inline-flex; align-items: center; justify-content: center; gap: 6px;
146
- min-width: 0; padding: 7px 12px;
147
- border: 0; border-radius: calc(var(--radius-md) - 3px);
148
- background: transparent; color: var(--text-dim);
149
- font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
150
- cursor: pointer;
151
- transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
152
- }
153
- .jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
- .jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
140
+ .jseg-item {
141
+ box-sizing: border-box;
142
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
143
+ min-width: 0; padding: 7px 12px;
144
+ border: 0; border-radius: calc(var(--radius-md) - 3px);
145
+ background: transparent; color: var(--text-dim);
146
+ font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
147
+ cursor: pointer;
148
+ transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
149
+ }
150
+ .jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
+ .jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
155
152
 
156
- /* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
157
- .jseg-item[aria-checked="true"] {
158
- background: var(--surface);
159
- color: var(--accent);
160
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
161
- }
162
- .jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
163
- .jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
153
+ /* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
154
+ .jseg-item[aria-checked="true"] {
155
+ background: var(--surface);
156
+ color: var(--accent);
157
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
158
+ }
159
+ .jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
160
+ .jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
164
161
 
165
- /* Motion is never forced on somebody who asked it off. */
166
- @media (prefers-reduced-motion: reduce) {
167
- .jseg-item { transition: none; }
168
- }
162
+ /* Motion is never forced on somebody who asked it off. */
163
+ @media (prefers-reduced-motion: reduce) {
164
+ .jseg-item { transition: none; }
169
165
  }
170
166
  </style>
171
167
 
package/src/Sheet.astro CHANGED
@@ -187,260 +187,235 @@ const doneIsOurs = doneDefault || done === true;
187
187
  </dialog>
188
188
 
189
189
  <style>
190
- /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
191
- out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
192
- @layer justai.components {
193
- /* queries: the page scrolls behind an open sheet · background scroll lock · the sheet feels
194
- inactive · scroll behind the dialog
195
- THE PAGE BEHIND MUST NOT MOVE. `showModal()` makes the rest of the document INERT — it cannot
196
- be clicked or focused — but it does not stop it SCROLLING, and a person swiping over the dim
197
- watches the page slide under a sheet they thought they were using. The founder named the
198
- feeling exactly: "aktif sheet sanki aktif degilmis hissi veriyor". Measured before fixing:
199
- scrollY went 2244 → 1644 with a sheet open.
190
+ /* queries: the page scrolls behind an open sheet · background scroll lock · the sheet feels
191
+ inactive · scroll behind the dialog
192
+ THE PAGE BEHIND MUST NOT MOVE. `showModal()` makes the rest of the document INERT — it cannot
193
+ be clicked or focused — but it does not stop it SCROLLING, and a person swiping over the dim
194
+ watches the page slide under a sheet they thought they were using. The founder named the
195
+ feeling exactly: "aktif sheet sanki aktif degilmis hissi veriyor". Measured before fixing:
196
+ scrollY went 2244 → 1644 with a sheet open.
200
197
 
201
- Set by script on <html> and reference-counted, because sheets nest: unlocking on the first
202
- close would free the page while a second sheet is still up. */
203
- :global(html[data-sheet-locked]) { overflow: hidden; }
198
+ Set by script on <html> and reference-counted, because sheets nest: unlocking on the first
199
+ close would free the page while a second sheet is still up. */
200
+ :global(html[data-sheet-locked]) { overflow: hidden; }
204
201
 
205
- /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
206
- place a thumb looks for "I am finished here". */
207
- /* The back control mirrors Done across the header: same band, same weight, same restraint. It is
208
- the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
209
- chevron sits tight against the label the way iOS sets a back item. */
210
- .sheet-back {
211
- position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
212
- display: inline-flex; align-items: center; gap: 2px;
213
- border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
214
- color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
215
- max-width: 42%;
216
- }
217
- .sheet-back[hidden] { display: none; }
218
- .sheet-back svg { width: 20px; height: 20px; flex: none; }
219
- .sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
220
- .sheet-back:active { opacity: 0.6; }
221
- .sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
222
-
223
- /* queries: sheet page transition · in-sheet push animation · the pages slide
224
-
225
- Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
226
- what makes the sheet grow and shrink around the content the way a navigation stack does. The
227
- slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
228
- /* queries: the second sheet page appears BELOW the first · pushing shows both pages · the history
229
- list is squeezed at the bottom · a page does not hide · consumer css beats the sheet
230
- HIDING A PAGE IS A MECHANISM, NOT A SUGGESTION, so it is written where a class cannot outrank
231
- it. `[data-sheet-page] { display: none }` is one attribute — specificity (0,1,0) — and any
232
- consumer rule naming two classes beats it. files styles its detail page as `.pp .fd { display:
233
- flex }`, which is ordinary and correct, and it meant the root page NEVER HID: pushing to the
234
- versions drew them one under the other, so a person who tapped History got a cramped strip at
235
- the bottom of a sheet still showing everything they had just left.
236
- Qualified by the dialog it lives in, which raises this to (0,2,1) and keeps the rule honest —
237
- a page outside a sheet is nobody's business here. */
238
- @keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
239
- @keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
240
- @media (prefers-reduced-motion: reduce) {
241
- :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
242
- }
243
-
244
- /* queries: cancel colour · should Cancel be tinted · cancel looks disabled · grey cancel ·
245
- accent on the sheet bar · cancel vs done weight
246
- MIRRORS DONE ACROSS THE BAR — same band, opposite edge, SAME TINT, LIGHTER WEIGHT.
247
-
248
- This was grey (`--text-dim`), argued as "leaving without answering is an ordinary thing to do,
249
- not the reason you came". True about the hierarchy, wrong about the means: on iOS a bar button
250
- is TINTED, and grey text on a bar is what the platform uses to say UNAVAILABLE. Mail, Calendar
251
- and Photos all put Cancel in the tint colour beside a bolder Done — the hierarchy is carried by
252
- WEIGHT, 500 against 600, which is the one channel that does not collide with an existing
253
- meaning. A dimmed way out reads as a door that will not open. */
254
- .sheet-cancel {
255
- position: absolute; inset-block-start: 0; inset-inline-start: 0;
256
- background: none; border: 0; cursor: pointer; padding: 12px 16px;
257
- font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--accent);
258
- }
259
- .sheet-cancel:active { opacity: 0.6; }
260
- .sheet-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
261
- /* Cancel, Done and Back must never stand together — going back IS the way out of a pushed page,
262
- so the leading slot belongs to whichever of the two is meaningful right now. */
263
- .sheet:has(.sheet-back:not([hidden])) .sheet-cancel { display: none; }
264
-
265
- .sheet-done {
266
- position: absolute; inset-block-start: 0; inset-inline-end: 0;
267
- background: none; border: 0; cursor: pointer; padding: 12px 16px;
268
- font: inherit; font-size: var(--fs-sm); font-weight: 600; color: var(--accent);
269
- }
270
- .sheet-done:active { opacity: 0.6; }
271
- .sheet-done:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
202
+ /* Trailing and level with the title, where a navigation bar's right-hand action lives — the one
203
+ place a thumb looks for "I am finished here". */
204
+ /* The back control mirrors Done across the header: same band, same weight, same restraint. It is
205
+ the accent colour because it is a NAVIGATION affordance rather than a destructive one, and the
206
+ chevron sits tight against the label the way iOS sets a back item. */
207
+ .sheet-back {
208
+ position: absolute; inset-inline-start: 12px; inset-block-start: 10px;
209
+ display: inline-flex; align-items: center; gap: 2px;
210
+ border: 0; background: none; padding: 6px 8px 6px 4px; cursor: pointer;
211
+ color: var(--accent); font: inherit; font-size: var(--fs-sm); font-weight: 500;
212
+ max-width: 42%;
213
+ }
214
+ .sheet-back[hidden] { display: none; }
215
+ .sheet-back svg { width: 20px; height: 20px; flex: none; }
216
+ .sheet-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
217
+ .sheet-back:active { opacity: 0.6; }
218
+ .sheet-back:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
272
219
 
273
- .sheet {
274
- /* The dialog is the full-screen STAGE; the PANEL is the sheet.
275
- `position: fixed` is written out rather than inherited: the UA gives a dialog
276
- `position: absolute`, and an absolutely-positioned top-layer element resolves against the
277
- initial containing block — which is document-relative, so on a scrolled page the sheet would
278
- be placed at coordinates the visitor is no longer looking at. */
279
- position: fixed;
280
- inset: 0;
281
- width: 100%;
282
- height: 100%;
283
- max-width: none;
284
- max-height: none;
285
- margin: 0;
286
- padding: 0;
287
- border: 0;
288
- background: none;
289
- overflow: visible;
290
- /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
291
- display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
292
- UA rule that hides it while closed — a sheet permanently visible on every page. */
293
- display: none;
294
- }
295
- .sheet[open] {
296
- display: flex;
297
- align-items: flex-end;
298
- justify-content: center;
299
- }
220
+ /* queries: sheet page transition · in-sheet push animation · the pages slide
300
221
 
301
- /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
302
- display:none, and a property that had no previous computed value does not transition — the
303
- "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
304
- all. That silent no-op is the class of failure the harness exists to catch. */
305
- .sheet::backdrop {
306
- background: rgba(0, 0, 0, 0.4);
307
- -webkit-backdrop-filter: blur(3px);
308
- backdrop-filter: blur(3px);
309
- opacity: 1;
310
- transition: opacity 0.3s var(--ease-out);
311
- }
312
- @starting-style {
313
- .sheet[open]::backdrop { opacity: 0; }
314
- }
222
+ Only one page is in the layout at a time, so the body's height is the CURRENT page's — which is
223
+ what makes the sheet grow and shrink around the content the way a navigation stack does. The
224
+ slide is 220ms and is skipped for reduced motion, where the pages simply swap. */
225
+ /* queries: the second sheet page appears BELOW the first · pushing shows both pages · the history
226
+ list is squeezed at the bottom · a page does not hide · consumer css beats the sheet
227
+ HIDING A PAGE IS A MECHANISM, NOT A SUGGESTION, so it is written where a class cannot outrank
228
+ it. `[data-sheet-page] { display: none }` is one attribute — specificity (0,1,0) — and any
229
+ consumer rule naming two classes beats it. files styles its detail page as `.pp .fd { display:
230
+ flex }`, which is ordinary and correct, and it meant the root page NEVER HID: pushing to the
231
+ versions drew them one under the other, so a person who tapped History got a cramped strip at
232
+ the bottom of a sheet still showing everything they had just left.
233
+ Qualified by the dialog it lives in, which raises this to (0,2,1) and keeps the rule honest —
234
+ a page outside a sheet is nobody's business here. */
235
+ :global(dialog [data-sheet-page]:not(.is-current):not(.is-popping)) { display: none; }
236
+ :global([data-sheet-page]) { display: none; }
237
+ :global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
238
+ :global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
239
+ @keyframes sheet-push { from { transform: translateX(18%); opacity: 0; } }
240
+ @keyframes sheet-pop { from { transform: translateX(-14%); opacity: 0; } }
241
+ @media (prefers-reduced-motion: reduce) {
242
+ :global([data-sheet-page].is-current), :global([data-sheet-page].is-popping) { animation: none; }
243
+ }
315
244
 
316
- .sheet-panel {
317
- position: relative; /* the Done action is positioned against this */
318
- display: flex;
319
- flex-direction: column;
320
- /* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
321
- is the active view, and its width is READ rather than repeated: `--column` is declared once in
322
- @justai/ui and the page is sized from the same token. On a phone this is the whole screen; on a
323
- desk it is the persona's own width, centred. It said 480 until 2026-08-04, in its own copy of
324
- the number — so when the page became 768 the sheet stayed a phone's width on a tablet. */
325
- /* THE FALLBACK IS NOT DECORATION. `--column` arrives with @justai/ui, and a page that uses this
326
- cut without it — the harness's own sheet fixture is one — never sees the token at all. So the
327
- literal here is the fleet's real width, not a historical one: it was 768 for an afternoon and
328
- the sheet quietly rendered at 768 on a 640 fleet, which is how that was found. */
329
- width: min(100%, var(--sheet-width, var(--column, 640px)));
330
- /* dvh is Chromium 108+; the fallback above it survives because the build has targets. */
331
- max-height: min(92vh, 100vh - 24px);
332
- max-height: min(92dvh, 100dvh - 24px);
333
- /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
334
- background: color-mix(in srgb, var(--bg) 93%, transparent);
335
- -webkit-backdrop-filter: blur(30px) saturate(1.5);
336
- backdrop-filter: blur(30px) saturate(1.5);
337
- border-top: 1px solid var(--border);
338
- border-radius: 20px 20px 0 0;
339
- /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
340
- iOS decelerates into place instead — an overshoot spring belongs to something that scales,
341
- not to something that travels to an edge. */
342
- transform: translateY(0);
343
- transition: transform 0.34s var(--ease-out);
344
- will-change: transform;
345
- }
346
- @starting-style {
347
- .sheet[open] .sheet-panel { transform: translateY(100%); }
348
- }
349
- /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
350
- a control. Focus a visitor MOVES lands on the rows, which do show one. */
351
- .sheet-panel:focus { outline: none; }
352
- /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
353
- every pointermove is what makes a web sheet feel like a web page. */
354
- .sheet[data-dragging] .sheet-panel { transition: none; }
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.
355
248
 
356
- .sheet-grip {
357
- flex: none;
358
- display: grid;
359
- place-items: center;
360
- height: 28px;
361
- cursor: grab;
362
- touch-action: none; /* the browser must not claim the vertical gesture we are reading */
363
- }
364
- .sheet-grip span {
365
- width: 36px;
366
- height: 5px;
367
- border-radius: 2.5px;
368
- background: color-mix(in srgb, var(--text) 22%, transparent);
369
- }
370
- .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
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. */
255
+ .sheet-cancel {
256
+ position: absolute; inset-block-start: 0; inset-inline-start: 0;
257
+ background: none; border: 0; cursor: pointer; padding: 12px 16px;
258
+ font: inherit; font-size: var(--fs-sm); font-weight: 500; color: var(--accent);
259
+ }
260
+ .sheet-cancel:active { opacity: 0.6; }
261
+ .sheet-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
262
+ /* Cancel, Done and Back must never stand together — going back IS the way out of a pushed page,
263
+ so the leading slot belongs to whichever of the two is meaningful right now. */
264
+ .sheet:has(.sheet-back:not([hidden])) .sheet-cancel { display: none; }
371
265
 
372
- .sheet-title {
373
- flex: none;
374
- margin: 0;
375
- padding: 2px 20px 10px;
376
- font-size: var(--fs-md);
377
- font-weight: 700;
378
- letter-spacing: -0.01em;
379
- color: var(--text);
380
- text-align: center;
381
- }
266
+ .sheet-done {
267
+ position: absolute; inset-block-start: 0; inset-inline-end: 0;
268
+ background: none; border: 0; cursor: pointer; padding: 12px 16px;
269
+ font: inherit; font-size: var(--fs-sm); font-weight: 600; color: var(--accent);
270
+ }
271
+ .sheet-done:active { opacity: 0.6; }
272
+ .sheet-done:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
382
273
 
383
- .sheet-body {
384
- flex: 1 1 auto;
385
- min-height: 0;
386
- overflow-y: auto;
387
- /* queries: the sheet scrolls sideways · horizontal scroll in a sheet · long text pushes the
388
- panel wider · content wider than the sheet
389
- NOTHING SCROLLS SIDEWAYS. A sheet is a column with a known width, and a person dragging it
390
- left to read the end of a mime type has been handed a defect rather than a feature —
391
- measured at 867px of content in a 390px panel, from one unbroken string.
274
+ .sheet {
275
+ /* The dialog is the full-screen STAGE; the PANEL is the sheet.
276
+ `position: fixed` is written out rather than inherited: the UA gives a dialog
277
+ `position: absolute`, and an absolutely-positioned top-layer element resolves against the
278
+ initial containing block — which is document-relative, so on a scrolled page the sheet would
279
+ be placed at coordinates the visitor is no longer looking at. */
280
+ position: fixed;
281
+ inset: 0;
282
+ width: 100%;
283
+ height: 100%;
284
+ max-width: none;
285
+ max-height: none;
286
+ margin: 0;
287
+ padding: 0;
288
+ border: 0;
289
+ background: none;
290
+ overflow: visible;
291
+ /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
292
+ display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
293
+ UA rule that hides it while closed — a sheet permanently visible on every page. */
294
+ display: none;
295
+ }
296
+ .sheet[open] {
297
+ display: flex;
298
+ align-items: flex-end;
299
+ justify-content: center;
300
+ }
392
301
 
393
- Two halves, and both are needed: `overflow-x` stops the panel itself from becoming a
394
- horizontal scroller, and `overflow-wrap` makes a long unbroken token WRAP instead of being
395
- silently clipped by it. Content that is genuinely wide — a table, a code block — carries its
396
- own scroller; the sheet never becomes one. */
397
- overflow-x: hidden;
398
- overflow-wrap: anywhere;
399
- /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
400
- overscroll-behavior: contain;
401
- -webkit-overflow-scrolling: touch;
402
- /* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
403
- its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
404
- exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
405
- reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
406
- number that makes the clipping impossible rather than unlikely. */
407
- padding: 6px 12px max(14px, env(safe-area-inset-bottom));
408
- }
302
+ /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
303
+ display:none, and a property that had no previous computed value does not transition — the
304
+ "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
305
+ all. That silent no-op is the class of failure the harness exists to catch. */
306
+ .sheet::backdrop {
307
+ background: rgba(0, 0, 0, 0.4);
308
+ -webkit-backdrop-filter: blur(3px);
309
+ backdrop-filter: blur(3px);
310
+ opacity: 1;
311
+ transition: opacity 0.3s var(--ease-out);
312
+ }
313
+ @starting-style {
314
+ .sheet[open]::backdrop { opacity: 0; }
315
+ }
409
316
 
410
- /* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
411
- now, because the gesture that changes it does too: the grabber is never hidden. */
412
- .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
317
+ .sheet-panel {
318
+ position: relative; /* the Done action is positioned against this */
319
+ display: flex;
320
+ flex-direction: column;
321
+ /* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
322
+ is the active view, and its width is READ rather than repeated: `--column` is declared once in
323
+ @justai/ui and the page is sized from the same token. On a phone this is the whole screen; on a
324
+ desk it is the persona's own width, centred. It said 480 until 2026-08-04, in its own copy of
325
+ the number — so when the page became 768 the sheet stayed a phone's width on a tablet. */
326
+ /* THE FALLBACK IS NOT DECORATION. `--column` arrives with @justai/ui, and a page that uses this
327
+ cut without it — the harness's own sheet fixture is one — never sees the token at all. So the
328
+ literal here is the fleet's real width, not a historical one: it was 768 for an afternoon and
329
+ the sheet quietly rendered at 768 on a 640 fleet, which is how that was found. */
330
+ width: min(100%, var(--sheet-width, var(--column, 640px)));
331
+ /* dvh is Chromium 108+; the fallback above it survives because the build has targets. */
332
+ max-height: min(92vh, 100vh - 24px);
333
+ max-height: min(92dvh, 100dvh - 24px);
334
+ /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
335
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
336
+ -webkit-backdrop-filter: blur(30px) saturate(1.5);
337
+ backdrop-filter: blur(30px) saturate(1.5);
338
+ border-top: 1px solid var(--border);
339
+ border-radius: 20px 20px 0 0;
340
+ /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
341
+ iOS decelerates into place instead — an overshoot spring belongs to something that scales,
342
+ not to something that travels to an edge. */
343
+ transform: translateY(0);
344
+ transition: transform 0.34s var(--ease-out);
345
+ will-change: transform;
346
+ }
347
+ @starting-style {
348
+ .sheet[open] .sheet-panel { transform: translateY(100%); }
349
+ }
350
+ /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
351
+ a control. Focus a visitor MOVES lands on the rows, which do show one. */
352
+ .sheet-panel:focus { outline: none; }
353
+ /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
354
+ every pointermove is what makes a web sheet feel like a web page. */
355
+ .sheet[data-dragging] .sheet-panel { transition: none; }
413
356
 
414
- @media (prefers-reduced-motion: reduce) {
415
- .sheet-panel, .sheet::backdrop { transition: none; }
416
- .sheet-panel { transform: none; opacity: 1; }
417
- }
357
+ .sheet-grip {
358
+ flex: none;
359
+ display: grid;
360
+ place-items: center;
361
+ height: 28px;
362
+ cursor: grab;
363
+ touch-action: none; /* the browser must not claim the vertical gesture we are reading */
418
364
  }
365
+ .sheet-grip span {
366
+ width: 36px;
367
+ height: 5px;
368
+ border-radius: 2.5px;
369
+ background: color-mix(in srgb, var(--text) 22%, transparent);
370
+ }
371
+ .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
419
372
 
420
- /* queries: why is this rule outside the layer · unlayered css · a mechanism a consumer must not
421
- switch off · component invariant vs appearance
373
+ .sheet-title {
374
+ flex: none;
375
+ margin: 0;
376
+ padding: 2px 20px 10px;
377
+ font-size: var(--fs-md);
378
+ font-weight: 700;
379
+ letter-spacing: -0.01em;
380
+ color: var(--text);
381
+ text-align: center;
382
+ }
422
383
 
423
- OUTSIDE THE LAYER, AND THAT IS THE WHOLE POINT.
384
+ .sheet-body {
385
+ flex: 1 1 auto;
386
+ min-height: 0;
387
+ overflow-y: auto;
388
+ /* queries: the sheet scrolls sideways · horizontal scroll in a sheet · long text pushes the
389
+ panel wider · content wider than the sheet
390
+ NOTHING SCROLLS SIDEWAYS. A sheet is a column with a known width, and a person dragging it
391
+ left to read the end of a mime type has been handed a defect rather than a feature —
392
+ measured at 867px of content in a 390px panel, from one unbroken string.
424
393
 
425
- Everything above is this component's APPEARANCE, and a persona overriding it should not have to
426
- out-specify anything — that is what `@layer justai.components` buys. These four rules are not
427
- appearance. They are the mechanism that makes a sheet a navigation stack rather than a list, and
428
- in CSS an unlayered rule beats every layered one, whoever wrote it.
394
+ Two halves, and both are needed: `overflow-x` stops the panel itself from becoming a
395
+ horizontal scroller, and `overflow-wrap` makes a long unbroken token WRAP instead of being
396
+ silently clipped by it. Content that is genuinely wide — a table, a code block — carries its
397
+ own scroller; the sheet never becomes one. */
398
+ overflow-x: hidden;
399
+ overflow-wrap: anywhere;
400
+ /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
401
+ overscroll-behavior: contain;
402
+ -webkit-overflow-scrolling: touch;
403
+ /* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
404
+ its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
405
+ exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
406
+ reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
407
+ number that makes the clipping impossible rather than unlikely. */
408
+ padding: 6px 12px max(14px, env(safe-area-inset-bottom));
409
+ }
429
410
 
430
- The fight was already had once with specificity: `[data-sheet-page] { display: none }` is (0,1,0)
431
- and `files` styles its detail page `.pp .fd { display: flex }`, which is ordinary and correct —
432
- the root page never hid, and pushing to the versions drew both, one under the other. Layering
433
- these would hand that defect straight back, which is how it was found again: the spec named "a
434
- consumer's own class cannot keep a page in the layout" failed the moment they went in.
411
+ /* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
412
+ now, because the gesture that changes it does too: the grabber is never hidden. */
413
+ .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
435
414
 
436
- THE RULE THIS IS AN INSTANCE OF: a component's appearance is the consumer's to override; an
437
- invariant a stylesheet can switch off is not an invariant. Across fifteen components and 1,539
438
- lines of CSS, this is the only block that needed to be here — which is worth knowing, because it
439
- means the distinction is cheap to hold rather than a judgement on every rule. */
440
- :global(dialog [data-sheet-page]:not(.is-current):not(.is-popping)) { display: none; }
441
- :global([data-sheet-page]) { display: none; }
442
- :global([data-sheet-page].is-current) { display: block; animation: sheet-push 0.22s var(--ease-out) both; }
443
- :global([data-sheet-page].is-popping) { display: block; animation: sheet-pop 0.22s var(--ease-out) both; }
415
+ @media (prefers-reduced-motion: reduce) {
416
+ .sheet-panel, .sheet::backdrop { transition: none; }
417
+ .sheet-panel { transform: none; opacity: 1; }
418
+ }
444
419
  </style>
445
420
 
446
421
  <script>