@akonwi/mica 0.8.0 → 0.10.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/README.md CHANGED
@@ -65,6 +65,11 @@ Every component states where its behavior comes from
65
65
  `select.js`, `toast.js`. Each is a tiny standalone module that enhances
66
66
  working markup — never renders it. There is no shared runtime.
67
67
 
68
+ `m-avatar` accepts authored initials or a direct native `img`, both CSS-only.
69
+ `avatar.js` is a separate opt-in visual enhancement: importing it locally
70
+ derives deterministic color and features from the `blobatar` seed without a
71
+ network request.
72
+
68
73
  ## Your CSS always wins
69
74
 
70
75
  All of mica ships inside `@layer mica.*`. Unlayered user CSS beats layered
@@ -0,0 +1,28 @@
1
+ # Third-party licenses
2
+
3
+ ## Blobatar 2.4.0
4
+
5
+ Mica's optional `avatar.js` module incorporates code from
6
+ [Blobatar](https://github.com/Alain00/blobatar).
7
+
8
+ MIT License
9
+
10
+ Copyright (c) 2026 Alain
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
package/avatar.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /* Type declarations for mica/avatar.js — side-effect module.
2
+ * Registers <m-avatar> and locally generates Blobatar-derived paint for its
3
+ * `blobatar` seed. Authored initials remain the no-module fallback.
4
+ */
5
+ declare global {
6
+ interface HTMLElementTagNameMap {
7
+ "m-avatar": HTMLElement;
8
+ }
9
+ }
10
+ export {};
package/avatar.js ADDED
@@ -0,0 +1,301 @@
1
+ /*! mica/avatar.js — generated by tools/build-avatar.ts.
2
+ * Includes Blobatar 2.4.0 (MIT); see THIRD_PARTY_LICENSES.md. */
3
+ // node_modules/blobatar/dist/index.js
4
+ function L({ l: t, c: n, h: e }) {
5
+ let o = e * Math.PI / 180, a = n * Math.cos(o), r = n * Math.sin(o), s = t + 0.3963377774 * a + 0.2158037573 * r, c = t - 0.1055613458 * a - 0.0638541728 * r, i = t - 0.0894841775 * a - 1.291485548 * r, m = s * s * s, l = c * c * c, u = i * i * i;
6
+ return [4.0767416621 * m - 3.3077115913 * l + 0.2309699292 * u, -1.2684380046 * m + 2.6097574011 * l - 0.3413193965 * u, -0.0041960863 * m - 0.7034186147 * l + 1.707614701 * u];
7
+ }
8
+ var q = (t) => t.every((n) => n >= -0.0001 && n <= 1.0001);
9
+ function K(t) {
10
+ let n = L(t);
11
+ if (!q(n)) {
12
+ let e = 0, o = t.c;
13
+ for (let a = 0;a < 12; a++) {
14
+ let r = (e + o) / 2;
15
+ if (q(L({ ...t, c: r })))
16
+ e = r;
17
+ else
18
+ o = r;
19
+ }
20
+ n = L({ ...t, c: e });
21
+ }
22
+ return n.map((e) => Math.min(1, Math.max(0, e)));
23
+ }
24
+ function D(t) {
25
+ let [n, e, o] = K(t);
26
+ return 0.2126 * n + 0.7152 * e + 0.0722 * o;
27
+ }
28
+ function k(t, n) {
29
+ let e = D(t), o = D(n);
30
+ return (Math.max(e, o) + 0.05) / (Math.min(e, o) + 0.05);
31
+ }
32
+ function B(t, n, e) {
33
+ if (k(t, n) >= e)
34
+ return t;
35
+ let o = t.l >= n.l ? 1 : -1;
36
+ for (let s of [o, -o]) {
37
+ let c = { ...t };
38
+ for (let i = 0;i < 60; i++) {
39
+ if (c.l = Math.min(1, Math.max(0, c.l + s * 0.02)), k(c, n) >= e)
40
+ return c;
41
+ if (c.l === 0 || c.l === 1)
42
+ break;
43
+ }
44
+ }
45
+ let a = { ...t, l: 0, c: 0 }, r = { ...t, l: 1, c: 0 };
46
+ return k(a, n) >= k(r, n) ? a : r;
47
+ }
48
+ function P(t) {
49
+ return "#" + K(t).map((n) => {
50
+ let e = n <= 0.0031308 ? 12.92 * n : 1.055 * Math.pow(n, 0.4166666666666667) - 0.055;
51
+ return Math.round(e * 255).toString(16).padStart(2, "0");
52
+ }).join("");
53
+ }
54
+ var Z = [[0.2, { l: 0.86, c: 0.085 }], [0.36, { l: 0.9, c: 0.028 }], [0.62, { l: 0.73, c: 0.135 }], [0.8, { l: 0.62, c: 0.165 }], [0.93, { l: 0.87, c: 0.16 }], [1, { l: 0.34, c: 0.035 }]];
55
+ var Et = (t) => Z.find(([n]) => t < n)?.[1] ?? Z[0][1];
56
+ var U = { l: 0.145, c: 0, h: 0 };
57
+ var Q = 1.5;
58
+ var Lt = (t, n) => {
59
+ let e = Et(n), o = B({ l: e.l, c: e.c, h: t }, U, Q);
60
+ return { bg: { l: 0.965, c: 0.01, h: t }, head: o, eye: o.l >= 0.5 ? { l: 0.17, c: 0.02, h: t } : { l: 0.97, c: 0.012, h: t } };
61
+ };
62
+ var X = [["head", "bg", 1.25], ["eye", "head", 4.5]];
63
+ function Y(t, n = true, e = 0) {
64
+ let o = Lt(t, e);
65
+ if (n)
66
+ for (let [a, r, s] of X)
67
+ o[a] = B(o[a], o[r], s);
68
+ return o;
69
+ }
70
+ function w(t, n = true, e = 0) {
71
+ let o = Y(t, n, e), a = {};
72
+ for (let r in o)
73
+ a[r] = P(o[r]);
74
+ return a;
75
+ }
76
+ var y = (t) => {
77
+ let n = Math.round(t * 100) / 100;
78
+ return Object.is(n, -0) ? "0" : String(n);
79
+ };
80
+ function T({ cx: t, cy: n, rx: e, ry: o, n: a = 4, rot: r = 0 }) {
81
+ let s = Math.min(1, (8 * Math.pow(2, -1 / a) - 4) / 3), c = e, i = o, m = c * s, l = i * s, u = [[c, 0], [c, l], [m, i], [0, i], [-m, i], [-c, l], [-c, 0], [-c, -l], [-m, -i], [0, -i], [m, -i], [c, -l], [c, 0]], x = r * Math.PI / 180, p = Math.cos(x), d = Math.sin(x), h = (b) => {
82
+ let [g, M] = u[b];
83
+ return `${y(t + g * p - M * d)} ${y(n + g * d + M * p)}`;
84
+ }, f = `M${h(0)}`;
85
+ for (let b = 1;b < 13; b += 3)
86
+ f += `C${h(b)} ${h(b + 1)} ${h(b + 2)}`;
87
+ return f + "Z";
88
+ }
89
+ function G(t, n, e, o, a, r = 0) {
90
+ let s = a.length, c = r * Math.PI / 180, i = a.map((u, x) => {
91
+ let p = c + 2 * Math.PI * x / s;
92
+ return [t + e * u * Math.cos(p), n + o * u * Math.sin(p)];
93
+ }), m = (u) => i[(u % s + s) % s], l = `M${y(m(0)[0])} ${y(m(0)[1])}`;
94
+ for (let u = 0;u < s; u++) {
95
+ let [x, p] = m(u - 1), [d, h] = m(u), [f, b] = m(u + 1), [g, M] = m(u + 2);
96
+ l += `C${y(d + (f - x) / 6)} ${y(h + (b - p) / 6)} ${y(f - (g - d) / 6)} ${y(b - (M - h) / 6)} ${y(f)} ${y(b)}`;
97
+ }
98
+ return l + "Z";
99
+ }
100
+ function J({ cx: t, cy: n, rx: e, ry: o, sides: a, round: r = 0.3, rot: s = 0 }) {
101
+ let c = r > 0 ? r < 1 ? r / 2 : 0.5 : 0, i = s * Math.PI / 180 - Math.PI / 2, m = Array.from({ length: a }, (p, d) => {
102
+ let h = i + 2 * Math.PI * d / a;
103
+ return [t + e * Math.cos(h), n + o * Math.sin(h)];
104
+ }), l = (p) => m[(p % a + a) % a], u = (p, d) => {
105
+ let [h, f] = l(p), [b, g] = l(d);
106
+ return `${y(h + (b - h) * c)} ${y(f + (g - f) * c)}`;
107
+ }, x = `M${u(0, -1)}`;
108
+ for (let p = 0;p < a; p++) {
109
+ let [d, h] = l(p);
110
+ if (x += `Q${y(d)} ${y(h)} ${u(p, p + 1)}`, c < 0.5)
111
+ x += `L${u(p + 1, p)}`;
112
+ }
113
+ return x + "Z";
114
+ }
115
+ function W(t, n, e, o) {
116
+ let a = y(t - e), r = y(t + e);
117
+ return `M${a} ${y(n - o)}H${r}V${y(n + o)}H${a}Z`;
118
+ }
119
+ function tt(t, n, e, o, a) {
120
+ let r = Math.max(1.05, a), s = e * Math.sqrt(1 - 1 / (r * r)), c = n - o / r, i = n - r * o, m = s * 0.14, l = c + 0.86 * (i - c);
121
+ return `M${y(t - s)} ${y(c)}L${y(t - m)} ${y(l)}Q${y(t)} ${y(i)} ${y(t + m)} ${y(l)}L${y(t + s)} ${y(c)}Z`;
122
+ }
123
+ function I(t, n) {
124
+ for (let e = 0;e < n.length; e++)
125
+ t = Math.imul(t ^ n[e], 3432918353), t = t << 13 | t >>> 19;
126
+ return t;
127
+ }
128
+ function wt(t) {
129
+ return t = Math.imul(t ^ t >>> 16, 2246822507), t = Math.imul(t ^ t >>> 13, 3266489909), (t ^ t >>> 16) >>> 0;
130
+ }
131
+ var nt = new TextEncoder;
132
+ function et(t) {
133
+ return t.normalize("NFC").trim().toLowerCase();
134
+ }
135
+ function rt(t, n = true) {
136
+ let e = n ? et(t) : t;
137
+ return I(1779033703 ^ e.length, nt.encode(e));
138
+ }
139
+ function v(t, n) {
140
+ return wt(I(I(t, Uint8Array.of(255)), nt.encode(n))) / 4294967296;
141
+ }
142
+ function A(t, n = true, e) {
143
+ let o = rt(t, n), a = (r) => {
144
+ let s = e?.[r], c = Array.isArray(s) ? s[Math.floor(v(o, r) * s.length)] : s;
145
+ return c === undefined ? v(o, r) : c > 0 ? c < 1 ? c : 0.999999 : 0;
146
+ };
147
+ return a.num = (r, s, c) => s + a(r) * (c - s), a.int = (r, s, c) => s + Math.floor(a(r) * (c - s + 1)), a.pick = (r, s) => s[Math.floor(a(r) * s.length)], a.bool = (r, s = 0.5) => a(r) < s, a.jitter = (r, s) => (a(r) * 2 - 1) * s, a;
148
+ }
149
+ function ot(t, n, e) {
150
+ let o = n.expression;
151
+ if (e || !o)
152
+ return { l: t, wrap: "" };
153
+ return o.bake(t, o.p);
154
+ }
155
+ var It = (t, n) => n?.tint ? n.tint(t, n.p) : t;
156
+ var at = (t, n) => n ? `<g transform="${n}">${t}</g>` : t;
157
+ var vt = (t) => t.replace(/[&<>]/g, (n) => n === "&" ? "&amp;" : n === "<" ? "&lt;" : "&gt;");
158
+ function S(t, n) {
159
+ let e = A(t, n.normalize ?? true, n.traits);
160
+ return { t: e, palette: { ...w(n.hue ?? e.num("hue", 0, 360), n.contrast ?? true, n.tone ?? e("tone")), ...n.palette } };
161
+ }
162
+ var At = (t) => t.title ? `<title>${vt(t.title)}</title>` : "";
163
+ function st(t, n, e) {
164
+ let o = n.background ?? t.background;
165
+ if (o === false)
166
+ return;
167
+ return { d: o === "square" ? "M0 0H100V100H0Z" : T({ cx: 50, cy: 50, rx: 50, ry: 50, n: o === "circle" ? 2 : 6 }), fill: e.bg };
168
+ }
169
+ var Rt = (t) => t ? `<path d="${t.d}" fill="${t.fill}"/>` : "";
170
+ function ct(t) {
171
+ return (n, e = {}) => {
172
+ let { t: o, palette: a } = S(n, e), r = It(a, e.expression), s = e.size ? ` width="${e.size}" height="${e.size}"` : "", c = ot(t.layout(o), e), i = At(e) + Rt(st(t, e, r)) + at(t.render(c.l, r), c.wrap);
173
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"${s}>${i}</svg>`;
174
+ };
175
+ }
176
+ var ut = (t, n, e) => {
177
+ let o = n.rx, a = t.num("eye.rx", 0.075, 0.105) * o, r = t.num("eye.ratio", 1.9, 3.2), s = t.num("eye.scale", 0.78, 1.24), c = t.num("eye.stretch", 0.85, 1.18), i = t.num("eye.gap", 0.1, 0.24) * o, m = a * Math.max(1, s), l = a * r * Math.max(1, s * c), u = m + o * 0.03 + i, x = t.jitter("gaze.x", 0.09) * e.rx, p = t.num("gaze.y", -0.2, 0.08) * e.ry, d = t.jitter("eye.dy", 0.04) * e.ry, h = Math.hypot(m, l), f = Math.hypot((Math.abs(x) + u + h) / e.rx, (Math.abs(p) + Math.abs(d) + h) / e.ry), b = f > 0.9 ? 0.9 / f : 1, g = a * b, M = g * r, F = u * b, Pt = Math.max(0, Math.min(1, i / l)), Tt = Math.min(12, Math.asin(Pt) * 180 / Math.PI), j = t.num("eye.lean", -1, 1) * Tt, Bt = Math.max(-12, Math.min(12, j + t.jitter("eye.lean2", 3.5))), z = e.cx + x * b, C = e.cy + p * b;
178
+ return [{ cx: z - F, cy: C, rx: g, ry: M, n: t.num("eye.n", 3.5, 6), rot: j }, { cx: z + F, cy: C + d * b, rx: g * s, ry: M * s * c, n: t.num("eye.n", 3.5, 6), rot: Bt }];
179
+ };
180
+ function mt(t, n) {
181
+ let e = (r) => (t.find(([, s]) => r < s) ?? t[t.length - 1])[0];
182
+ function o(r) {
183
+ let s = e(r("shape")), c = r.num("body.r", 31, 38) * s.core, i = { cx: 50 + r.jitter("body.x", 1.5), cy: 50 + r.jitter("body.y", 1.5), rx: c, ry: c * r.num("body.ratio", 0.92, 1.08), n: r.num("body.n", 1.9, 2.5), rot: 0, radii: Array.from({ length: r.int("body.pts", 6, 8) }, (u, x) => 1 + r.jitter(`body.r${x}`, 0.16)) };
184
+ s.body?.(r, i);
185
+ let m = s.face?.(i) ?? i, l = { petals: [], extra: [] };
186
+ return s.decorate?.(r, i, l), { shape: s.name, draw: s.path, body: i, face: m, petals: l.petals, extra: l.extra, eyes: n(r, i, m) };
187
+ }
188
+ function a(r, s, c) {
189
+ let i = (u) => Math.round(u * 100) / 100, m = (u, x) => {
190
+ let p = `<path d="${T(u)}"/>`;
191
+ return c ? `<g class="mo-eye" style="--mo-wrap:${x ? 1 : -1};--mo-lean:${i(u.rot)};transform-origin:${i(u.cx)}px ${i(u.cy)}px">${p}</g>` : p;
192
+ }, l = `<g fill="${s.head}">` + r.petals.map((u) => `<circle cx="${i(u.cx)}" cy="${i(u.cy)}" r="${i(u.r)}"/>`).join("") + r.extra.map((u) => `<path d="${u}"/>`).join("") + `<path d="${r.draw ? r.draw(r.body) : T(r.body)}"/></g><g fill="${s.eye}"${c ? ' class="mo-eyes"' : ""}>` + r.eyes.map(m).join("") + "</g>";
193
+ return c ? `<g class="mo-breathe"><g class="mo-bob">${l}</g></g>` : l;
194
+ }
195
+ return { layout: o, render: a, background: false };
196
+ }
197
+ var lt = (t) => J(t);
198
+ var pt = (t) => G(t.cx, t.cy, t.rx, t.ry, t.radii, t.rot);
199
+ var R = (t) => (n) => ({ cx: n.cx, cy: n.cy, rx: n.rx * t, ry: n.ry * t });
200
+ var yt = (t) => R(Math.min(...t.radii) * 0.95)(t);
201
+ var Ft = (t) => R(0.84)(t);
202
+ var xt = { name: "round", core: 1 };
203
+ var bt = { name: "organic", core: 0.98, path: pt, face: yt };
204
+ var ht = { name: "boxy", core: 0.86, body: (t, n) => {
205
+ n.n = t.num("body.n", 3.4, 6), n.rot = t.num("body.rot", -20, 20);
206
+ } };
207
+ var dt = { name: "capsule", core: 1.02, body: (t, n) => {
208
+ n.ry *= t.num("capsule.squat", 0.55, 0.68);
209
+ }, face: R(0.94), decorate: (t, n, e) => {
210
+ for (let o of [-1, 1])
211
+ e.petals.push({ cx: n.cx + o * (n.rx - n.ry), cy: n.cy, r: n.ry });
212
+ }, path: (t) => W(t.cx, t.cy, t.rx - t.ry, t.ry) };
213
+ var ft = { name: "nub", core: 0.88, decorate: (t, n, e) => {
214
+ let o = t.int("nub.n", 1, 2);
215
+ for (let a = 0;a < o; a++) {
216
+ let r = t.num(`nub.a${a}`, 0, 2 * Math.PI);
217
+ e.petals.push({ cx: n.cx + Math.cos(r) * n.rx * 0.88, cy: n.cy + Math.sin(r) * n.rx * 0.88, r: n.rx * t.num(`nub.r${a}`, 0.24, 0.4) });
218
+ }
219
+ } };
220
+ var gt = { name: "cloud", core: 0.78, face: yt, path: pt, decorate: (t, n, e) => {
221
+ let o = t.int("cloud.n", 4, 6);
222
+ for (let a = 0;a < o; a++) {
223
+ let r = Math.PI + Math.PI * (a + 0.5) / o;
224
+ e.petals.push({ cx: n.cx + Math.cos(r) * n.rx * 0.8, cy: n.cy + Math.sin(r) * n.rx * 0.5, r: n.rx * t.num(`cloud.r${a}`, 0.44, 0.62) });
225
+ }
226
+ } };
227
+ var Mt = { name: "droplet", core: 0.78, body: (t, n) => {
228
+ n.cy += 0.22 * n.ry, n.n = 2;
229
+ }, face: (t) => ({ cx: t.cx, cy: t.cy + t.ry * 0.05, rx: t.rx * 0.88, ry: t.ry * 0.88 }), decorate: (t, n, e) => {
230
+ e.extra.push(tt(n.cx, n.cy, n.rx, n.ry, t.num("droplet.tip", 1.4, 1.65)));
231
+ } };
232
+ var $t = { name: "hexagon", core: 1.05, path: lt, face: Ft, body: (t, n) => {
233
+ n.sides = 6, n.rot = t.num("body.rot", -12, 12), n.round = t.num("poly.round", 0.24, 0.5);
234
+ } };
235
+ var kt = { name: "sun", core: 0.7, decorate: (t, n, e) => {
236
+ let o = t.int("sun.n", 6, 9), a = n.rx * t.num("sun.dist", 1, 1.08), r = n.rx * t.num("sun.r", 0.2, 0.26), s = t.num("sun.rot", 0, 2 * Math.PI);
237
+ for (let c = 0;c < o; c++) {
238
+ let i = s + 2 * Math.PI * c / o;
239
+ e.petals.push({ cx: n.cx + Math.cos(i) * a, cy: n.cy + Math.sin(i) * a, r });
240
+ }
241
+ } };
242
+ var Ot = { name: "triangle", core: 1.15, path: lt, body: (t, n) => {
243
+ n.sides = 3, n.rot = t.num("body.rot", -5, 5), n.round = t.num("poly.round", 0.24, 0.5);
244
+ }, face: (t) => ({ cx: t.cx, cy: t.cy + t.ry * 0.1, rx: t.rx * 0.54, ry: t.ry * 0.36 }) };
245
+ var jt = [[xt, 0.22], [bt, 0.48], [ht, 0.6], [dt, 0.7], [ft, 0.79], [gt, 0.86], [Mt, 0.915], [$t, 0.95], [kt, 0.98], [Ot, 1]];
246
+ var E = mt(jt, ut);
247
+ var zt = ct(E);
248
+
249
+ // tools/avatar.js
250
+ var PROPERTIES = [
251
+ "--m-avatar-content-color",
252
+ "--m-avatar-body-color",
253
+ "--m-avatar-eye-color",
254
+ "--m-avatar-body-inset-block",
255
+ "--m-avatar-body-inset-inline",
256
+ "--m-avatar-body-x",
257
+ "--m-avatar-body-y",
258
+ "--m-avatar-eye-top",
259
+ "--m-avatar-eye-left",
260
+ "--m-avatar-eye-width",
261
+ "--m-avatar-eye-height",
262
+ "--m-avatar-eye-gap",
263
+ "--m-avatar-eye-drop"
264
+ ];
265
+ var percent = (value) => `${Math.round(value * 100) / 100}%`;
266
+
267
+ class MAvatar extends HTMLElement {
268
+ static observedAttributes = ["blobatar"];
269
+ connectedCallback() {
270
+ this.#update();
271
+ }
272
+ attributeChangedCallback() {
273
+ if (this.isConnected)
274
+ this.#update();
275
+ }
276
+ #update() {
277
+ const seed = this.getAttribute("blobatar");
278
+ if (seed === null) {
279
+ for (const property of PROPERTIES)
280
+ this.style.removeProperty(property);
281
+ return;
282
+ }
283
+ const t = A(seed);
284
+ const colors = w(t.num("hue", 0, 360), true, t("tone"));
285
+ this.style.setProperty("--m-avatar-content-color", "transparent");
286
+ this.style.setProperty("--m-avatar-body-color", colors.head);
287
+ this.style.setProperty("--m-avatar-eye-color", colors.eye);
288
+ this.style.setProperty("--m-avatar-body-inset-block", percent(t.num("mica.body.block", 11, 16)));
289
+ this.style.setProperty("--m-avatar-body-inset-inline", percent(t.num("mica.body.inline", 10, 16)));
290
+ this.style.setProperty("--m-avatar-body-x", percent(t.jitter("mica.body.x", 2)));
291
+ this.style.setProperty("--m-avatar-body-y", percent(t.jitter("mica.body.y", 2)));
292
+ this.style.setProperty("--m-avatar-eye-top", percent(t.num("mica.eye.top", 42, 49)));
293
+ this.style.setProperty("--m-avatar-eye-left", percent(t.num("mica.eye.left", 36, 40)));
294
+ this.style.setProperty("--m-avatar-eye-width", percent(t.num("mica.eye.width", 2.5, 3.5)));
295
+ this.style.setProperty("--m-avatar-eye-height", percent(t.num("mica.eye.height", 7.5, 10)));
296
+ this.style.setProperty("--m-avatar-eye-gap", percent(t.num("mica.eye.gap", 19, 23)));
297
+ this.style.setProperty("--m-avatar-eye-drop", percent(t.jitter("mica.eye.drop", 2)));
298
+ }
299
+ }
300
+ if (!customElements.get("m-avatar"))
301
+ customElements.define("m-avatar", MAvatar);
package/drawer.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /* Type declarations for mica/drawer.js — side-effect module.
2
- * Swipe-to-dismiss drag physics for dialog[data-drawer] bottom sheets.
3
- * No exports; sets :root[data-drawer-gestures] when active.
2
+ * Swipe-to-dismiss drag physics and a managed overlay scrim for
3
+ * dialog[data-drawer] bottom sheets. No exports; sets
4
+ * :root[data-drawer-gestures] when active.
4
5
  */
5
6
  export {};
package/drawer.js CHANGED
@@ -1,12 +1,14 @@
1
- /* mica/drawer.js — JS-enhanced: swipe-to-dismiss for dialog[data-drawer]
2
- * bottom sheets on small screens.
1
+ /* mica/drawer.js — JS-enhanced: swipe-to-dismiss and a managed scrim for
2
+ * dialog[data-drawer] bottom sheets on small screens.
3
3
  *
4
4
  * Enhances working markup, never replaces it: without this module the
5
5
  * drawer opens/closes via buttons and Esc; with it, the mobile sheet
6
6
  * grows a grab handle (CSS keys off :root[data-drawer-gestures] — an
7
7
  * affordance may only appear when the behavior exists) and vaul-style
8
8
  * drag physics: the sheet follows a downward drag, resists upward,
9
- * and dismisses past 1/3 of its height or on a flick.
9
+ * and dismisses past 1/3 of its height or on a flick. A fixed visual
10
+ * overlay replaces the CSS-only scrim so its dimming can follow the drag
11
+ * and fade on iOS, where ::backdrop disappears immediately on close.
10
12
  *
11
13
  * Drag surface: the handle / header strip. Drag-from-content is
12
14
  * deliberately out of scope — it requires scroll-vs-drag heuristics
@@ -18,7 +20,47 @@ const CLOSE_DISTANCE = 1 / 3; // of sheet height
18
20
  const CLOSE_VELOCITY = 0.6; // px/ms, downward flick
19
21
  const DRAWER = "dialog[data-drawer]";
20
22
 
23
+ let overlay;
24
+ const dismissCleanup = new WeakMap();
25
+
26
+ const overlayElement = () => {
27
+ if (overlay) return overlay;
28
+ overlay = document.createElement("div");
29
+ overlay.setAttribute("data-mica-drawer-overlay", "");
30
+ overlay.setAttribute("aria-hidden", "true");
31
+ overlay.inert = true;
32
+ overlay.style.setProperty("--m-drawer-overlay-opacity", "0");
33
+ document.body.append(overlay);
34
+ // Commit the transparent state before the first open transition.
35
+ overlay.getBoundingClientRect();
36
+ return overlay;
37
+ };
38
+
39
+ const setOverlayOpacity = (value) => {
40
+ if (!overlay && value === 0) return;
41
+ overlayElement().style.setProperty(
42
+ "--m-drawer-overlay-opacity",
43
+ String(Math.max(0, Math.min(1, value))),
44
+ );
45
+ };
46
+
47
+ const syncOverlay = () => {
48
+ const openDrawer = document.querySelector(`${DRAWER}[open]`);
49
+ // iOS can drop the closed dialog before its translate transition runs,
50
+ // so transitionend never clears the drag-dismiss inline style. Clear a
51
+ // pending exit synchronously on reopen, before the next frame paints.
52
+ if (openDrawer) dismissCleanup.get(openDrawer)?.();
53
+ setOverlayOpacity(openDrawer ? 1 : 0);
54
+ };
55
+
56
+ new MutationObserver(syncOverlay).observe(document.documentElement, {
57
+ attributes: true,
58
+ attributeFilter: ["open"],
59
+ subtree: true,
60
+ });
61
+
21
62
  document.documentElement.setAttribute("data-drawer-gestures", "");
63
+ syncOverlay();
22
64
 
23
65
  document.addEventListener("pointerdown", (down) => {
24
66
  if (!SHEET.matches) return;
@@ -40,7 +82,10 @@ document.addEventListener("pointerdown", (down) => {
40
82
 
41
83
  dialog.setPointerCapture(down.pointerId);
42
84
  const baseTransition = dialog.style.transition;
85
+ const scrim = overlayElement();
86
+ const baseOverlayTransition = scrim.style.transition;
43
87
  dialog.style.transition = "none";
88
+ scrim.style.transition = "none";
44
89
 
45
90
  const move = (e) => {
46
91
  dy = e.clientY - down.clientY;
@@ -49,6 +94,9 @@ document.addEventListener("pointerdown", (down) => {
49
94
  lastT = e.timeStamp;
50
95
  // follow downward; resist upward
51
96
  dialog.style.translate = `0 ${dy > 0 ? dy : dy / 4}px`;
97
+ // Clear by half a sheet: a one-to-one distance mapping was too subtle
98
+ // against dark surfaces (a quarter pull only moved alpha .4 → .3).
99
+ setOverlayOpacity(1 - Math.max(0, dy) / (rect.height / 2));
52
100
  };
53
101
 
54
102
  const up = () => {
@@ -56,22 +104,38 @@ document.addEventListener("pointerdown", (down) => {
56
104
  dialog.removeEventListener("pointerup", up);
57
105
  dialog.removeEventListener("pointercancel", up);
58
106
  dialog.style.transition = baseTransition;
107
+ scrim.style.transition = baseOverlayTransition;
59
108
  if (dy > rect.height * CLOSE_DISTANCE || velocity > CLOSE_VELOCITY) {
60
109
  // continue the motion DOWNWARD off-screen. close() fires its exit
61
- // (backdrop fade + display flip at the end via allow-discrete);
110
+ // (scrim fade + display flip at the end via allow-discrete);
62
111
  // the inline translate overrides the small CSS exit offset so the
63
112
  // sheet keeps sliding down instead of snapping back up into view
64
113
  // (the flash). Clear it only at transitionend — display:none by
65
- // then, so clearing is invisible and the next open starts clean.
66
- dialog.addEventListener(
67
- "transitionend",
68
- (e) => { if (e.propertyName === "translate") dialog.style.translate = ""; },
69
- { once: true },
70
- );
114
+ // then, so clearing is invisible. iOS may never fire that event,
115
+ // so a timeout clears it while closed and syncOverlay clears it if
116
+ // the drawer reopens first.
117
+ let cleanupTimer;
118
+ const onTransitionEnd = (e) => {
119
+ if (e.propertyName === "translate") clearDismiss();
120
+ };
121
+ const clearDismiss = () => {
122
+ if (dismissCleanup.get(dialog) !== clearDismiss) return;
123
+ clearTimeout(cleanupTimer);
124
+ dialog.removeEventListener("transitionend", onTransitionEnd);
125
+ dialog.style.translate = "";
126
+ dismissCleanup.delete(dialog);
127
+ };
128
+ dismissCleanup.set(dialog, clearDismiss);
129
+ dialog.addEventListener("transitionend", onTransitionEnd);
130
+ cleanupTimer = setTimeout(() => {
131
+ if (!dialog.open) clearDismiss();
132
+ }, 200);
133
+ setOverlayOpacity(0);
71
134
  requestAnimationFrame(() => { dialog.style.translate = "0 100dvh"; });
72
135
  dialog.close();
73
136
  } else {
74
137
  dialog.style.translate = ""; // spring back
138
+ setOverlayOpacity(1);
75
139
  }
76
140
  };
77
141
 
package/mica.css CHANGED
@@ -771,6 +771,161 @@
771
771
  --m-badge-numerals: tabular-nums;
772
772
  }
773
773
 
774
+ /* --- avatar: authored initials, optionally Blobatar-painted -------- */
775
+ :where(m-avatar) {
776
+ display: inline-grid;
777
+ place-items: center;
778
+ position: relative;
779
+ flex: none;
780
+ box-sizing: border-box;
781
+ inline-size: var(--avatar-size, 3rem);
782
+ block-size: var(--avatar-size, 3rem);
783
+ overflow: hidden;
784
+ border-radius: var(--radius-md);
785
+ background: var(--color-surface-raised);
786
+ color: var(--m-avatar-content-color, var(--color-text));
787
+ font-size: calc(var(--avatar-size, 3rem) * 0.5);
788
+ font-weight: 600;
789
+ line-height: 1;
790
+ text-transform: uppercase;
791
+ vertical-align: middle;
792
+ white-space: nowrap;
793
+ user-select: none;
794
+ }
795
+
796
+ :where(m-avatar > img) {
797
+ position: absolute;
798
+ inset: 0;
799
+ z-index: 1;
800
+ inline-size: 100%;
801
+ block-size: 100%;
802
+ object-fit: cover;
803
+ }
804
+
805
+ /* Attribute selectors only transport values. The pseudo rules below
806
+ render them, so missing avatar.js leaves authored initials untouched. */
807
+ :where(m-avatar[blobatar]) {
808
+ --m-avatar-decoration: "";
809
+ --m-avatar-render-inset-block: 0;
810
+ --m-avatar-render-inset-inline: 0;
811
+ --m-avatar-render-x: 0;
812
+ --m-avatar-render-y: 0;
813
+ }
814
+
815
+ :where(m-avatar[blobatar][contained]) {
816
+ --m-avatar-render-inset-block: var(--m-avatar-body-inset-block);
817
+ --m-avatar-render-inset-inline: var(--m-avatar-body-inset-inline);
818
+ --m-avatar-render-x: var(--m-avatar-body-x);
819
+ --m-avatar-render-y: var(--m-avatar-body-y);
820
+ }
821
+
822
+ :where(m-avatar)::before {
823
+ content: var(--m-avatar-decoration, none);
824
+ position: absolute;
825
+ inset-block: var(--m-avatar-render-inset-block);
826
+ inset-inline: var(--m-avatar-render-inset-inline);
827
+ border-radius: inherit;
828
+ background: var(--m-avatar-body-color);
829
+ transform: translate(var(--m-avatar-render-x), var(--m-avatar-render-y));
830
+ }
831
+
832
+ :where(m-avatar)::after {
833
+ content: var(--m-avatar-decoration, none);
834
+ position: absolute;
835
+ inset: 0;
836
+ background:
837
+ radial-gradient(
838
+ ellipse var(--m-avatar-eye-width) var(--m-avatar-eye-height)
839
+ at var(--m-avatar-eye-left) var(--m-avatar-eye-top),
840
+ var(--m-avatar-eye-color) 98%, transparent 100%
841
+ ),
842
+ radial-gradient(
843
+ ellipse var(--m-avatar-eye-width) var(--m-avatar-eye-height)
844
+ at calc(var(--m-avatar-eye-left) + var(--m-avatar-eye-gap))
845
+ calc(var(--m-avatar-eye-top) + var(--m-avatar-eye-drop)),
846
+ var(--m-avatar-eye-color) 98%, transparent 100%
847
+ );
848
+ }
849
+
850
+ /* --- skeleton: quiet CSS-only loading placeholder ---------------- */
851
+ :where(m-skeleton) {
852
+ display: block;
853
+ flex: none;
854
+ inline-size: var(--skeleton-width, 100%);
855
+ block-size: var(--skeleton-height, 0.75rem);
856
+ overflow: hidden;
857
+ border-radius: var(--skeleton-radius, var(--radius-md));
858
+ background-color: var(--color-surface-raised);
859
+ animation: mica-skeleton-pulse 1.6s ease-in-out infinite alternate;
860
+ }
861
+
862
+ @keyframes mica-skeleton-pulse {
863
+ to {
864
+ background-color: var(--color-border);
865
+ }
866
+ }
867
+
868
+ @media (prefers-reduced-motion: reduce) {
869
+ :where(m-skeleton) {
870
+ animation: none;
871
+ background-color: var(--color-border);
872
+ }
873
+ }
874
+
875
+ /* --- stat grid: semantic summaries in a fused frame -------------- */
876
+ :where(dl.stat-grid) {
877
+ display: flex;
878
+ flex-wrap: wrap;
879
+ gap: 1px;
880
+ margin-block: 0;
881
+ padding: 1px;
882
+ overflow: hidden;
883
+ border-radius: var(--radius-md);
884
+ background: var(--color-border);
885
+ }
886
+
887
+ :where(dl.stat-grid) > :where(div.stat) {
888
+ display: flex;
889
+ flex-direction: column;
890
+ flex: 1 1 min(100%, 8rem);
891
+ gap: var(--space-sm);
892
+ min-inline-size: 0;
893
+ padding: var(--space-md);
894
+ background: var(--color-surface);
895
+ }
896
+
897
+ :where(dl.stat-grid) > :where(div.stat) > :where(dt) {
898
+ color: var(--color-text-muted);
899
+ font-size: 0.75rem;
900
+ font-weight: 500;
901
+ line-height: 1.4;
902
+ }
903
+
904
+ :where(dl.stat-grid) > :where(div.stat) > :where(dd) {
905
+ display: flex;
906
+ flex-direction: column;
907
+ flex: 1 1 auto;
908
+ gap: var(--space-2xs);
909
+ min-inline-size: 0;
910
+ margin-block: 0;
911
+ }
912
+
913
+ :where(dl.stat-grid) > :where(div.stat) > :where(dd) > :first-child {
914
+ color: var(--color-text);
915
+ font-size: 1.5rem;
916
+ font-weight: 600;
917
+ font-variant-numeric: tabular-nums;
918
+ line-height: 1.2;
919
+ overflow-wrap: anywhere;
920
+ }
921
+
922
+ :where(dl.stat-grid) > :where(div.stat) > :where(dd) > :where(small) {
923
+ margin-block-start: auto;
924
+ color: var(--color-text-muted);
925
+ font-size: 0.75rem;
926
+ line-height: 1.4;
927
+ }
928
+
774
929
  /* --- content: tables, lists, code, quotes ------------------------ */
775
930
  :where(table) {
776
931
  inline-size: 100%;
@@ -907,6 +1062,71 @@
907
1062
  background: var(--color-danger);
908
1063
  }
909
1064
 
1065
+ /* --- stepper: framed sequence controls --------------------------- *
1066
+ * Mica owns only the fused layout. Consumers own labels, disabled
1067
+ * state, events, routing, and value changes. The frame deliberately does
1068
+ * not clip overflow: native focus rings must remain fully visible.
1069
+ * ------------------------------------------------------------------ */
1070
+ :where(m-stepper) {
1071
+ display: grid;
1072
+ grid-template-columns: auto minmax(0, 1fr) auto;
1073
+ align-items: stretch;
1074
+ min-inline-size: 0;
1075
+ border: 1px solid var(--color-border-strong);
1076
+ border-radius: var(--radius-md);
1077
+ background: var(--color-surface);
1078
+ }
1079
+
1080
+ :where(m-stepper:has(> [data-reset])) {
1081
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
1082
+ }
1083
+
1084
+ :where(m-stepper) > :where(button) {
1085
+ border: 0;
1086
+ border-radius: 0;
1087
+ background: transparent;
1088
+
1089
+ &:focus-visible {
1090
+ z-index: 1;
1091
+ }
1092
+ }
1093
+
1094
+ :where(m-stepper) > :where(button:first-child) {
1095
+ border-start-start-radius: inherit;
1096
+ border-end-start-radius: inherit;
1097
+ }
1098
+
1099
+ :where(m-stepper) > :where(button:last-child) {
1100
+ border-start-end-radius: inherit;
1101
+ border-end-end-radius: inherit;
1102
+ }
1103
+
1104
+ :where(m-stepper) > :where([data-step]) {
1105
+ inline-size: var(--control-height);
1106
+ padding-inline: 0;
1107
+ }
1108
+
1109
+ :where(m-stepper) > :where(:nth-child(2)) {
1110
+ display: grid;
1111
+ place-items: center;
1112
+ min-inline-size: 0;
1113
+ block-size: var(--control-height);
1114
+ padding-inline: var(--space-sm);
1115
+ overflow: hidden;
1116
+ border-inline: 1px solid var(--color-border);
1117
+ background: var(--color-surface-raised);
1118
+ color: var(--color-text);
1119
+ font-size: 0.875rem;
1120
+ font-weight: 600;
1121
+ text-align: center;
1122
+ text-overflow: ellipsis;
1123
+ white-space: nowrap;
1124
+ }
1125
+
1126
+ :where(m-stepper) > :where([data-reset]) {
1127
+ border-inline-start: 1px solid var(--color-border);
1128
+ }
1129
+
910
1130
  /* --- dialog: modal + drawer --------------------------------------- *
911
1131
  * The browser brings focus trapping, Esc, top layer, ::backdrop, and
912
1132
  * form[method=dialog] closing. Invoker commands (commandfor/command)
@@ -1132,6 +1352,14 @@
1132
1352
  border-block-start: 0;
1133
1353
  }
1134
1354
 
1355
+ /* Internal visual layer injected by drawer.js. It stays ordinary page
1356
+ content beneath the dialog's transparent top-layer ::backdrop, so it
1357
+ survives iOS removing that backdrop on close. Hidden outside the
1358
+ mobile-sheet breakpoint; aria-hidden + inert are set by the module. */
1359
+ :where([data-mica-drawer-overlay]) {
1360
+ display: none;
1361
+ }
1362
+
1135
1363
  /* small screens: the drawer becomes a bottom sheet — shadcn's own
1136
1364
  mobile drawer (vaul/radix) is exactly this. Anchored to the bottom
1137
1365
  edge via auto top margin: bottom attachment is the one thing iOS
@@ -1148,7 +1376,7 @@
1148
1376
  max-inline-size: none;
1149
1377
  border-inline-start: 0;
1150
1378
  border-block-start: 1px solid var(--color-border);
1151
- /* Two box-shadows, both painted by the SHEET element so they
1379
+ /* CSS-only fallback: two shadows painted by the SHEET so they
1152
1380
  persist through the exit (display:allow-discrete keeps the box
1153
1381
  rendered) — unlike ::backdrop, which iOS drops from the top
1154
1382
  layer the instant you close, cutting the scrim. So the dim
@@ -1184,8 +1412,28 @@
1184
1412
  0 0 0 100vmax oklch(0 0 0 / 0);
1185
1413
  }
1186
1414
  }
1187
- /* the box-shadow scrim replaces ::backdrop here; keep the native
1188
- backdrop clear so they don't double up or flash on the iOS cut. */
1415
+ /* With drawer.js, keep only the bleed slab; its ordinary fixed
1416
+ overlay owns the visible scrim and follows drag progress. */
1417
+ :where(:root[data-drawer-gestures])
1418
+ :where(dialog[data-drawer]) {
1419
+ box-shadow: 0 4rem 0 0 var(--color-surface-overlay);
1420
+ }
1421
+
1422
+ :where([data-mica-drawer-overlay]) {
1423
+ display: block;
1424
+ position: fixed;
1425
+ inset: 0;
1426
+ z-index: 50;
1427
+ inline-size: 100lvw;
1428
+ block-size: 100lvh;
1429
+ background: oklch(0 0 0 / 0.4);
1430
+ opacity: var(--m-drawer-overlay-opacity, 0);
1431
+ pointer-events: none;
1432
+ transition: opacity 0.15s;
1433
+ }
1434
+
1435
+ /* The fallback shadow or managed overlay replaces ::backdrop here;
1436
+ keep it clear so the scrims don't double up or flash on iOS. */
1189
1437
  :where(dialog[data-drawer][open])::backdrop {
1190
1438
  background: oklch(0 0 0 / 0);
1191
1439
  }
@@ -1431,38 +1679,136 @@
1431
1679
  /* --- tabs: m-tabs + tabs.js (JS-enhanced) ------------------------------ *
1432
1680
  * without the module: inert nav, all panels visible in order —
1433
1681
  * complete content. with it: tablist semantics + roving focus.
1434
- * shadcn segmented look: muted track, surface active tab.
1682
+ * default: muted track + surface active tab. variant="underline":
1683
+ * quiet rail + accent selection line; behavior stays identical.
1435
1684
  * ------------------------------------------------------------------ */
1436
1685
  :where(m-tabs) {
1686
+ --m-tabs-rail-gap: var(--space-2xs);
1687
+ --m-tabs-rail-padding: var(--space-2xs);
1688
+ --m-tabs-rail-border: none;
1689
+ --m-tabs-rail-radius: var(--radius-md);
1690
+ --m-tabs-rail-background: var(--neutral-3);
1691
+ --m-tabs-overflow-cover: var(--neutral-3);
1692
+ --m-tabs-overflow-duration: 0.15s;
1693
+ --m-tabs-overflow-marker: color-mix(in oklch, var(--color-text-muted) 70%, transparent);
1694
+ --m-tabs-overflow-start-gutter: 0px;
1695
+ --m-tabs-overflow-end-gutter: 0px;
1696
+ --m-tabs-overflow-start: 0;
1697
+ --m-tabs-overflow-end: 0;
1698
+ --m-tabs-tab-radius: var(--radius-sm);
1699
+ --m-tabs-tab-edge-padding: var(--space-sm);
1700
+ --m-tabs-tab-focus-offset: var(--focus-ring-offset);
1701
+ --m-tabs-tab-selected-background: var(--color-surface);
1702
+ --m-tabs-tab-selected-shadow: 0 1px 2px oklch(0 0 0 / 0.08);
1437
1703
  display: block;
1438
1704
  }
1439
1705
 
1706
+ :where(m-tabs[variant="underline"]) {
1707
+ --m-tabs-rail-gap: 0;
1708
+ --m-tabs-rail-padding: 0;
1709
+ --m-tabs-rail-border: 1px solid var(--color-border);
1710
+ --m-tabs-rail-radius: 0;
1711
+ --m-tabs-rail-background: transparent;
1712
+ --m-tabs-overflow-cover: var(--color-surface);
1713
+ --m-tabs-tab-radius: 0;
1714
+ --m-tabs-tab-edge-padding: 0;
1715
+ --m-tabs-tab-focus-offset: -2px;
1716
+ --m-tabs-tab-selected-background: transparent;
1717
+ --m-tabs-tab-selected-shadow: inset 0 -2px var(--color-accent);
1718
+ }
1719
+
1440
1720
  :where(m-tabs) > :where(nav) {
1441
1721
  display: inline-flex;
1442
- gap: var(--space-2xs);
1443
- padding: var(--space-2xs);
1444
- background: var(--neutral-3);
1445
- border-radius: var(--radius-md);
1722
+ gap: var(--m-tabs-rail-gap);
1723
+ padding: var(--m-tabs-rail-padding);
1724
+ border-block-end: var(--m-tabs-rail-border);
1725
+ border-radius: var(--m-tabs-rail-radius);
1726
+ background: var(--m-tabs-rail-background);
1446
1727
 
1447
1728
  /* Graceful collapse: labels never wrap inside a tab — when the rail
1448
- is narrower than its tabs it scrolls horizontally instead. The
1449
- thin scrollbar is the overflow affordance on platforms that show
1450
- one; scroll chaining stays contained. */
1729
+ is narrower than its tabs it scrolls horizontally instead. Before
1730
+ enhancement, the native scrollbar remains the overflow affordance.
1731
+ tabs.js hides it, activates sticky direction markers in clear
1732
+ gutters, and reveals a selected tab within those gutters. Scroll
1733
+ chaining stays contained. */
1451
1734
  max-inline-size: 100%;
1452
1735
  overflow-x: auto;
1453
1736
  overscroll-behavior-x: contain;
1454
1737
  scrollbar-width: thin;
1455
1738
 
1739
+ &[role="tablist"] {
1740
+ scrollbar-width: none;
1741
+ }
1742
+
1743
+ &::before,
1744
+ &::after {
1745
+ content: "";
1746
+ position: sticky;
1747
+ z-index: 1;
1748
+ align-self: stretch;
1749
+ background-color: var(--m-tabs-overflow-cover);
1750
+ background-position: center;
1751
+ background-size: 6px 10px;
1752
+ background-repeat: no-repeat;
1753
+ pointer-events: none;
1754
+ }
1755
+
1756
+ &::before {
1757
+ inset-inline-start: 0;
1758
+ flex: 0 0 var(--space-md);
1759
+ margin-inline-end: calc(var(--m-tabs-overflow-start-gutter) - var(--space-md));
1760
+ opacity: var(--m-tabs-overflow-start);
1761
+ background-image: conic-gradient(from 45deg at left center,
1762
+ var(--m-tabs-overflow-marker) 0 25%, transparent 0);
1763
+ transition:
1764
+ opacity var(--m-tabs-overflow-duration),
1765
+ margin-inline-end var(--m-tabs-overflow-duration);
1766
+ }
1767
+
1768
+ &::after {
1769
+ inset-inline-end: 0;
1770
+ flex: 0 0 var(--space-md);
1771
+ margin-inline-start: calc(var(--m-tabs-overflow-end-gutter) - var(--space-md));
1772
+ opacity: var(--m-tabs-overflow-end);
1773
+ background-image: conic-gradient(from 225deg at right center,
1774
+ var(--m-tabs-overflow-marker) 0 25%, transparent 0);
1775
+ transition:
1776
+ opacity var(--m-tabs-overflow-duration),
1777
+ margin-inline-start var(--m-tabs-overflow-duration);
1778
+ }
1779
+
1780
+ &:dir(rtl)::before {
1781
+ background-image: conic-gradient(from 225deg at right center,
1782
+ var(--m-tabs-overflow-marker) 0 25%, transparent 0);
1783
+ }
1784
+
1785
+ &:dir(rtl)::after {
1786
+ background-image: conic-gradient(from 45deg at left center,
1787
+ var(--m-tabs-overflow-marker) 0 25%, transparent 0);
1788
+ }
1789
+
1456
1790
  & > :where(button) {
1457
1791
  flex-shrink: 0;
1458
1792
  white-space: nowrap;
1459
1793
  block-size: var(--control-height-sm);
1460
1794
  padding-inline: var(--space-sm);
1461
1795
  border: 0;
1462
- border-radius: var(--radius-sm);
1796
+ border-radius: var(--m-tabs-tab-radius);
1463
1797
  background: transparent;
1464
1798
  color: var(--color-text-muted);
1465
1799
 
1800
+ &:first-of-type {
1801
+ padding-inline-start: var(--m-tabs-tab-edge-padding);
1802
+ }
1803
+
1804
+ &:last-of-type {
1805
+ padding-inline-end: var(--m-tabs-tab-edge-padding);
1806
+ }
1807
+
1808
+ &:focus-visible {
1809
+ outline-offset: var(--m-tabs-tab-focus-offset);
1810
+ }
1811
+
1466
1812
  /* :not() guards mirror the base button states (house rule) */
1467
1813
  &:hover:not(:disabled, [aria-disabled="true"]),
1468
1814
  &:active:not(:disabled, [aria-disabled="true"]) {
@@ -1471,19 +1817,25 @@
1471
1817
  }
1472
1818
 
1473
1819
  &[aria-selected="true"] {
1474
- background: var(--color-surface);
1820
+ background: var(--m-tabs-tab-selected-background);
1475
1821
  color: var(--color-text);
1476
- box-shadow: 0 1px 2px oklch(0 0 0 / 0.08);
1822
+ box-shadow: var(--m-tabs-tab-selected-shadow);
1477
1823
 
1478
1824
  &:hover:not(:disabled, [aria-disabled="true"]),
1479
1825
  &:active:not(:disabled, [aria-disabled="true"]) {
1480
- background: var(--color-surface);
1826
+ background: var(--m-tabs-tab-selected-background);
1481
1827
  color: var(--color-text);
1482
1828
  }
1483
1829
  }
1484
1830
  }
1485
1831
  }
1486
1832
 
1833
+ /* Vendor pseudo gets its own rule: one unknown selector in a list
1834
+ invalidates the whole list. */
1835
+ :where(m-tabs) > :where(nav[role="tablist"])::-webkit-scrollbar {
1836
+ display: none;
1837
+ }
1838
+
1487
1839
  :where(m-tabs) > :where(section) {
1488
1840
  padding-block: var(--space-md);
1489
1841
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
5
  "keywords": [
6
6
  "css",
@@ -21,6 +21,10 @@
21
21
  "exports": {
22
22
  ".": "./mica.css",
23
23
  "./mica.css": "./mica.css",
24
+ "./avatar.js": {
25
+ "types": "./avatar.d.ts",
26
+ "default": "./avatar.js"
27
+ },
24
28
  "./invoker.js": {
25
29
  "types": "./invoker.d.ts",
26
30
  "default": "./invoker.js"
@@ -55,6 +59,8 @@
55
59
  },
56
60
  "files": [
57
61
  "mica.css",
62
+ "avatar.js",
63
+ "avatar.d.ts",
58
64
  "invoker.js",
59
65
  "invoker.d.ts",
60
66
  "drawer.js",
@@ -69,16 +75,22 @@
69
75
  "toast.d.ts",
70
76
  "combobox.js",
71
77
  "combobox.d.ts",
72
- "types/react.d.ts"
78
+ "types/react.d.ts",
79
+ "THIRD_PARTY_LICENSES.md"
73
80
  ],
74
81
  "sideEffects": true,
75
82
  "devDependencies": {
76
83
  "axe-core": "^4.12.1",
84
+ "blobatar": "2.4.0",
77
85
  "pixelmatch": "^7.2.0",
78
86
  "playwright": "^1.61.1",
79
87
  "pngjs": "^7.0.0"
80
88
  },
81
89
  "scripts": {
90
+ "build:avatar": "bun tools/build-avatar.ts",
91
+ "docs:build": "bun tools/build-docs.ts",
92
+ "docs:check": "bun tools/build-docs.ts --check",
93
+ "docs:serve": "bun run docs:build && bun tools/serve.ts",
82
94
  "snapshot": "bun tools/snapshot.ts",
83
95
  "snapshot:check": "bun tools/snapshot.ts --check",
84
96
  "snapshot:setup": "playwright install chromium webkit"
package/tabs.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /* Type declarations for mica/tabs.js — side-effect module.
2
- * Registers <m-tabs>: roving tabindex, arrow keys, aria-selected wiring.
2
+ * Registers <m-tabs>: roving tabindex, arrow keys, aria-selected wiring,
3
+ * selected-tab reveal, and directional overflow markers.
3
4
  */
4
5
  declare global {
5
6
  interface HTMLElementTagNameMap {
package/tabs.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Enhances working light-DOM markup; never renders it. Without this
4
4
  * module, the nav buttons are inert and every panel is visible in
5
5
  * order — complete, readable content. With it: real tablist semantics,
6
- * roving tabindex, arrow-key navigation, automatic activation.
6
+ * roving tabindex, arrow-key navigation, automatic activation, and
7
+ * selected-tab reveal with position-aware overflow markers.
7
8
  *
8
9
  * <m-tabs>
9
10
  * <nav>
@@ -23,10 +24,43 @@ let uid = 0;
23
24
  class MTabs extends HTMLElement {
24
25
  #tabs = [];
25
26
  #panels = [];
27
+ #nav = null;
28
+ #overflowing = false;
29
+ #resizeObserver = null;
30
+ #revealFrame = null;
31
+ #onScroll = () => this.#syncOverflow();
32
+ #cancelReveal = () => {
33
+ if (this.#revealFrame !== null) cancelAnimationFrame(this.#revealFrame);
34
+ this.#revealFrame = null;
35
+ };
36
+ #onClick = (event) => {
37
+ const tab = event.target instanceof Element ? event.target.closest("button") : null;
38
+ const index = this.#tabs.indexOf(tab);
39
+ if (index === -1) return;
40
+ this.select(index);
41
+ this.#reveal(index);
42
+ };
43
+ #onKeyDown = (event) => {
44
+ const n = this.#tabs.length;
45
+ const current = this.#tabs.findIndex(
46
+ (tab) => tab.getAttribute("aria-selected") === "true",
47
+ );
48
+ let next = null;
49
+ if (event.key === "ArrowRight") next = (current + 1) % n;
50
+ else if (event.key === "ArrowLeft") next = (current - 1 + n) % n;
51
+ else if (event.key === "Home") next = 0;
52
+ else if (event.key === "End") next = n - 1;
53
+ if (next === null) return;
54
+ event.preventDefault();
55
+ this.select(next); // automatic activation, radix-style
56
+ this.#tabs[next].focus({ preventScroll: true });
57
+ this.#reveal(next);
58
+ };
26
59
 
27
60
  connectedCallback() {
28
61
  const nav = this.querySelector(":scope > nav");
29
62
  if (!nav) return;
63
+ this.#nav = nav;
30
64
  this.#tabs = [...nav.querySelectorAll("button")];
31
65
  const sections = [...this.querySelectorAll(":scope > section")];
32
66
  this.#panels = this.#tabs.map((b, i) => {
@@ -44,29 +78,117 @@ class MTabs extends HTMLElement {
44
78
  panel.setAttribute("aria-labelledby", tab.id);
45
79
  panel.tabIndex = 0;
46
80
  }
47
- tab.addEventListener("click", () => this.select(i));
48
81
  });
49
82
 
50
- nav.addEventListener("keydown", (e) => {
51
- const n = this.#tabs.length;
52
- const current = this.#tabs.findIndex(
53
- (t) => t.getAttribute("aria-selected") === "true",
54
- );
55
- let next = null;
56
- if (e.key === "ArrowRight") next = (current + 1) % n;
57
- else if (e.key === "ArrowLeft") next = (current - 1 + n) % n;
58
- else if (e.key === "Home") next = 0;
59
- else if (e.key === "End") next = n - 1;
60
- if (next === null) return;
61
- e.preventDefault();
62
- this.select(next); // automatic activation, radix-style
63
- this.#tabs[next].focus();
64
- });
83
+ nav.addEventListener("click", this.#onClick);
84
+ nav.addEventListener("keydown", this.#onKeyDown);
85
+ nav.addEventListener("pointerdown", this.#cancelReveal, { passive: true });
86
+ nav.addEventListener("wheel", this.#cancelReveal, { passive: true });
65
87
 
66
88
  const initial = this.#tabs.findIndex(
67
89
  (t) => t.hasAttribute("selected") || t.getAttribute("aria-selected") === "true",
68
90
  );
69
91
  this.select(initial === -1 ? 0 : initial);
92
+
93
+ nav.addEventListener("scroll", this.#onScroll, { passive: true });
94
+ this.#resizeObserver = new ResizeObserver(() => this.#measureOverflow());
95
+ this.#resizeObserver.observe(nav);
96
+ this.#tabs.forEach((tab) => this.#resizeObserver.observe(tab));
97
+ this.#measureOverflow();
98
+ }
99
+
100
+ disconnectedCallback() {
101
+ this.#nav?.removeEventListener("click", this.#onClick);
102
+ this.#nav?.removeEventListener("keydown", this.#onKeyDown);
103
+ this.#nav?.removeEventListener("pointerdown", this.#cancelReveal);
104
+ this.#nav?.removeEventListener("wheel", this.#cancelReveal);
105
+ this.#nav?.removeEventListener("scroll", this.#onScroll);
106
+ this.#resizeObserver?.disconnect();
107
+ this.#cancelReveal();
108
+ for (const property of [
109
+ "--m-tabs-overflow-start-gutter",
110
+ "--m-tabs-overflow-end-gutter",
111
+ "--m-tabs-overflow-start",
112
+ "--m-tabs-overflow-end",
113
+ ]) this.#nav?.style.removeProperty(property);
114
+ this.#nav = null;
115
+ this.#resizeObserver = null;
116
+ }
117
+
118
+ #measureOverflow() {
119
+ const nav = this.#nav;
120
+ if (!nav) return;
121
+ const style = getComputedStyle(nav);
122
+ const gap = Number.parseFloat(style.columnGap) || 0;
123
+ const padding = (Number.parseFloat(style.paddingInlineStart) || 0) +
124
+ (Number.parseFloat(style.paddingInlineEnd) || 0);
125
+ const tabsWidth = this.#tabs.reduce(
126
+ (width, tab) => width + tab.getBoundingClientRect().width,
127
+ padding + gap * Math.max(0, this.#tabs.length - 1),
128
+ );
129
+ this.#overflowing = tabsWidth > nav.clientWidth + 1;
130
+ this.#syncOverflow();
131
+ }
132
+
133
+ #syncOverflow() {
134
+ const nav = this.#nav;
135
+ if (!nav) return;
136
+ const offset = Math.abs(nav.scrollLeft);
137
+ const max = nav.scrollWidth - nav.clientWidth;
138
+ const start = this.#overflowing && offset > 1;
139
+ const end = this.#overflowing && offset < max - 1;
140
+ nav.style.setProperty("--m-tabs-overflow-start", String(start ? 1 : 0));
141
+ nav.style.setProperty("--m-tabs-overflow-end", String(end ? 1 : 0));
142
+ nav.style.setProperty(
143
+ "--m-tabs-overflow-start-gutter",
144
+ start ? "var(--space-md)" : "0px",
145
+ );
146
+ nav.style.setProperty(
147
+ "--m-tabs-overflow-end-gutter",
148
+ end ? "var(--space-md)" : "0px",
149
+ );
150
+ }
151
+
152
+ #reveal(index) {
153
+ const nav = this.#nav;
154
+ if (!nav || !this.#tabs[index]) return;
155
+ this.#cancelReveal();
156
+ const durations = getComputedStyle(nav, "::before").transitionDuration.split(",");
157
+ const duration = durations.reduce((longest, value) => {
158
+ const time = Number.parseFloat(value) * (value.trim().endsWith("ms") ? 1 : 1000);
159
+ return Math.max(longest, time);
160
+ }, 0);
161
+ // Follow the moving gutters through their transition, plus two frames
162
+ // for the scroll event that activates the newly exposed marker.
163
+ const settleAt = performance.now() + duration + 34;
164
+ let firstFrame = true;
165
+ const reveal = (now) => {
166
+ const nav = this.#nav;
167
+ const tab = this.#tabs[index];
168
+ if (!nav || !tab) return;
169
+ const navRect = nav.getBoundingClientRect();
170
+ const tabRect = tab.getBoundingClientRect();
171
+ const beforeStyle = getComputedStyle(nav, "::before");
172
+ const afterStyle = getComputedStyle(nav, "::after");
173
+ const before = Math.max(0,
174
+ (Number.parseFloat(beforeStyle.inlineSize) || 0) +
175
+ (Number.parseFloat(beforeStyle.marginInlineEnd) || 0));
176
+ const after = Math.max(0,
177
+ (Number.parseFloat(afterStyle.inlineSize) || 0) +
178
+ (Number.parseFloat(afterStyle.marginInlineStart) || 0));
179
+ const rtl = getComputedStyle(nav).direction === "rtl";
180
+ const left = navRect.left + (rtl ? after : before);
181
+ const right = navRect.right - (rtl ? before : after);
182
+ if (tabRect.left < left) nav.scrollBy({ left: tabRect.left - left });
183
+ else if (tabRect.right > right) nav.scrollBy({ left: tabRect.right - right });
184
+ if (firstFrame) {
185
+ this.#syncOverflow();
186
+ firstFrame = false;
187
+ }
188
+ if (now < settleAt) this.#revealFrame = requestAnimationFrame(reveal);
189
+ else this.#revealFrame = null;
190
+ };
191
+ this.#revealFrame = requestAnimationFrame(reveal);
70
192
  }
71
193
 
72
194
  select(index) {
package/types/react.d.ts CHANGED
@@ -51,11 +51,17 @@ declare module 'react' {
51
51
 
52
52
  // components
53
53
  'm-segmented': MicaElement
54
- 'm-tabs': MicaElement
54
+ 'm-stepper': MicaElement
55
+ 'm-tabs': MicaElement<{ variant?: 'underline' }>
55
56
  'm-badge': MicaElement<{
56
57
  variant?: 'primary' | 'success' | 'warning' | 'danger'
57
58
  count?: boolean
58
59
  }>
60
+ 'm-avatar': MicaElement<{
61
+ blobatar?: string
62
+ contained?: boolean
63
+ }>
64
+ 'm-skeleton': MicaElement
59
65
  'm-toast': MicaElement<{
60
66
  popover?: 'manual'
61
67
  variant?: 'success' | 'warning' | 'danger'