@justai/cuts 0.29.0 → 0.30.1

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.29.0",
3
+ "version": "0.30.1",
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": {
@@ -21,17 +21,16 @@
21
21
  "./Profile.astro": "./src/Profile.astro",
22
22
  "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
23
23
  "./Sheet.astro": "./src/Sheet.astro",
24
- "./Menu.astro": "./src/Menu.astro",
25
24
  "./ground.css": "./src/ground.css"
26
25
  },
27
26
  "peerDependencies": {
28
- "@justai/ui": ">=0.2.0",
29
27
  "astro": ">=5"
30
28
  },
31
29
  "publishConfig": {
32
30
  "access": "public"
33
31
  },
34
32
  "dependencies": {
35
- "@justai/core": "^0.5.0"
33
+ "@justai/core": "^0.5.0",
34
+ "@justai/ui": "^0.3.0"
36
35
  }
37
36
  }
@@ -13,10 +13,13 @@
13
13
  // 2026-07-26 — WHY THIS IS A SHEET AND NO LONGER A DROPDOWN. It used to be a panel absolutely
14
14
  // positioned under its button: a desktop metaphor, in an ecosystem whose personas are born
15
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, and on a wide screen the
17
- // same sheet adapts to a panel anchored to the button. <Sheet> owns all of that; this file is back to
18
- // being only what it always claimed to be: the persona's list, in the ecosystem's shape.
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
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";
20
23
  interface Props {
21
24
  current: string;
22
25
  locales: readonly string[];
@@ -26,6 +29,7 @@ interface Props {
26
29
  searchLabel: string; // the persona's t.chromeLangSearch
27
30
  }
28
31
  const { current, locales, localeNames, localePath, label, searchLabel } = Astro.props;
32
+ const ordered = orderLocales(locales);
29
33
  ---
30
34
 
31
35
  <div class="picker" data-picker>
@@ -40,7 +44,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
40
44
  aria-label={`${label}: ${current.toUpperCase()}`}
41
45
  title={label}>{current.toUpperCase()}</button>
42
46
 
43
- <Sheet id="lang-sheet" title={label} detent="large" anchor="[data-trigger]">
47
+ <Sheet id="lang-sheet" title={label} detent="large">
44
48
  {/* `name` and not `id`: a form field with neither makes the browser complain that it cannot
45
49
  autofill (Chrome's Issues panel flags it on every page of the fleet, since this cut is on
46
50
  every page). `name` answers it without the one risk an `id` would carry — an id must be
@@ -48,7 +52,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
48
52
  the list client-side. */}
49
53
  <input type="search" class="search" name="lang-search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
50
54
  <div class="list" role="listbox" aria-label={label} data-list>
51
- {locales.map((l) => (
55
+ {ordered.map((l) => (
52
56
  <a
53
57
  href={localePath(l)}
54
58
  role="option"
@@ -117,7 +121,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
117
121
 
118
122
  <script>
119
123
  // What is left here is only what the LIST does: filter, and remember the choice. Opening,
120
- // dismissing, dragging and the wide-screen adaptation all belong to <Sheet>.
124
+ // dismissing and dragging all belong to <Sheet>.
121
125
  const picker = document.querySelector<HTMLElement>("[data-picker]");
122
126
  if (picker) {
123
127
  const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
@@ -137,9 +141,11 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
137
141
  trigger.setAttribute("aria-expanded", "true");
138
142
  search.value = "";
139
143
  filter();
140
- // Focus the search ONLY where a keyboard is already present. On a phone, focusing would throw
141
- // the software keyboard over the list you opened the sheet to read — iOS shows the list first
142
- // and lets you reach for search. The same restraint, expressed as a pointer query.
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.
143
149
  if (matchMedia("(pointer: fine)").matches) setTimeout(() => search.focus(), 60);
144
150
  });
145
151
  sheet?.addEventListener("close", () => trigger.setAttribute("aria-expanded", "false"));
package/src/Sheet.astro CHANGED
@@ -14,16 +14,30 @@ import "./ground.css";
14
14
  * positioned under its button is a DESKTOP metaphor: it is what the language picker and the profile
15
15
  * menu both were, and on a phone it reads as a website, not an app.
16
16
  *
17
- * WHAT APPLE DOES, and the line this component draws with <Menu>: a short list of ACTIONS is a menu,
18
- * anchored to its button. A LIST TO CHOOSE FROM long enough to scroll, or carrying a search field —
19
- * is a sheet, presented from the bottom edge with a grabber and drag-to-dismiss. Thirteen languages
20
- * with a search box is the second thing, which is why the LangPicker is a Sheet and the account
21
- * actions are a Menu.
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.
22
21
  *
23
- * ADAPTIVE THE WAY UIKIT IS: a sheet on compact width becomes, on regular width, a panel anchored to
24
- * the control that summoned it (UIKit turns a sheet into a popover exactly there). Same component,
25
- * same markup, one media query because "extended to desktop" is a presentation change, not a
26
- * second component.
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.
27
41
  *
28
42
  * NATIVE <dialog>, not a scrim div: the top layer, ::backdrop, Escape and focus containment are the
29
43
  * platform's already, and the fleet's ConfirmSheet set that precedent.
@@ -43,16 +57,11 @@ interface Props {
43
57
  title?: string;
44
58
  /** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
45
59
  detent?: "medium" | "large";
46
- /**
47
- * Selector for the control this sheet belongs to. On regular width the panel anchors to it
48
- * instead of centering — UIKit's sheet→popover adaptation. Omit to centre on large screens.
49
- */
50
- anchor?: string;
51
60
  }
52
- const { id, title, detent = "medium", anchor } = Astro.props;
61
+ const { id, title, detent = "medium" } = Astro.props;
53
62
  ---
54
63
 
55
- <dialog class="sheet" id={id} data-detent={detent} data-anchor={anchor} aria-label={title}>
64
+ <dialog class="sheet" id={id} data-detent={detent} aria-label={title}>
56
65
  {/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
57
66
  hands focus to the first focusable descendant — which for a picker is the search field, and on
58
67
  a phone that throws the software keyboard over the list the visitor opened the sheet to read.
@@ -73,9 +82,8 @@ const { id, title, detent = "medium", anchor } = Astro.props;
73
82
  /* The dialog is the full-screen STAGE; the PANEL is the sheet.
74
83
  `position: fixed` is written out rather than inherited: the UA gives a dialog
75
84
  `position: absolute`, and an absolutely-positioned top-layer element resolves against the
76
- initial containing block — which is document-relative, so on a scrolled page the regular-width
77
- branch below would place the panel at coordinates the visitor is no longer looking at. Fixed
78
- makes both branches mean the same thing by the viewport. */
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. */
79
87
  position: fixed;
80
88
  inset: 0;
81
89
  width: 100%;
@@ -87,7 +95,7 @@ const { id, title, detent = "medium", anchor } = Astro.props;
87
95
  border: 0;
88
96
  background: none;
89
97
  overflow: visible;
90
- /* The stage is a flex box that seats the panel on the BOTTOM edge. It has to be written as a
98
+ /* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
91
99
  display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
92
100
  UA rule that hides it while closed — a sheet permanently visible on every page. */
93
101
  display: none;
@@ -97,6 +105,7 @@ const { id, title, detent = "medium", anchor } = Astro.props;
97
105
  align-items: flex-end;
98
106
  justify-content: center;
99
107
  }
108
+
100
109
  /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
101
110
  display:none, and a property that had no previous computed value does not transition — the
102
111
  "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
@@ -115,10 +124,10 @@ const { id, title, detent = "medium", anchor } = Astro.props;
115
124
  .sheet-panel {
116
125
  display: flex;
117
126
  flex-direction: column;
118
- /* Edge to edge on a phone. The stage is a flex box, so without this the panel would shrink to
119
- its content and present as a floating card which is a modal, not a sheet. The regular-width
120
- branch is where it becomes a card, deliberately. */
121
- width: 100%;
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));
122
131
  max-height: min(92dvh, 100dvh - 24px);
123
132
  /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
124
133
  background: color-mix(in srgb, var(--bg) 93%, transparent);
@@ -127,8 +136,8 @@ const { id, title, detent = "medium", anchor } = Astro.props;
127
136
  border-top: 1px solid var(--border);
128
137
  border-radius: 20px 20px 0 0;
129
138
  /* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
130
- iOS decelerates into place instead. The spring belongs to <Menu>, which scales rather than
131
- travels. */
139
+ iOS decelerates into place instead an overshoot spring belongs to something that scales,
140
+ not to something that travels to an edge. */
132
141
  transform: translateY(0);
133
142
  transition: transform 0.34s var(--ease-out);
134
143
  will-change: transform;
@@ -177,48 +186,17 @@ const { id, title, detent = "medium", anchor } = Astro.props;
177
186
  /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
178
187
  overscroll-behavior: contain;
179
188
  -webkit-overflow-scrolling: touch;
180
- padding: 0 12px max(14px, env(safe-area-inset-bottom));
181
- }
182
- /* DETENTS ARE A COMPACT-WIDTH IDEA, and the media query says so. A detent is only half a design
183
- without the gesture that changes it: on regular width the grabber is gone, so a `medium` sheet
184
- would sit at 56dvh forever with no way to ask for more — a panel cropped mid-list, permanently.
185
- There the panel's own max-height governs instead. */
186
- @media (max-width: 767.98px) {
187
- .sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
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));
188
195
  }
189
196
 
190
- /* ── regular width: the sheet becomes a panel, anchored to whatever summoned it ── */
191
- @media (min-width: 768px) {
192
- .sheet {
193
- /* no longer a full-viewport stage — the box IS the panel's position */
194
- inset: auto;
195
- width: auto;
196
- height: auto;
197
- margin: 0;
198
- /* written by the script from the anchor's rect; these are the fallbacks when centring */
199
- top: var(--sheet-top, 50%);
200
- left: var(--sheet-left, 50%);
201
- translate: var(--sheet-translate, -50% -50%);
202
- }
203
- .sheet[open] { display: block; }
204
- .sheet-panel {
205
- width: min(360px, calc(100vw - 32px));
206
- max-height: min(70dvh, 560px);
207
- border: 1px solid var(--border);
208
- border-radius: var(--radius-lg);
209
- box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.45);
210
- transform: none;
211
- opacity: 1;
212
- transition: transform 0.2s var(--ease-out), opacity 0.2s var(--ease-out);
213
- }
214
- @starting-style {
215
- .sheet[open] .sheet-panel { transform: translateY(-8px) scale(0.98); opacity: 0; }
216
- }
217
- .sheet::backdrop { background: rgba(0, 0, 0, 0.18); -webkit-backdrop-filter: none; backdrop-filter: none; }
218
- .sheet-grip { display: none; } /* nothing to drag when it is a popover */
219
- /* — see .sheet-panel:focus below — */
220
- .sheet-title { text-align: start; padding-top: 14px; }
221
- }
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); }
222
200
 
223
201
  @media (prefers-reduced-motion: reduce) {
224
202
  .sheet-panel, .sheet::backdrop { transition: none; }
@@ -229,27 +207,15 @@ const { id, title, detent = "medium", anchor } = Astro.props;
229
207
  <script>
230
208
  // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
231
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.
232
214
  (function () {
233
215
  const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
234
216
  const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
235
217
  const wired = new WeakSet<HTMLDialogElement>();
236
218
 
237
- function place(sheet: HTMLDialogElement) {
238
- // Anchored presentation is a regular-width affair; on compact the CSS owns the position.
239
- if (!window.matchMedia("(min-width: 768px)").matches) return;
240
- const sel = sheet.getAttribute("data-anchor");
241
- const el = sel ? (document.querySelector(sel) as HTMLElement | null) : null;
242
- if (!el) return;
243
- const r = el.getBoundingClientRect();
244
- const panel = sheet.querySelector<HTMLElement>("[data-panel]");
245
- const w = panel ? panel.getBoundingClientRect().width || 360 : 360;
246
- // Keep the panel on screen: prefer aligning its trailing edge to the control's, then clamp.
247
- const left = Math.min(Math.max(12, r.right - w), window.innerWidth - w - 12);
248
- sheet.style.setProperty("--sheet-top", `${r.bottom + 10}px`);
249
- sheet.style.setProperty("--sheet-left", `${left}px`);
250
- sheet.style.setProperty("--sheet-translate", "0 0");
251
- }
252
-
253
219
  function wire(sheet: HTMLDialogElement) {
254
220
  if (wired.has(sheet) || !sheet.showModal) return;
255
221
  wired.add(sheet);
@@ -264,10 +230,9 @@ const { id, title, detent = "medium", anchor } = Astro.props;
264
230
  panel.style.transform = "";
265
231
  });
266
232
 
267
- // ── drag: the grabber follows the finger, and the release decides ──
233
+ // ── drag: the grabber follows the pointer, and the release decides ──
268
234
  let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
269
235
  grip?.addEventListener("pointerdown", (e: PointerEvent) => {
270
- if (window.matchMedia("(min-width: 768px)").matches) return;
271
236
  id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
272
237
  grip.setPointerCapture(id);
273
238
  sheet.setAttribute("data-dragging", "");
@@ -294,26 +259,17 @@ const { id, title, detent = "medium", anchor } = Astro.props;
294
259
  grip?.addEventListener("pointercancel", release);
295
260
  }
296
261
 
297
- function open(sheet: HTMLDialogElement) {
298
- wire(sheet);
299
- place(sheet);
300
- if (!sheet.open) sheet.showModal();
301
- // Land the focus on the PANEL, not on whatever control happens to be first inside it — the
302
- // keyboard-over-the-list failure described on the tabindex attribute above.
303
- sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
304
- }
305
-
306
262
  document.addEventListener("click", (e) => {
307
263
  const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
308
264
  if (!t) return;
309
265
  const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
310
266
  if (!sheet) return;
311
267
  e.preventDefault();
312
- open(sheet);
313
- });
314
- // A resize can move the control the panel is pinned to; re-place only what is on screen.
315
- addEventListener("resize", () => {
316
- document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]").forEach(place);
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 });
317
273
  });
318
274
  })();
319
275
  </script>
package/src/Menu.astro DELETED
@@ -1,216 +0,0 @@
1
- ---
2
- // THE GROUND rides with the menu — see <Sheet> for the failure this line prevents. Law 1 is not a
3
- // promise a component makes, it is a file it imports.
4
- import "./ground.css";
5
- /**
6
- * <Menu> — the ecosystem's ACTION MENU, in iOS grammar.
7
- *
8
- * THE LINE BETWEEN THIS AND <Sheet>, which is Apple's line and not ours: a short list of ACTIONS is a
9
- * menu, anchored to the control that opened it — that is what iOS shows when you tap an avatar or a
10
- * toolbar button. A list you CHOOSE from, long enough to scroll or carrying a search field, is a
11
- * sheet from the bottom edge. Account actions are the first thing; thirteen languages are the second.
12
- *
13
- * A DESKTOP DROPDOWN IS NOT AN iOS MENU, and the difference is not the position — both are anchored.
14
- * It is everything else, and each line below is one of them:
15
- * • it SPRINGS FROM THE CONTROL. transform-origin is set to the corner the menu grew out of, so the
16
- * thing that opened it is visibly where it came from. A dropdown fades in place.
17
- * • the screen behind it DIMS. A menu is modal on iOS; a dropdown is a layer.
18
- * • rows are 44px. That is the platform's touch minimum, and it is why an iOS menu reads roomy
19
- * next to a web dropdown built for a mouse.
20
- * • the icon sits AFTER the label. iOS menus are trailing-icon; web menus are leading-icon. This is
21
- * the single most recognisable tell of the two grammars.
22
- * • hover is a bonus, never the affordance — :active carries the feedback so a finger gets the same
23
- * answer as a cursor.
24
- *
25
- * THE PLATFORM PRIMITIVE IS `popover`, not <dialog>: a menu must light-dismiss and must NOT trap
26
- * focus (a modal dialog would fight the page behind it, and Apple's menus do not trap either). The
27
- * top layer, Escape, and dismiss-on-outside-click all come free and correct.
28
- *
29
- * NO HOMEWORK (law 1): `data-menu-open="<id>"` on any control opens it, positioning included; every
30
- * token consumed is grounded in ground.css.
31
- */
32
- interface Props {
33
- /** DOM id — the handle `data-menu-open` points at. */
34
- id: string;
35
- /** Accessible name for the menu itself, e.g. "Account". */
36
- label?: string;
37
- }
38
- const { id, label } = Astro.props;
39
- ---
40
-
41
- <div class="menu" id={id} popover="auto" role="menu" aria-label={label} data-menu>
42
- <slot />
43
- </div>
44
-
45
- <style is:global>
46
- /* GLOBAL, not scoped, and deliberately: a menu's rows are almost always written by the CONSUMER
47
- (the persona knows what its actions are; the framework knows what a menu looks like). Scoped
48
- styles would reach the panel and miss every row inside it — the same class of failure as a cut
49
- that keeps its selectors but loses the ground it fed on. The class names are prefixed so this
50
- can never be mistaken for a page's own vocabulary. */
51
-
52
- .menu[popover] {
53
- position: fixed;
54
- inset: auto;
55
- top: var(--menu-top, 50%);
56
- left: var(--menu-left, 50%);
57
- margin: 0;
58
- padding: 6px;
59
- width: max-content;
60
- min-width: 232px;
61
- max-width: min(300px, calc(100vw - 24px));
62
- border: 1px solid var(--border);
63
- border-radius: var(--radius-md);
64
- background: color-mix(in srgb, var(--bg) 93%, transparent);
65
- -webkit-backdrop-filter: blur(30px) saturate(1.5);
66
- backdrop-filter: blur(30px) saturate(1.5);
67
- box-shadow: 0 22px 60px -18px rgba(0, 0, 0, 0.5);
68
- color: var(--text);
69
- font-family: var(--font);
70
- overflow: visible;
71
- /* It grows OUT OF the control: the script writes the origin to the corner it was summoned from. */
72
- transform-origin: var(--menu-origin, top right);
73
- transform: scale(0.92);
74
- opacity: 0;
75
- transition: transform 0.22s var(--ease-spring), opacity 0.16s var(--ease-out), overlay 0.22s allow-discrete, display 0.22s allow-discrete;
76
- }
77
- .menu[popover]:popover-open { transform: scale(1); opacity: 1; }
78
- @starting-style {
79
- .menu[popover]:popover-open { transform: scale(0.92); opacity: 0; }
80
- }
81
- .menu[popover]::backdrop {
82
- background: rgba(0, 0, 0, 0.28);
83
- opacity: 0;
84
- transition: opacity 0.22s var(--ease-out), overlay 0.22s allow-discrete, display 0.22s allow-discrete;
85
- }
86
- .menu[popover]:popover-open::backdrop { opacity: 1; }
87
- @starting-style {
88
- .menu[popover]:popover-open::backdrop { opacity: 0; }
89
- }
90
-
91
- /* ── a row ── */
92
- .menu-item {
93
- display: flex;
94
- align-items: center;
95
- justify-content: space-between;
96
- gap: 12px;
97
- width: 100%;
98
- min-height: 44px; /* the platform's touch target, and the reason this reads as an app */
99
- padding: 0 12px;
100
- border: 0;
101
- border-radius: 10px;
102
- background: none;
103
- color: inherit;
104
- font: 600 var(--fs-sm) / 1.3 var(--font);
105
- text-align: start;
106
- text-decoration: none;
107
- cursor: pointer;
108
- transition: background 0.12s var(--ease-out);
109
- }
110
- /* :active first — a finger never hovers, and the feedback must not be a desktop-only privilege. */
111
- .menu-item:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
112
- @media (hover: hover) {
113
- .menu-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
114
- }
115
- .menu-item:focus-visible {
116
- outline: none;
117
- background: color-mix(in srgb, var(--accent) 16%, transparent);
118
- }
119
- /* The icon FOLLOWS the label — the iOS tell. */
120
- .menu-item svg { flex: none; width: 17px; height: 17px; color: var(--text-dim); }
121
- .menu-item .menu-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
122
-
123
- .menu-item.destructive { color: var(--danger); }
124
- .menu-item.destructive svg { color: currentColor; }
125
- .menu-item[aria-checked="true"] { color: var(--confirm-accent, var(--accent)); }
126
- .menu-item[aria-checked="true"] svg { color: currentColor; }
127
-
128
- /* A hairline between GROUPS — inset nowhere, because iOS separates menu sections edge to edge. */
129
- .menu-sep { height: 1px; margin: 5px -6px; background: var(--border); }
130
-
131
- /* An identity block at the head of a menu: avatar, name, secondary line. Not a row — it does
132
- nothing when tapped, so it must not look tappable. */
133
- .menu-head {
134
- display: flex;
135
- align-items: center;
136
- gap: 10px;
137
- padding: 8px 12px 10px;
138
- }
139
- .menu-head .menu-name { font: 700 var(--fs-sm) / 1.25 var(--font); color: var(--text); }
140
- .menu-head .menu-sub { font: 400 var(--fs-xs) / 1.3 var(--font); color: var(--text-dim); }
141
-
142
- @media (prefers-reduced-motion: reduce) {
143
- .menu[popover], .menu[popover]::backdrop { transition: none; }
144
- .menu[popover]:popover-open { transform: none; }
145
- }
146
- </style>
147
-
148
- <script>
149
- // Positioning and the trigger contract, once for every menu in the ecosystem.
150
- (function () {
151
- const GAP = 8;
152
- const triggerOf = new WeakMap<HTMLElement, HTMLElement>();
153
- const wired = new WeakSet<HTMLElement>();
154
-
155
- // aria-expanded is mirrored from the popover's OWN toggle event, once per menu and permanently.
156
- // The first version set it to "true" by hand on click and attached a {once: true} toggle listener
157
- // for the close — which never fired: showPopover() QUEUES its toggle event, so the listener
158
- // registered a line later was consumed by the OPENING toggle and removed before any close
159
- // happened. The trigger then announced an expanded menu forever, to exactly the visitors who
160
- // depend on that announcement. It passed on the desk and failed in CI, where one worker changed
161
- // the timing — a scheduling race, not a slow machine.
162
- function wire(menu: HTMLElement) {
163
- if (wired.has(menu)) return;
164
- wired.add(menu);
165
- menu.addEventListener("toggle", (ev) => {
166
- const t = triggerOf.get(menu);
167
- if (t) t.setAttribute("aria-expanded", (ev as ToggleEvent).newState === "open" ? "true" : "false");
168
- });
169
- }
170
-
171
- function place(menu: HTMLElement, trigger: HTMLElement) {
172
- const r = trigger.getBoundingClientRect();
173
- const w = menu.offsetWidth || 232;
174
- const h = menu.offsetHeight || 200;
175
- // Trailing-aligned by default (the control is usually at the end of a toolbar), then clamped
176
- // into the viewport — a menu that runs off the screen edge is the desktop bug we are leaving.
177
- let left = Math.min(Math.max(12, r.right - w), window.innerWidth - w - 12);
178
- let top = r.bottom + GAP;
179
- let originY = "top";
180
- // Not enough room below → flip above, which is what iOS does rather than shrinking the menu.
181
- if (top + h > window.innerHeight - 12 && r.top - GAP - h > 12) {
182
- top = r.top - GAP - h;
183
- originY = "bottom";
184
- }
185
- const originX = left + w / 2 > r.left + r.width / 2 ? "left" : "right";
186
- menu.style.setProperty("--menu-top", `${Math.round(top)}px`);
187
- menu.style.setProperty("--menu-left", `${Math.round(left)}px`);
188
- menu.style.setProperty("--menu-origin", `${originY} ${originX}`);
189
- }
190
-
191
- document.addEventListener("click", (e) => {
192
- const t = (e.target as HTMLElement)?.closest?.("[data-menu-open]") as HTMLElement | null;
193
- if (!t) return;
194
- const menu = document.getElementById(t.getAttribute("data-menu-open") || "");
195
- if (!menu || !("showPopover" in menu)) return;
196
- e.preventDefault();
197
- wire(menu);
198
- triggerOf.set(menu, t);
199
- if (menu.matches(":popover-open")) { menu.hidePopover(); return; }
200
- // Measure BEFORE showing: an unpositioned popover would paint at its fallback spot for one
201
- // frame and then jump — the flash is small and it is exactly what makes a UI feel unfinished.
202
- menu.style.visibility = "hidden";
203
- menu.showPopover();
204
- place(menu, t);
205
- menu.style.visibility = "";
206
- });
207
-
208
- // Choosing an item closes the menu — every menu, without each consumer remembering to.
209
- document.addEventListener("click", (e) => {
210
- const item = (e.target as HTMLElement)?.closest?.(".menu-item") as HTMLElement | null;
211
- if (!item) return;
212
- const menu = item.closest<HTMLElement>("[data-menu]");
213
- if (menu?.matches(":popover-open")) menu.hidePopover();
214
- });
215
- })();
216
- </script>