@justai/cuts 0.28.0 → 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 +4 -2
- package/src/ConfirmSheet.astro +1 -1
- package/src/LangPicker.astro +64 -56
- package/src/Menu.astro +216 -0
- package/src/Sheet.astro +319 -0
- package/src/ground.css +23 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A persona's named parts
|
|
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": {
|
package/src/ConfirmSheet.astro
CHANGED
|
@@ -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:
|
|
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,
|
package/src/LangPicker.astro
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
31
|
-
|
|
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
|
-
</
|
|
68
|
+
</Sheet>
|
|
54
69
|
</div>
|
|
55
70
|
|
|
56
71
|
<style>
|
|
57
|
-
|
|
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
|
-
.
|
|
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%;
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
98
|
-
transition: background 0.15s;
|
|
108
|
+
transition: background 0.12s var(--ease-out);
|
|
99
109
|
}
|
|
100
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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>
|
package/src/Sheet.astro
ADDED
|
@@ -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
|
|
25
|
-
* travels with whichever of
|
|
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,15 +70,24 @@
|
|
|
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
|
|
|
68
|
-
/* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
77
|
+
/* The page's ground — margin reset, the fleet's type baseline, and the page colour ON :where(html),
|
|
78
|
+
* NEVER on body. Learned live, fleet-wide, within a day: personas paint their dot-grid on html and
|
|
79
|
+
* leave body's background UNDECLARED — relying on the web's default transparency structurally, not
|
|
80
|
+
* by declaration. Zero specificity beats a declaration that exists; it still fills one that never
|
|
81
|
+
* did. A body background from the ground, with the fleet's `body{height:100%}`, painted an opaque
|
|
82
|
+
* viewport-tall box over the top of every grid — dots gone at the top, reappearing where body ends.
|
|
83
|
+
* The founder saw it before any instrument did. The rule that survives: THE GROUND MAY ONLY SUPPLY
|
|
84
|
+
* WHAT A BARE PAGE LACKS ON ELEMENTS CONSUMERS DECLARE — html's background is declared by every
|
|
85
|
+
* persona (they win); body's transparency is load-bearing and stays untouched. */
|
|
86
|
+
:where(html) {
|
|
87
|
+
background: var(--bg);
|
|
88
|
+
}
|
|
71
89
|
:where(body) {
|
|
72
90
|
margin: 0;
|
|
73
|
-
background: var(--bg);
|
|
74
91
|
color: var(--text);
|
|
75
92
|
font: 1rem/1.5 var(--font);
|
|
76
93
|
}
|