@justai/cuts 0.28.1 → 0.29.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.29.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,6 +20,8 @@
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",
24
+ "./Menu.astro": "./src/Menu.astro",
23
25
  "./ground.css": "./src/ground.css"
24
26
  },
25
27
  "peerDependencies": {
@@ -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,14 @@
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, 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.
19
+ import Sheet from "./Sheet.astro";
12
20
  interface Props {
13
21
  current: string;
14
22
  locales: readonly string[];
@@ -21,18 +29,25 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
21
29
  ---
22
30
 
23
31
  <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>
32
+ {/* `data-sheet-open` is the whole wiring — <Sheet> finds the trigger; no script here opens it. */}
33
+ <button
34
+ class="trigger"
35
+ type="button"
36
+ data-sheet-open="lang-sheet"
37
+ data-trigger
38
+ aria-haspopup="dialog"
39
+ aria-expanded="false"
40
+ aria-label={`${label}: ${current.toUpperCase()}`}
41
+ title={label}>{current.toUpperCase()}</button>
25
42
 
26
- <div class="menu" role="listbox" data-menu hidden>
43
+ <Sheet id="lang-sheet" title={label} detent="large" anchor="[data-trigger]">
27
44
  {/* `name` and not `id`: a form field with neither makes the browser complain that it cannot
28
45
  autofill (Chrome's Issues panel flags it on every page of the fleet, since this cut is on
29
46
  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. */}
47
+ unique in a document. The value is never submitted: this input lives in no form and filters
48
+ the list client-side. */}
34
49
  <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>
50
+ <div class="list" role="listbox" aria-label={label} data-list>
36
51
  {locales.map((l) => (
37
52
  <a
38
53
  href={localePath(l)}
@@ -50,11 +65,15 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
50
65
  </a>
51
66
  ))}
52
67
  </div>
53
- </div>
68
+ </Sheet>
54
69
  </div>
55
70
 
56
71
  <style>
57
- .picker { position: relative; }
72
+ /* No `position: relative` any more, and that absence is the point: nothing here is positioned
73
+ against the trigger. <Sheet> presents in the top layer, so the picker can no longer be clipped
74
+ by an overflow on the toolbar the way an absolutely-positioned dropdown could be. */
75
+ .picker { display: contents; }
76
+
58
77
  /* most minimalist: just the 2-letter code, flush to the right edge */
59
78
  .trigger {
60
79
  display: inline-grid; place-items: center; min-width: 36px; height: 36px;
@@ -66,47 +85,43 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
66
85
  .trigger:hover { color: var(--text); }
67
86
  .trigger:active { transform: scale(0.9); }
68
87
 
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
-
88
+ /* iOS's search field: a filled pill rather than an outlined box, sitting above the list. */
88
89
  .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;
90
+ width: 100%;
91
+ font: inherit; font-size: var(--fs-base); color: var(--text);
92
+ background: color-mix(in srgb, var(--text) 7%, transparent);
93
+ border: 1px solid transparent; border-radius: 10px;
94
+ padding: 10px 12px; outline: none;
95
+ margin: 0 0 6px;
92
96
  }
93
- .list { max-height: min(54vh, 360px); overflow-y: auto; scrollbar-width: thin; }
97
+ .search::placeholder { color: var(--text-dim); }
98
+ .search:focus { border-color: color-mix(in srgb, var(--accent) 55%, transparent); }
99
+
100
+ .list { padding-block-end: 2px; }
94
101
  .item {
95
- display: flex; align-items: center; justify-content: space-between;
102
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
103
+ /* 44px — the platform's touch target. The old 9px padding made a mouse's row, and row height is
104
+ the clearest single tell of a web dropdown wearing an app's clothes. */
105
+ min-height: 44px;
106
+ padding: 0 12px; border-radius: 10px;
96
107
  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;
108
+ transition: background 0.12s var(--ease-out);
99
109
  }
100
- .item:hover { background: var(--surface-2); }
110
+ /* :active before :hover — a finger never hovers, and the feedback must not be desktop-only. */
111
+ .item:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
112
+ @media (hover: hover) { .item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } }
113
+ .item:focus-visible { outline: none; background: color-mix(in srgb, var(--accent) 16%, transparent); }
101
114
  .item.active { color: var(--accent); font-weight: 600; }
102
- .item svg { width: 16px; height: 16px; flex: none; }
115
+ .item svg { width: 17px; height: 17px; flex: none; }
103
116
  </style>
104
117
 
105
118
  <script>
119
+ // 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>.
106
121
  const picker = document.querySelector<HTMLElement>("[data-picker]");
107
122
  if (picker) {
108
123
  const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
109
- const menu = picker.querySelector<HTMLElement>("[data-menu]")!;
124
+ const sheet = document.getElementById("lang-sheet") as HTMLDialogElement | null;
110
125
  const search = picker.querySelector<HTMLInputElement>("[data-search]")!;
111
126
  const items = Array.from(picker.querySelectorAll<HTMLAnchorElement>(".item"));
112
127
 
@@ -116,26 +131,19 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
116
131
  it.style.display = (it.dataset.name ?? "").includes(q) ? "" : "none";
117
132
  }
118
133
  }
119
- function open() {
120
- menu.hidden = false;
134
+ search.addEventListener("input", filter);
135
+
136
+ trigger.addEventListener("click", () => {
121
137
  trigger.setAttribute("aria-expanded", "true");
122
138
  search.value = "";
123
139
  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();
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.
143
+ if (matchMedia("(pointer: fine)").matches) setTimeout(() => search.focus(), 60);
134
144
  });
135
- document.addEventListener("keydown", (e) => {
136
- if (e.key === "Escape") close();
137
- });
138
- search.addEventListener("input", filter);
145
+ sheet?.addEventListener("close", () => trigger.setAttribute("aria-expanded", "false"));
146
+
139
147
  // Remember the chosen language for auto-routing on the next visit.
140
148
  for (const it of items) {
141
149
  it.addEventListener("click", (e) => {
package/src/Menu.astro ADDED
@@ -0,0 +1,216 @@
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>
@@ -0,0 +1,319 @@
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, 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.
22
+ *
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.
27
+ *
28
+ * NATIVE <dialog>, not a scrim div: the top layer, ::backdrop, Escape and focus containment are the
29
+ * platform's already, and the fleet's ConfirmSheet set that precedent.
30
+ *
31
+ * NO HOMEWORK (law 1): any element carrying `data-sheet-open="<id>"` opens this sheet — the consumer
32
+ * writes no JavaScript, and every token this file consumes is grounded in ground.css.
33
+ *
34
+ * WHAT IT DELIBERATELY DOES NOT DO: iOS scales the presenting screen back behind a sheet. On the web
35
+ * that means transforming the page under a position:fixed toolbar, which breaks the toolbar's
36
+ * containing block. The dim and the blur carry the depth instead; the scale-back is skipped
37
+ * knowingly rather than approximated badly.
38
+ */
39
+ interface Props {
40
+ /** DOM id — the handle `data-sheet-open` points at. */
41
+ id: string;
42
+ /** Shown at the top of the panel, beside the grabber. Omit for a bare sheet. */
43
+ title?: string;
44
+ /** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
45
+ 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
+ }
52
+ const { id, title, detent = "medium", anchor } = Astro.props;
53
+ ---
54
+
55
+ <dialog class="sheet" id={id} data-detent={detent} data-anchor={anchor} aria-label={title}>
56
+ {/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
57
+ hands focus to the first focusable descendant — which for a picker is the search field, and on
58
+ a phone that throws the software keyboard over the list the visitor opened the sheet to read.
59
+ The dialog still owns the focus (Escape, containment, screen-reader context all intact); it
60
+ simply starts on the sheet rather than inside a control the visitor did not reach for. */}
61
+ <div class="sheet-panel" data-panel tabindex="-1">
62
+ {/* The grabber is the affordance AND the drag handle — iOS puts them in the same 44pt band, so
63
+ the thing you see is the thing you can grab. Hidden from AX: Escape and the backdrop are the
64
+ keyboard/assistive paths, and a decorative pill announced as a control is noise. */}
65
+ <div class="sheet-grip" data-grip aria-hidden="true"><span></span></div>
66
+ {title && <h2 class="sheet-title">{title}</h2>}
67
+ <div class="sheet-body"><slot /></div>
68
+ </div>
69
+ </dialog>
70
+
71
+ <style>
72
+ .sheet {
73
+ /* The dialog is the full-screen STAGE; the PANEL is the sheet.
74
+ `position: fixed` is written out rather than inherited: the UA gives a dialog
75
+ `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. */
79
+ position: fixed;
80
+ inset: 0;
81
+ width: 100%;
82
+ height: 100%;
83
+ max-width: none;
84
+ max-height: none;
85
+ margin: 0;
86
+ padding: 0;
87
+ border: 0;
88
+ background: none;
89
+ overflow: visible;
90
+ /* The stage is a flex box that seats the panel on the BOTTOM edge. It has to be written as a
91
+ display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
92
+ UA rule that hides it while closed — a sheet permanently visible on every page. */
93
+ display: none;
94
+ }
95
+ .sheet[open] {
96
+ display: flex;
97
+ align-items: flex-end;
98
+ justify-content: center;
99
+ }
100
+ /* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
101
+ display:none, and a property that had no previous computed value does not transition — the
102
+ "from" state has to be declared, or the sheet simply appears where it belongs with no travel at
103
+ all. That silent no-op is the class of failure the harness exists to catch. */
104
+ .sheet::backdrop {
105
+ background: rgba(0, 0, 0, 0.4);
106
+ -webkit-backdrop-filter: blur(3px);
107
+ backdrop-filter: blur(3px);
108
+ opacity: 1;
109
+ transition: opacity 0.3s var(--ease-out);
110
+ }
111
+ @starting-style {
112
+ .sheet[open]::backdrop { opacity: 0; }
113
+ }
114
+
115
+ .sheet-panel {
116
+ display: flex;
117
+ 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%;
122
+ max-height: min(92dvh, 100dvh - 24px);
123
+ /* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
124
+ background: color-mix(in srgb, var(--bg) 93%, transparent);
125
+ -webkit-backdrop-filter: blur(30px) saturate(1.5);
126
+ backdrop-filter: blur(30px) saturate(1.5);
127
+ border-top: 1px solid var(--border);
128
+ border-radius: 20px 20px 0 0;
129
+ /* 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. */
132
+ transform: translateY(0);
133
+ transition: transform 0.34s var(--ease-out);
134
+ will-change: transform;
135
+ }
136
+ @starting-style {
137
+ .sheet[open] .sheet-panel { transform: translateY(100%); }
138
+ }
139
+ /* The panel takes focus programmatically, so it must not wear a focus ring — it is a surface, not
140
+ a control. Focus a visitor MOVES lands on the rows, which do show one. */
141
+ .sheet-panel:focus { outline: none; }
142
+ /* While a finger is on it, the panel follows the finger with no transition — a 0.34s ease between
143
+ every pointermove is what makes a web sheet feel like a web page. */
144
+ .sheet[data-dragging] .sheet-panel { transition: none; }
145
+
146
+ .sheet-grip {
147
+ flex: none;
148
+ display: grid;
149
+ place-items: center;
150
+ height: 28px;
151
+ cursor: grab;
152
+ touch-action: none; /* the browser must not claim the vertical gesture we are reading */
153
+ }
154
+ .sheet-grip span {
155
+ width: 36px;
156
+ height: 5px;
157
+ border-radius: 2.5px;
158
+ background: color-mix(in srgb, var(--text) 22%, transparent);
159
+ }
160
+ .sheet:not([data-dragging]) .sheet-grip:active { cursor: grabbing; }
161
+
162
+ .sheet-title {
163
+ flex: none;
164
+ margin: 0;
165
+ padding: 2px 20px 10px;
166
+ font-size: var(--fs-md);
167
+ font-weight: 700;
168
+ letter-spacing: -0.01em;
169
+ color: var(--text);
170
+ text-align: center;
171
+ }
172
+
173
+ .sheet-body {
174
+ flex: 1 1 auto;
175
+ min-height: 0;
176
+ overflow-y: auto;
177
+ /* A scroll that reaches its end must not become the page's scroll behind the sheet. */
178
+ overscroll-behavior: contain;
179
+ -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); }
188
+ }
189
+
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
+ }
222
+
223
+ @media (prefers-reduced-motion: reduce) {
224
+ .sheet-panel, .sheet::backdrop { transition: none; }
225
+ .sheet-panel { transform: none; opacity: 1; }
226
+ }
227
+ </style>
228
+
229
+ <script>
230
+ // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
231
+ // an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
232
+ (function () {
233
+ const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
234
+ const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
235
+ const wired = new WeakSet<HTMLDialogElement>();
236
+
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
+ function wire(sheet: HTMLDialogElement) {
254
+ if (wired.has(sheet) || !sheet.showModal) return;
255
+ wired.add(sheet);
256
+ const panel = sheet.querySelector<HTMLElement>("[data-panel]");
257
+ const grip = sheet.querySelector<HTMLElement>("[data-grip]");
258
+ if (!panel) return;
259
+
260
+ // A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
261
+ sheet.addEventListener("click", (e) => { if (e.target === sheet) sheet.close(); });
262
+ sheet.addEventListener("close", () => {
263
+ sheet.removeAttribute("data-expanded");
264
+ panel.style.transform = "";
265
+ });
266
+
267
+ // ── drag: the grabber follows the finger, and the release decides ──
268
+ let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
269
+ grip?.addEventListener("pointerdown", (e: PointerEvent) => {
270
+ if (window.matchMedia("(min-width: 768px)").matches) return;
271
+ id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
272
+ grip.setPointerCapture(id);
273
+ sheet.setAttribute("data-dragging", "");
274
+ });
275
+ grip?.addEventListener("pointermove", (e: PointerEvent) => {
276
+ if (id !== e.pointerId) return;
277
+ dy = e.clientY - y0;
278
+ // Downward drag travels 1:1. Upward is RUBBER-BANDED rather than free: pulling up past the
279
+ // detent must feel like resistance, not like a second scroll surface.
280
+ panel.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy / 4}px)`;
281
+ });
282
+ const release = (e: PointerEvent) => {
283
+ if (id !== e.pointerId) return;
284
+ const dt = Math.max(1, e.timeStamp - t0);
285
+ const v = dy / dt;
286
+ sheet.removeAttribute("data-dragging");
287
+ panel.style.transform = "";
288
+ id = null;
289
+ if (dy > panel.offsetHeight * DRAG_CLOSE || v > FLICK) sheet.close();
290
+ // A decisive pull upwards promotes the medium detent to large — iOS's two-detent sheet.
291
+ else if (dy < -40) sheet.setAttribute("data-expanded", "");
292
+ };
293
+ grip?.addEventListener("pointerup", release);
294
+ grip?.addEventListener("pointercancel", release);
295
+ }
296
+
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
+ document.addEventListener("click", (e) => {
307
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
308
+ if (!t) return;
309
+ const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
310
+ if (!sheet) return;
311
+ 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);
317
+ });
318
+ })();
319
+ </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