@justai/cuts 0.133.0 → 0.138.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/src/Profile.astro CHANGED
@@ -99,40 +99,44 @@ const vars = [
99
99
  </section>
100
100
 
101
101
  <style>
102
- /* ---- PROFILE cut ---- */
103
- .profile { padding-block-end: 8px; }
104
- .cover {
105
- height: var(--cover-h, 132px);
106
- /* full-bleed to the persona's page edge (see --edge on Toolbar) */
107
- margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
108
- background: var(--cover, linear-gradient(135deg, var(--accent), transparent));
109
- position: relative;
102
+ /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
103
+ out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
104
+ @layer justai.components {
105
+ /* ---- PROFILE cut ---- */
106
+ .profile { padding-block-end: 8px; }
107
+ .cover {
108
+ height: var(--cover-h, 132px);
109
+ /* full-bleed to the persona's page edge (see --edge on Toolbar) */
110
+ margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
111
+ background: var(--cover, linear-gradient(135deg, var(--accent), transparent));
112
+ position: relative;
113
+ }
114
+ @media (min-width: 640px) { .cover { margin-inline: -28px; } }
115
+ .avatar {
116
+ width: 76px; height: 76px; border-radius: var(--radius-lg); margin-block-start: -40px;
117
+ display: grid; place-items: center; padding: var(--avatar-pad, 0);
118
+ background: var(--avatar-bg, #fff);
119
+ border: 3px solid var(--bg); box-shadow: 0 8px 22px -16px rgba(0, 0, 0, 0.22);
120
+ position: relative;
121
+ /* it is an <a> now — undo the link ground, or a slotted SVG drawn in currentColor turns accent */
122
+ color: inherit; text-decoration: none;
123
+ transition: transform 0.15s var(--ease-out, ease-out);
124
+ }
125
+ /* Pressed, not hovered. The tile is the persona's FACE: growing it under the cursor would animate the
126
+ one object on the page that should hold still. Depressing it on press is the iOS answer — it says
127
+ "this responds" at the moment of contact, on touch as well, where there is no hover to say it. */
128
+ .avatar:active { transform: scale(0.97); }
129
+ .avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
130
+ @media (prefers-reduced-motion: reduce) { .avatar { transition: none; } }
131
+ /* the avatar content is slotted (persona-owned) → style it globally */
132
+ .avatar :global(svg) { width: 100%; height: 100%; display: block; }
133
+ .avatar :global(img) { width: 100%; height: 100%; display: block; object-fit: cover; }
134
+ @media (prefers-color-scheme: light) {
135
+ :global(html:not([data-theme="dark"])) .avatar { border-color: #fff; background: var(--avatar-bg-light, var(--avatar-bg, #fff)); }
136
+ }
137
+ .pname { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -0.02em; margin-block-start: 12px; }
138
+ .handle { color: var(--text-dim); font-size: var(--fs-xs); margin-block-start: 2px; }
139
+ .bio { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 12px; }
140
+ .meta { color: var(--text-dim); font-size: var(--fs-2xs); margin-block-start: 10px; }
110
141
  }
111
- @media (min-width: 640px) { .cover { margin-inline: -28px; } }
112
- .avatar {
113
- width: 76px; height: 76px; border-radius: var(--radius-lg); margin-block-start: -40px;
114
- display: grid; place-items: center; padding: var(--avatar-pad, 0);
115
- background: var(--avatar-bg, #fff);
116
- border: 3px solid var(--bg); box-shadow: 0 8px 22px -16px rgba(0, 0, 0, 0.22);
117
- position: relative;
118
- /* it is an <a> now — undo the link ground, or a slotted SVG drawn in currentColor turns accent */
119
- color: inherit; text-decoration: none;
120
- transition: transform 0.15s var(--ease-out, ease-out);
121
- }
122
- /* Pressed, not hovered. The tile is the persona's FACE: growing it under the cursor would animate the
123
- one object on the page that should hold still. Depressing it on press is the iOS answer — it says
124
- "this responds" at the moment of contact, on touch as well, where there is no hover to say it. */
125
- .avatar:active { transform: scale(0.97); }
126
- .avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
127
- @media (prefers-reduced-motion: reduce) { .avatar { transition: none; } }
128
- /* the avatar content is slotted (persona-owned) → style it globally */
129
- .avatar :global(svg) { width: 100%; height: 100%; display: block; }
130
- .avatar :global(img) { width: 100%; height: 100%; display: block; object-fit: cover; }
131
- @media (prefers-color-scheme: light) {
132
- :global(html:not([data-theme="dark"])) .avatar { border-color: #fff; background: var(--avatar-bg-light, var(--avatar-bg, #fff)); }
133
- }
134
- .pname { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -0.02em; margin-block-start: 12px; }
135
- .handle { color: var(--text-dim); font-size: var(--fs-xs); margin-block-start: 2px; }
136
- .bio { font-size: var(--fs-sm); line-height: 1.5; margin-block-start: 12px; }
137
- .meta { color: var(--text-dim); font-size: var(--fs-2xs); margin-block-start: 10px; }
138
142
  </style>
@@ -105,120 +105,71 @@ const on = value && options.some((o) => o.value === value) ? value : options[0]?
105
105
  </div>
106
106
 
107
107
  <style>
108
- /* queries: segmented control styles · the chosen segment · ios segmented look
109
- ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
110
- which is what makes a segmented control read as "these are the options" rather than as a row of
111
- buttons where one happens to be coloured. */
112
- .jseg {
113
- /* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
114
- SIX pixels wider than its parent unless something has already said `border-box` — and on a
115
- bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
116
- homework takes when nobody notices: a control that fits everywhere it was tried. */
117
- box-sizing: border-box;
118
- display: inline-flex;
119
- align-items: stretch;
120
- gap: 2px;
121
- padding: 2px;
122
- border-radius: var(--radius-md);
123
- background: var(--surface-2);
124
- border: 1px solid var(--border);
125
- /* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
126
- this is what makes it true for the eye. */
127
- isolation: isolate;
128
- }
129
- /* queries: segmented control height · 44px touch target · the segments are too small
130
- TWO SIZES, AND THE DIFFERENCE IS WHAT THE CONTROL IS.
131
- `full` is a VIEW'S OWN SWITCH — a thumb's first target on the screen, so it meets the fleet's
132
- 44px like every row and every action does. `inline` is a TOOLBAR CHIP, sitting among other
133
- chrome at chrome's size; forcing 44 there would make a toolbar taller than its content, which
134
- is why iOS ships its own segmented controls shorter in exactly that position.
135
- Found by a consumer's spec measuring 33px where its page promised 44 — a number the component
136
- should have owned rather than each page discovering separately. */
137
- .jseg[data-width="full"] { display: flex; width: 100%; }
138
- .jseg[data-width="full"] .jseg-item { flex: 1; min-height: 44px; }
108
+ /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
109
+ out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
110
+ @layer justai.components {
111
+ /* queries: segmented control styles · the chosen segment · ios segmented look
112
+ ONE TRACK, THE CHOSEN ONE RAISED. The track is recessed and the selection sits on top of it —
113
+ which is what makes a segmented control read as "these are the options" rather than as a row of
114
+ buttons where one happens to be coloured. */
115
+ .jseg {
116
+ /* ITS OWN BOX MODEL, not the consumer's. `width: 100%` plus a padding and a border comes out
117
+ SIX pixels wider than its parent unless something has already said `border-box` — and on a
118
+ bare page nothing has. Inheriting a reset is homework (law 1), and this is the shape that
119
+ homework takes when nobody notices: a control that fits everywhere it was tried. */
120
+ box-sizing: border-box;
121
+ display: inline-flex;
122
+ align-items: stretch;
123
+ gap: 2px;
124
+ padding: 2px;
125
+ border-radius: var(--radius-md);
126
+ background: var(--surface-2);
127
+ border: 1px solid var(--border);
128
+ /* A control is one tab stop. The roving tabindex below is what makes that true for the keyboard;
129
+ this is what makes it true for the eye. */
130
+ isolation: isolate;
131
+ }
132
+ /* queries: segmented control height · 44px touch target · the segments are too small
133
+ TWO SIZES, AND THE DIFFERENCE IS WHAT THE CONTROL IS.
134
+ `full` is a VIEW'S OWN SWITCH — a thumb's first target on the screen, so it meets the fleet's
135
+ 44px like every row and every action does. `inline` is a TOOLBAR CHIP, sitting among other
136
+ chrome at chrome's size; forcing 44 there would make a toolbar taller than its content, which
137
+ is why iOS ships its own segmented controls shorter in exactly that position.
138
+ Found by a consumer's spec measuring 33px where its page promised 44 — a number the component
139
+ should have owned rather than each page discovering separately. */
140
+ .jseg[data-width="full"] { display: flex; width: 100%; }
141
+ .jseg[data-width="full"] .jseg-item { flex: 1; min-height: 44px; }
139
142
 
140
- .jseg-item {
141
- box-sizing: border-box;
142
- display: inline-flex; align-items: center; justify-content: center; gap: 6px;
143
- min-width: 0; padding: 7px 12px;
144
- border: 0; border-radius: calc(var(--radius-md) - 3px);
145
- background: transparent; color: var(--text-dim);
146
- font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
147
- cursor: pointer;
148
- transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
149
- }
150
- .jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
- .jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
143
+ .jseg-item {
144
+ box-sizing: border-box;
145
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
146
+ min-width: 0; padding: 7px 12px;
147
+ border: 0; border-radius: calc(var(--radius-md) - 3px);
148
+ background: transparent; color: var(--text-dim);
149
+ font: inherit; font-size: var(--fs-sm); font-weight: 500; line-height: 1.2;
150
+ cursor: pointer;
151
+ transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out);
152
+ }
153
+ .jseg-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
+ .jseg-item :global(svg) { width: 16px; height: 16px; flex: none; }
152
155
 
153
- /* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
154
- .jseg-item[aria-checked="true"] {
155
- background: var(--surface);
156
- color: var(--accent);
157
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
158
- }
159
- .jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
160
- .jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
156
+ /* THE CHOSEN ONE. `--surface` against a `--surface-2` track is the raise; the accent names it. */
157
+ .jseg-item[aria-checked="true"] {
158
+ background: var(--surface);
159
+ color: var(--accent);
160
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
161
+ }
162
+ .jseg-item[aria-disabled="true"] { opacity: 0.45; cursor: default; }
163
+ .jseg-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
161
164
 
162
- /* Motion is never forced on somebody who asked it off. */
163
- @media (prefers-reduced-motion: reduce) {
164
- .jseg-item { transition: none; }
165
+ /* Motion is never forced on somebody who asked it off. */
166
+ @media (prefers-reduced-motion: reduce) {
167
+ .jseg-item { transition: none; }
168
+ }
165
169
  }
166
170
  </style>
167
171
 
168
- <script>
169
- // queries: segmented control keyboard · arrow keys · roving tabindex · aria-checked · change event
170
- //
171
- // ROVING TABINDEX IS THE WHOLE OF THE KEYBOARD. A radiogroup is ONE tab stop: Tab reaches the
172
- // chosen option and the arrows move within the group. Leaving every option at `tabIndex 0` is the
173
- // common mistake and it is invisible to anyone using a mouse — a person on a keyboard suddenly has
174
- // to press Tab three times to get past a control they were not using.
175
- //
176
- // Lifted from qr, which had it right and had it alone.
177
- const wire = (root: HTMLElement) => {
178
- const items = () => Array.from(root.querySelectorAll<HTMLElement>('[role="radio"]'));
179
-
180
- const set = (el: HTMLElement, focus?: boolean, quiet?: boolean) => {
181
- if (el.getAttribute("aria-disabled") === "true") return;
182
- const next = el.dataset.value!;
183
- if (root.dataset.value === next) { if (focus) el.focus(); return; }
184
- for (const r of items()) {
185
- const chosen = r === el;
186
- r.setAttribute("aria-checked", String(chosen));
187
- r.tabIndex = chosen ? 0 : -1;
188
- }
189
- root.dataset.value = next;
190
- if (focus) el.focus();
191
- // Setting `.value` from code does NOT re-announce — a caller that just said what it wants does
192
- // not need to be told, and a listener that reacts by setting the value would loop.
193
- if (!quiet) root.dispatchEvent(new CustomEvent("change", { detail: { value: next }, bubbles: true }));
194
- };
195
-
196
- for (const el of items()) el.addEventListener("click", () => set(el, true));
197
-
198
- root.addEventListener("keydown", (e) => {
199
- const key = (e as KeyboardEvent).key;
200
- const all = items().filter((r) => r.getAttribute("aria-disabled") !== "true");
201
- const at = all.findIndex((r) => r.getAttribute("aria-checked") === "true");
202
- let to = -1;
203
- if (key === "ArrowRight" || key === "ArrowDown") to = (at + 1) % all.length;
204
- else if (key === "ArrowLeft" || key === "ArrowUp") to = (at - 1 + all.length) % all.length;
205
- else if (key === "Home") to = 0;
206
- else if (key === "End") to = all.length - 1;
207
- if (to < 0 || !all[to]) return;
208
- e.preventDefault();
209
- set(all[to], true);
210
- });
211
-
212
- // The one thing a caller needs beyond listening: saying what it wants.
213
- Object.defineProperty(root, "value", {
214
- get: () => root.dataset.value,
215
- set: (v: string) => {
216
- const el = items().find((r) => r.dataset.value === v);
217
- if (el) set(el, false, true);
218
- },
219
- configurable: true,
220
- });
221
- };
222
-
223
- for (const root of document.querySelectorAll<HTMLElement>(".jseg")) wire(root);
224
- </script>
172
+ <script>
173
+ // The behaviour lives in ./behaviour/segmented.ts — see that file for why it is not in here.
174
+ import "./behaviour/segmented";
175
+ </script>