@akonwi/mica 0.7.0 → 0.9.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/combobox.js CHANGED
@@ -20,13 +20,16 @@ let uid = 0;
20
20
  class MCombobox extends HTMLElement {
21
21
  #input;
22
22
  #list;
23
+ #datalist;
24
+ #listId;
23
25
  #options = [];
24
26
  #active = -1;
27
+ #observer;
25
28
 
26
29
  connectedCallback() {
27
30
  this.#input = this.querySelector("input");
28
- const datalist = this.querySelector("datalist");
29
- if (!this.#input || !datalist) return;
31
+ this.#datalist = this.querySelector("datalist");
32
+ if (!this.#input || !this.#datalist) return;
30
33
 
31
34
  // disable the native popup; the module takes over
32
35
  this.#input.removeAttribute("list");
@@ -35,16 +38,49 @@ class MCombobox extends HTMLElement {
35
38
  this.#input.setAttribute("aria-autocomplete", "list");
36
39
  this.#input.autocomplete = "off";
37
40
 
38
- const listId = `m-cb-${++uid}`;
41
+ this.#listId = `m-cb-${++uid}`;
39
42
  this.#list = document.createElement("div");
40
- this.#list.id = listId;
43
+ this.#list.id = this.#listId;
41
44
  this.#list.setAttribute("role", "listbox");
42
45
  this.#list.hidden = true;
43
46
 
44
- this.#options = [...datalist.options].map((o, i) => {
47
+ this.#buildOptions();
48
+ this.append(this.#list);
49
+ this.#input.setAttribute("aria-controls", this.#listId);
50
+
51
+ // Options may be rendered asynchronously (a search-backed combobox):
52
+ // rebuild the listbox whenever the author's datalist changes.
53
+ this.#observer = new MutationObserver(() => {
54
+ this.#buildOptions();
55
+ if (document.activeElement === this.#input) this.#openAndFilter();
56
+ });
57
+ this.#observer.observe(this.#datalist, {
58
+ childList: true,
59
+ subtree: true,
60
+ attributes: true,
61
+ attributeFilter: ["value"],
62
+ });
63
+
64
+ this.#input.addEventListener("input", (e) => {
65
+ // ignore our own dispatched events from #choose — they'd reopen
66
+ if (e.isTrusted) this.#openAndFilter();
67
+ });
68
+ this.#input.addEventListener("keydown", (e) => this.#onKey(e));
69
+ this.addEventListener("focusout", (e) => {
70
+ if (!this.contains(e.relatedTarget)) this.#close();
71
+ });
72
+ }
73
+
74
+ disconnectedCallback() {
75
+ this.#observer?.disconnect();
76
+ }
77
+
78
+ #buildOptions() {
79
+ this.#list.replaceChildren();
80
+ this.#options = [...this.#datalist.options].map((o, i) => {
45
81
  const d = document.createElement("div");
46
82
  d.setAttribute("role", "option");
47
- d.id = `${listId}-${i}`;
83
+ d.id = `${this.#listId}-${i}`;
48
84
  d.textContent = o.value || o.textContent;
49
85
  // pointerdown so the input never loses focus
50
86
  d.addEventListener("pointerdown", (e) => {
@@ -58,17 +94,7 @@ class MCombobox extends HTMLElement {
58
94
  this.#list.append(d);
59
95
  return d;
60
96
  });
61
- this.append(this.#list);
62
- this.#input.setAttribute("aria-controls", listId);
63
-
64
- this.#input.addEventListener("input", (e) => {
65
- // ignore our own dispatched events from #choose — they'd reopen
66
- if (e.isTrusted) this.#openAndFilter();
67
- });
68
- this.#input.addEventListener("keydown", (e) => this.#onKey(e));
69
- this.addEventListener("focusout", (e) => {
70
- if (!this.contains(e.relatedTarget)) this.#close();
71
- });
97
+ this.#active = -1;
72
98
  }
73
99
 
74
100
  #visible() {
@@ -127,7 +153,15 @@ class MCombobox extends HTMLElement {
127
153
  }
128
154
 
129
155
  #choose(option) {
130
- this.#input.value = option.textContent;
156
+ // Write through the native prototype setter: frameworks that patch
157
+ // the value property to track controlled inputs (React) only observe
158
+ // the change when it lands on the native setter.
159
+ const setter = Object.getOwnPropertyDescriptor(
160
+ HTMLInputElement.prototype,
161
+ "value",
162
+ )?.set;
163
+ if (setter) setter.call(this.#input, option.textContent);
164
+ else this.#input.value = option.textContent;
131
165
  this.#close();
132
166
  this.#input.dispatchEvent(new Event("input", { bubbles: true }));
133
167
  this.#input.dispatchEvent(new Event("change", { bubbles: true }));
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,82 @@
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
+
774
850
  /* --- content: tables, lists, code, quotes ------------------------ */
775
851
  :where(table) {
776
852
  inline-size: 100%;
@@ -1132,6 +1208,14 @@
1132
1208
  border-block-start: 0;
1133
1209
  }
1134
1210
 
1211
+ /* Internal visual layer injected by drawer.js. It stays ordinary page
1212
+ content beneath the dialog's transparent top-layer ::backdrop, so it
1213
+ survives iOS removing that backdrop on close. Hidden outside the
1214
+ mobile-sheet breakpoint; aria-hidden + inert are set by the module. */
1215
+ :where([data-mica-drawer-overlay]) {
1216
+ display: none;
1217
+ }
1218
+
1135
1219
  /* small screens: the drawer becomes a bottom sheet — shadcn's own
1136
1220
  mobile drawer (vaul/radix) is exactly this. Anchored to the bottom
1137
1221
  edge via auto top margin: bottom attachment is the one thing iOS
@@ -1148,7 +1232,7 @@
1148
1232
  max-inline-size: none;
1149
1233
  border-inline-start: 0;
1150
1234
  border-block-start: 1px solid var(--color-border);
1151
- /* Two box-shadows, both painted by the SHEET element so they
1235
+ /* CSS-only fallback: two shadows painted by the SHEET so they
1152
1236
  persist through the exit (display:allow-discrete keeps the box
1153
1237
  rendered) — unlike ::backdrop, which iOS drops from the top
1154
1238
  layer the instant you close, cutting the scrim. So the dim
@@ -1184,8 +1268,28 @@
1184
1268
  0 0 0 100vmax oklch(0 0 0 / 0);
1185
1269
  }
1186
1270
  }
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. */
1271
+ /* With drawer.js, keep only the bleed slab; its ordinary fixed
1272
+ overlay owns the visible scrim and follows drag progress. */
1273
+ :where(:root[data-drawer-gestures])
1274
+ :where(dialog[data-drawer]) {
1275
+ box-shadow: 0 4rem 0 0 var(--color-surface-overlay);
1276
+ }
1277
+
1278
+ :where([data-mica-drawer-overlay]) {
1279
+ display: block;
1280
+ position: fixed;
1281
+ inset: 0;
1282
+ z-index: 50;
1283
+ inline-size: 100lvw;
1284
+ block-size: 100lvh;
1285
+ background: oklch(0 0 0 / 0.4);
1286
+ opacity: var(--m-drawer-overlay-opacity, 0);
1287
+ pointer-events: none;
1288
+ transition: opacity 0.15s;
1289
+ }
1290
+
1291
+ /* The fallback shadow or managed overlay replaces ::backdrop here;
1292
+ keep it clear so the scrims don't double up or flash on iOS. */
1189
1293
  :where(dialog[data-drawer][open])::backdrop {
1190
1294
  background: oklch(0 0 0 / 0);
1191
1295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
5
  "keywords": [
6
6
  "css",
@@ -21,19 +21,46 @@
21
21
  "exports": {
22
22
  ".": "./mica.css",
23
23
  "./mica.css": "./mica.css",
24
- "./invoker.js": { "types": "./invoker.d.ts", "default": "./invoker.js" },
25
- "./drawer.js": { "types": "./drawer.d.ts", "default": "./drawer.js" },
26
- "./field.js": { "types": "./field.d.ts", "default": "./field.js" },
27
- "./select.js": { "types": "./select.d.ts", "default": "./select.js" },
28
- "./tabs.js": { "types": "./tabs.d.ts", "default": "./tabs.js" },
29
- "./toast.js": { "types": "./toast.d.ts", "default": "./toast.js" },
30
- "./combobox.js": { "types": "./combobox.d.ts", "default": "./combobox.js" },
24
+ "./avatar.js": {
25
+ "types": "./avatar.d.ts",
26
+ "default": "./avatar.js"
27
+ },
28
+ "./invoker.js": {
29
+ "types": "./invoker.d.ts",
30
+ "default": "./invoker.js"
31
+ },
32
+ "./drawer.js": {
33
+ "types": "./drawer.d.ts",
34
+ "default": "./drawer.js"
35
+ },
36
+ "./field.js": {
37
+ "types": "./field.d.ts",
38
+ "default": "./field.js"
39
+ },
40
+ "./select.js": {
41
+ "types": "./select.d.ts",
42
+ "default": "./select.js"
43
+ },
44
+ "./tabs.js": {
45
+ "types": "./tabs.d.ts",
46
+ "default": "./tabs.js"
47
+ },
48
+ "./toast.js": {
49
+ "types": "./toast.d.ts",
50
+ "default": "./toast.js"
51
+ },
52
+ "./combobox.js": {
53
+ "types": "./combobox.d.ts",
54
+ "default": "./combobox.js"
55
+ },
31
56
  "./types/react": {
32
57
  "types": "./types/react.d.ts"
33
58
  }
34
59
  },
35
60
  "files": [
36
61
  "mica.css",
62
+ "avatar.js",
63
+ "avatar.d.ts",
37
64
  "invoker.js",
38
65
  "invoker.d.ts",
39
66
  "drawer.js",
@@ -48,16 +75,22 @@
48
75
  "toast.d.ts",
49
76
  "combobox.js",
50
77
  "combobox.d.ts",
51
- "types/react.d.ts"
78
+ "types/react.d.ts",
79
+ "THIRD_PARTY_LICENSES.md"
52
80
  ],
53
81
  "sideEffects": true,
54
82
  "devDependencies": {
55
83
  "axe-core": "^4.12.1",
84
+ "blobatar": "2.4.0",
56
85
  "pixelmatch": "^7.2.0",
57
86
  "playwright": "^1.61.1",
58
87
  "pngjs": "^7.0.0"
59
88
  },
60
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",
61
94
  "snapshot": "bun tools/snapshot.ts",
62
95
  "snapshot:check": "bun tools/snapshot.ts --check",
63
96
  "snapshot:setup": "playwright install chromium webkit"
package/types/react.d.ts CHANGED
@@ -56,6 +56,10 @@ declare module 'react' {
56
56
  variant?: 'primary' | 'success' | 'warning' | 'danger'
57
57
  count?: boolean
58
58
  }>
59
+ 'm-avatar': MicaElement<{
60
+ blobatar?: string
61
+ contained?: boolean
62
+ }>
59
63
  'm-toast': MicaElement<{
60
64
  popover?: 'manual'
61
65
  variant?: 'success' | 'warning' | 'danger'