@social.io/catalog 2.0.0 → 2.1.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.
@@ -0,0 +1,367 @@
1
+ export type TSioLogoAnimationPhase = 'in' | 'out' | 'wave' | 'loading';
2
+
3
+ export interface ISioLogoRenderOptions {
4
+ size: number;
5
+ dpr: number;
6
+ gap: number;
7
+ radius: number;
8
+ square: boolean;
9
+ background: string;
10
+ mono: string;
11
+ light: boolean;
12
+ }
13
+
14
+ interface ISioLogoPoint {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ interface ISioLogoFacet {
20
+ fill: string;
21
+ points: [[number, number], [number, number], [number, number]];
22
+ }
23
+
24
+ const facets: ISioLogoFacet[] = [
25
+ { fill: '#4AA0F5', points: [[250, 75], [150, 133.333], [250, 191.667]] },
26
+ { fill: '#0A5AC0', points: [[250, 75], [350, 133.333], [250, 191.667]] },
27
+ { fill: '#DBEBFF', points: [[150, 133.333], [250, 191.667], [150, 250]] },
28
+ { fill: '#A9CDF7', points: [[250, 191.667], [150, 250], [250, 308.333]] },
29
+ { fill: '#1E80E6', points: [[250, 191.667], [350, 250], [250, 308.333]] },
30
+ { fill: '#1466C6', points: [[350, 250], [250, 308.333], [350, 366.667]] },
31
+ { fill: '#74B4F7', points: [[250, 308.333], [350, 366.667], [250, 425]] },
32
+ { fill: '#1E80E6', points: [[250, 308.333], [150, 366.667], [250, 425]] },
33
+ ];
34
+
35
+ const lightFills = [
36
+ '#4AA0F5', '#0A5AC0', '#8FBEF5', '#77ACEE',
37
+ '#1E80E6', '#1466C6', '#5FA6F2', '#1E80E6',
38
+ ];
39
+ const monoOpacities = [0.78, 0.64, 0.96, 0.86, 0.72, 0.66, 0.82, 0.72];
40
+ const tightBounds = { x: 146, y: 72, width: 208, height: 356 };
41
+ const centerX = 250;
42
+ const centerY = 250;
43
+
44
+ export const sioLogoAspect = tightBounds.width / tightBounds.height;
45
+
46
+ const centroids = facets.map((facet) => [
47
+ (facet.points[0][0] + facet.points[1][0] + facet.points[2][0]) / 3,
48
+ (facet.points[0][1] + facet.points[1][1] + facet.points[2][1]) / 3,
49
+ ]);
50
+
51
+ const clamp = (value: number, minimum: number, maximum: number): number =>
52
+ Math.max(minimum, Math.min(maximum, value));
53
+
54
+ const finiteAtLeast = (value: number, minimum: number, fallback: number): number =>
55
+ Number.isFinite(value) ? Math.max(minimum, value) : fallback;
56
+
57
+ const normalizeOptions = (options: ISioLogoRenderOptions): ISioLogoRenderOptions => ({
58
+ ...options,
59
+ size: finiteAtLeast(options.size, 1, 40),
60
+ dpr: clamp(finiteAtLeast(options.dpr, 0.5, 1), 0.5, 4),
61
+ gap: finiteAtLeast(options.gap, 0, 2),
62
+ radius: finiteAtLeast(options.radius, 0, 16),
63
+ });
64
+
65
+ const inset = (
66
+ points: ISioLogoFacet['points'],
67
+ gap: number,
68
+ ): ISioLogoPoint[] => {
69
+ if (gap <= 0) return points.map(([x, y]) => ({ x, y }));
70
+ const centroidX = (points[0][0] + points[1][0] + points[2][0]) / 3;
71
+ const centroidY = (points[0][1] + points[1][1] + points[2][1]) / 3;
72
+ const lines: Array<ISioLogoPoint & { dx: number; dy: number }> = [];
73
+
74
+ for (let index = 0; index < points.length; index += 1) {
75
+ const start = points[index];
76
+ const end = points[(index + 1) % points.length];
77
+ let deltaX = end[0] - start[0];
78
+ let deltaY = end[1] - start[1];
79
+ const length = Math.hypot(deltaX, deltaY);
80
+ deltaX /= length;
81
+ deltaY /= length;
82
+ let normalX = -deltaY;
83
+ let normalY = deltaX;
84
+ const middleX = (start[0] + end[0]) / 2;
85
+ const middleY = (start[1] + end[1]) / 2;
86
+ if ((centroidX - middleX) * normalX + (centroidY - middleY) * normalY < 0) {
87
+ normalX = -normalX;
88
+ normalY = -normalY;
89
+ }
90
+ lines.push({
91
+ x: start[0] + normalX * gap,
92
+ y: start[1] + normalY * gap,
93
+ dx: deltaX,
94
+ dy: deltaY,
95
+ });
96
+ }
97
+
98
+ return lines.map((line, index) => {
99
+ const previous = lines[(index - 1 + lines.length) % lines.length];
100
+ const denominator = previous.dx * line.dy - previous.dy * line.dx;
101
+ const distance =
102
+ ((line.x - previous.x) * line.dy - (line.y - previous.y) * line.dx) /
103
+ denominator;
104
+ return {
105
+ x: previous.x + previous.dx * distance,
106
+ y: previous.y + previous.dy * distance,
107
+ };
108
+ });
109
+ };
110
+
111
+ const createFacetPath = (
112
+ points: ISioLogoFacet['points'],
113
+ gap: number,
114
+ radius: number,
115
+ ): Path2D => {
116
+ const insetPoints = inset(points, gap);
117
+ const path = new Path2D();
118
+ if (radius <= 0) {
119
+ path.moveTo(insetPoints[0].x, insetPoints[0].y);
120
+ for (let index = 1; index < insetPoints.length; index += 1) {
121
+ path.lineTo(insetPoints[index].x, insetPoints[index].y);
122
+ }
123
+ path.closePath();
124
+ return path;
125
+ }
126
+
127
+ for (let index = 0; index < insetPoints.length; index += 1) {
128
+ const vertex = insetPoints[index];
129
+ const previous = insetPoints[(index - 1 + insetPoints.length) % insetPoints.length];
130
+ const next = insetPoints[(index + 1) % insetPoints.length];
131
+ const previousDistance = Math.hypot(previous.x - vertex.x, previous.y - vertex.y);
132
+ const nextDistance = Math.hypot(next.x - vertex.x, next.y - vertex.y);
133
+ const previousRadius = Math.min(radius, previousDistance * 0.5);
134
+ const nextRadius = Math.min(radius, nextDistance * 0.5);
135
+ const entry = {
136
+ x: vertex.x + ((previous.x - vertex.x) / previousDistance) * previousRadius,
137
+ y: vertex.y + ((previous.y - vertex.y) / previousDistance) * previousRadius,
138
+ };
139
+ const exit = {
140
+ x: vertex.x + ((next.x - vertex.x) / nextDistance) * nextRadius,
141
+ y: vertex.y + ((next.y - vertex.y) / nextDistance) * nextRadius,
142
+ };
143
+ if (index === 0) path.moveTo(entry.x, entry.y);
144
+ else path.lineTo(entry.x, entry.y);
145
+ path.quadraticCurveTo(vertex.x, vertex.y, exit.x, exit.y);
146
+ }
147
+ path.closePath();
148
+ return path;
149
+ };
150
+
151
+ const easeOutCubic = (progress: number): number => 1 - Math.pow(1 - progress, 3);
152
+ const easeOutBack = (progress: number): number => {
153
+ const coefficient = 1.70158;
154
+ return 1 + (coefficient + 1) * Math.pow(progress - 1, 3) + coefficient * Math.pow(progress - 1, 2);
155
+ };
156
+ const easeInCubic = (progress: number): number => progress * progress * progress;
157
+ const easeInBack = (progress: number): number => {
158
+ const coefficient = 1.70158;
159
+ return (coefficient + 1) * progress * progress * progress - coefficient * progress * progress;
160
+ };
161
+
162
+ export class SioLogoRenderer {
163
+ private options: ISioLogoRenderOptions;
164
+ private frameHandle: number | null = null;
165
+ private phase: TSioLogoAnimationPhase | null = null;
166
+ private startedAt = 0;
167
+ private stagger = 70;
168
+ private duration = 560;
169
+ private totalDuration = 0;
170
+ private backingWidth = 0;
171
+ private backingHeight = 0;
172
+ private scale = 1;
173
+ private offsetX = 0;
174
+ private offsetY = 0;
175
+ private paths: Path2D[] = [];
176
+ private loadingPaths: Path2D[] = [];
177
+ private fills: string[] = [];
178
+ private opacities: number[] = [];
179
+
180
+ constructor(
181
+ private readonly context: CanvasRenderingContext2D,
182
+ options: ISioLogoRenderOptions,
183
+ ) {
184
+ this.options = normalizeOptions(options);
185
+ this.rebuild();
186
+ }
187
+
188
+ public setOptions(options: ISioLogoRenderOptions): void {
189
+ this.options = normalizeOptions(options);
190
+ this.rebuild();
191
+ if (!this.phase) this.drawStatic();
192
+ }
193
+
194
+ public drawStatic(): void {
195
+ this.clearCanvas();
196
+ for (let index = 0; index < facets.length; index += 1) {
197
+ this.paint(index, 0, 0, 0, 1, this.opacities[index], this.paths[index]);
198
+ }
199
+ this.context.globalAlpha = 1;
200
+ }
201
+
202
+ public clear(): void {
203
+ this.stop();
204
+ this.clearCanvas();
205
+ }
206
+
207
+ public start(phase: TSioLogoAnimationPhase): void {
208
+ this.stop();
209
+ this.phase = phase;
210
+ this.startedAt = performance.now();
211
+ this.stagger = phase === 'wave' ? 55 : 70;
212
+ this.duration = phase === 'wave' ? 420 : 560;
213
+ this.totalDuration = this.stagger * (facets.length - 1) + this.duration;
214
+
215
+ const drawFrame = (time: number): void => {
216
+ if (!this.phase) return;
217
+ const elapsed = time - this.startedAt;
218
+ this.drawAnimationFrame(elapsed);
219
+ if (this.phase !== 'loading' && elapsed >= this.totalDuration) {
220
+ this.settle();
221
+ return;
222
+ }
223
+ this.frameHandle = requestAnimationFrame(drawFrame);
224
+ };
225
+ this.frameHandle = requestAnimationFrame(drawFrame);
226
+ }
227
+
228
+ public stop(): void {
229
+ if (this.frameHandle !== null) {
230
+ cancelAnimationFrame(this.frameHandle);
231
+ this.frameHandle = null;
232
+ }
233
+ this.phase = null;
234
+ }
235
+
236
+ private rebuild(): void {
237
+ const { size, dpr, square, light, mono, gap, radius } = this.options;
238
+ this.backingWidth = Math.round((square ? size : size * sioLogoAspect) * dpr);
239
+ this.backingHeight = Math.round(size * dpr);
240
+ this.context.canvas.width = this.backingWidth;
241
+ this.context.canvas.height = this.backingHeight;
242
+ if (square) {
243
+ this.scale = this.backingWidth / 500;
244
+ this.offsetX = 0;
245
+ this.offsetY = 0;
246
+ } else {
247
+ this.scale = this.backingHeight / tightBounds.height;
248
+ this.offsetX =
249
+ -tightBounds.x * this.scale +
250
+ (this.backingWidth - tightBounds.width * this.scale) / 2;
251
+ this.offsetY = -tightBounds.y * this.scale;
252
+ }
253
+ this.paths = facets.map((facet) => createFacetPath(facet.points, gap, radius));
254
+ this.loadingPaths = facets.map((facet) => createFacetPath(facet.points, gap + 3, 90));
255
+ this.fills = facets.map((facet, index) => mono || (light ? lightFills[index] : facet.fill));
256
+ this.opacities = facets.map((_, index) => (mono ? monoOpacities[index] : 1));
257
+ }
258
+
259
+ private clearCanvas(): void {
260
+ this.context.setTransform(1, 0, 0, 1, 0, 0);
261
+ this.context.clearRect(0, 0, this.backingWidth, this.backingHeight);
262
+ const { background } = this.options;
263
+ if (background && background !== 'transparent' && background !== 'none') {
264
+ this.context.fillStyle = background;
265
+ this.context.fillRect(0, 0, this.backingWidth, this.backingHeight);
266
+ }
267
+ }
268
+
269
+ private paint(
270
+ index: number,
271
+ translateX: number,
272
+ translateY: number,
273
+ rotation: number,
274
+ scale: number,
275
+ opacity: number,
276
+ path: Path2D,
277
+ ): void {
278
+ const [centroidX, centroidY] = centroids[index];
279
+ this.context.setTransform(this.scale, 0, 0, this.scale, this.offsetX, this.offsetY);
280
+ this.context.translate(translateX, translateY);
281
+ this.context.translate(centroidX, centroidY);
282
+ this.context.rotate((rotation * Math.PI) / 180);
283
+ this.context.scale(scale, scale);
284
+ this.context.translate(-centroidX, -centroidY);
285
+ this.context.globalAlpha = opacity;
286
+ this.context.fillStyle = this.fills[index];
287
+ this.context.fill(path);
288
+ }
289
+
290
+ private drawAnimationFrame(elapsed: number): void {
291
+ this.clearCanvas();
292
+ const activePhase = this.phase;
293
+ for (let index = 0; index < facets.length; index += 1) {
294
+ const [centroidX, centroidY] = centroids[index];
295
+ let translateX = 0;
296
+ let translateY = 0;
297
+ let rotation = 0;
298
+ let scale = 1;
299
+ let opacity = this.opacities[index];
300
+ let path = this.paths[index];
301
+
302
+ if (activePhase === 'in') {
303
+ const progress = clamp((elapsed - index * this.stagger) / this.duration, 0, 1);
304
+ const eased = easeOutCubic(progress);
305
+ translateX = (centerX - centroidX) * (1 - eased);
306
+ translateY = (centerY - centroidY) * (1 - eased);
307
+ rotation = (1 - eased) * -16;
308
+ scale = easeOutBack(progress);
309
+ opacity = clamp(progress * 1.6, 0, 1) * this.opacities[index];
310
+ } else if (activePhase === 'out') {
311
+ const reverseIndex = facets.length - 1 - index;
312
+ const progress = clamp((elapsed - reverseIndex * this.stagger) / this.duration, 0, 1);
313
+ const eased = easeInCubic(progress);
314
+ translateX = (centerX - centroidX) * eased;
315
+ translateY = (centerY - centroidY) * eased;
316
+ rotation = progress * 16;
317
+ scale = Math.max(0, 1 - easeInBack(progress));
318
+ opacity = clamp(1 - progress * 1.3, 0, 1) * this.opacities[index];
319
+ } else if (activePhase === 'wave') {
320
+ const progress = clamp((elapsed - index * this.stagger) / this.duration, 0, 1);
321
+ const pulse = Math.sin(progress * Math.PI);
322
+ const radialX = centroidX - centerX;
323
+ const radialY = centroidY - centerY;
324
+ const radialLength = Math.hypot(radialX, radialY) || 1;
325
+ translateX = (radialX / radialLength) * 16 * pulse;
326
+ translateY = (radialY / radialLength) * 16 * pulse;
327
+ scale = 1 - 0.08 * pulse;
328
+ } else if (activePhase === 'loading') {
329
+ const growth = clamp(elapsed / 650, 0, 1);
330
+ const easedGrowth = growth < 0.5
331
+ ? 2 * growth * growth
332
+ : 1 - Math.pow(-2 * growth + 2, 2) / 2;
333
+ const looping = elapsed >= 650;
334
+ const spin = (elapsed / 1500) * 2 * Math.PI;
335
+ const angle = -Math.PI / 2 + index * Math.PI / 4 + spin;
336
+ const destinationX = centerX + 120 * Math.cos(angle);
337
+ const destinationY = centerY + 120 * Math.sin(angle);
338
+ const brightness = 1 - index / facets.length;
339
+ translateX = (destinationX - centroidX) * easedGrowth;
340
+ translateY = (destinationY - centroidY) * easedGrowth;
341
+ scale =
342
+ (1 + (0.62 - 1) * easedGrowth) *
343
+ (looping ? 0.72 + 0.28 * brightness : 1);
344
+ opacity = (looping ? 0.28 + 0.72 * brightness : 1) * this.opacities[index];
345
+ path = looping
346
+ ? this.loadingPaths[index]
347
+ : createFacetPath(
348
+ facets[index].points,
349
+ this.options.gap + 3 * easedGrowth,
350
+ this.options.radius + (90 - this.options.radius) * easedGrowth,
351
+ );
352
+ }
353
+
354
+ this.paint(index, translateX, translateY, rotation, scale, opacity, path);
355
+ }
356
+ this.context.globalAlpha = 1;
357
+ }
358
+
359
+ private settle(): void {
360
+ const finishedPhase = this.phase;
361
+ this.frameHandle = null;
362
+ this.phase = null;
363
+ if (finishedPhase === 'out') this.clearCanvas();
364
+ else this.drawStatic();
365
+ this.context.globalAlpha = 1;
366
+ }
367
+ }
@@ -0,0 +1,311 @@
1
+ import {
2
+ DeesElement,
3
+ css,
4
+ customElement,
5
+ html,
6
+ property,
7
+ type TemplateResult,
8
+ } from '@design.estate/dees-element';
9
+ import { SioLogoRenderer, sioLogoAspect } from './sio-logo-render.js';
10
+ import { sioElementStyles } from './tokens.js';
11
+
12
+ export type TSioLogoTheme = 'auto' | 'light' | 'dark';
13
+
14
+ declare global {
15
+ interface HTMLElementTagNameMap {
16
+ 'sio-logo': SioLogo;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The social.io faceted S brand mark from the v8 design prototype.
22
+ *
23
+ * The canvas renderer preserves the exact eight-facet geometry and supports
24
+ * entrance, hover-wave, dismiss, and loading animations. Motion is disabled
25
+ * when the visitor requests reduced motion.
26
+ */
27
+ @customElement('sio-logo')
28
+ export class SioLogo extends DeesElement {
29
+ public static demo = () => html`
30
+ <div style="display:flex;align-items:center;gap:32px;padding:24px;background:#101012;">
31
+ <sio-logo size="120" theme="dark"></sio-logo>
32
+ <sio-logo size="120" square background="#556270" radius="22" theme="dark"></sio-logo>
33
+ <sio-logo size="120" loading theme="dark"></sio-logo>
34
+ <sio-logo size="120" mono="#ffffff" no-entrance theme="dark"></sio-logo>
35
+ </div>
36
+ `;
37
+
38
+ /** rendered height in CSS pixels; tight-crop width follows the brand aspect */
39
+ @property({ type: Number })
40
+ public accessor size = 40;
41
+
42
+ @property({ type: String })
43
+ public accessor background = 'transparent';
44
+
45
+ /** inward inset per facet, in 500px design-grid units */
46
+ @property({ type: Number })
47
+ public accessor gap = 2;
48
+
49
+ /** facet corner radius, in 500px design-grid units */
50
+ @property({ type: Number })
51
+ public accessor radius = 16;
52
+
53
+ /** render on a square tile instead of the tight S crop */
54
+ @property({ type: Boolean })
55
+ public accessor square = false;
56
+
57
+ /** collapse the palette to one color while retaining facet depth */
58
+ @property({ type: String })
59
+ public accessor mono = '';
60
+
61
+ /** backdrop palette; auto follows the nearest opaque surface */
62
+ @property({ type: String })
63
+ public accessor theme: TSioLogoTheme = 'auto';
64
+
65
+ @property({ type: Boolean, reflect: true })
66
+ public accessor loading = false;
67
+
68
+ @property({ type: Boolean, attribute: 'no-entrance' })
69
+ public accessor noEntrance = false;
70
+
71
+ @property({ type: Boolean, attribute: 'no-hover' })
72
+ public accessor noHover = false;
73
+
74
+ public static styles = [
75
+ ...sioElementStyles,
76
+ css`
77
+ :host {
78
+ display: inline-flex;
79
+ flex: none;
80
+ line-height: 0;
81
+ }
82
+ canvas {
83
+ display: block;
84
+ }
85
+ `,
86
+ ];
87
+
88
+ private renderer: SioLogoRenderer | null = null;
89
+ private themeObserver: MutationObserver | null = null;
90
+ private colorSchemeMedia: MediaQueryList | null = null;
91
+ private reducedMotionMedia: MediaQueryList | null = null;
92
+ private lastDevicePixelRatio = 0;
93
+ private lifecycleGeneration = 0;
94
+
95
+ private readonly handlePointerEnter = (): void => {
96
+ if (!this.noHover && !this.loading && !this.prefersReducedMotion()) this.wave();
97
+ };
98
+
99
+ private readonly handleAmbientChange = (): void => {
100
+ this.syncRenderer(this.loading ? 'loading' : null);
101
+ };
102
+
103
+ private readonly handleReducedMotionChange = (): void => {
104
+ this.syncRenderer(this.loading ? 'loading' : null);
105
+ };
106
+
107
+ private readonly handleResize = (): void => {
108
+ const currentDevicePixelRatio = window.devicePixelRatio || 1;
109
+ if (currentDevicePixelRatio === this.lastDevicePixelRatio) return;
110
+ this.syncRenderer(this.loading ? 'loading' : null);
111
+ };
112
+
113
+ public async connectedCallback(): Promise<void> {
114
+ const generation = ++this.lifecycleGeneration;
115
+ const connection = super.connectedCallback();
116
+ this.ensureAccessibleHost();
117
+ this.addEventListener('mouseenter', this.handlePointerEnter);
118
+ window.addEventListener('resize', this.handleResize);
119
+ this.observeReducedMotion();
120
+ await connection;
121
+ if (!this.isConnected || generation !== this.lifecycleGeneration) return;
122
+ if (this.hasUpdated) {
123
+ this.observeAmbientTheme();
124
+ this.syncRenderer(this.loading ? 'loading' : null);
125
+ }
126
+ }
127
+
128
+ public async disconnectedCallback(): Promise<void> {
129
+ const generation = ++this.lifecycleGeneration;
130
+ this.cleanupOwnedResources();
131
+ await super.disconnectedCallback();
132
+ if (!this.isConnected && generation === this.lifecycleGeneration) {
133
+ this.cleanupOwnedResources();
134
+ }
135
+ }
136
+
137
+ private cleanupOwnedResources(): void {
138
+ this.removeEventListener('mouseenter', this.handlePointerEnter);
139
+ window.removeEventListener('resize', this.handleResize);
140
+ this.stopAmbientThemeObservation();
141
+ this.stopReducedMotionObservation();
142
+ this.renderer?.stop();
143
+ }
144
+
145
+ public render(): TemplateResult {
146
+ const height = this.normalizedSize;
147
+ const width = this.square ? height : Math.round(height * sioLogoAspect);
148
+ return html`<canvas aria-hidden="true" style="width:${width}px;height:${height}px"></canvas>`;
149
+ }
150
+
151
+ public firstUpdated(changedProperties: Map<PropertyKey, unknown>): void {
152
+ super.firstUpdated(changedProperties);
153
+ const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>('canvas');
154
+ const context = canvas?.getContext('2d');
155
+ if (!context) return;
156
+ this.renderer = new SioLogoRenderer(context, this.getRendererOptions());
157
+ if (!this.isConnected) {
158
+ this.renderer.drawStatic();
159
+ return;
160
+ }
161
+ this.observeAmbientTheme();
162
+ if (this.loading && !this.prefersReducedMotion()) this.renderer.start('loading');
163
+ else if (!this.noEntrance && !this.prefersReducedMotion()) this.renderer.start('in');
164
+ else this.renderer.drawStatic();
165
+ }
166
+
167
+ public updated(changedProperties: Map<PropertyKey, unknown>): void {
168
+ if (!this.isConnected) {
169
+ this.stopAmbientThemeObservation();
170
+ this.renderer?.stop();
171
+ return;
172
+ }
173
+ const redrawProperties: PropertyKey[] = [
174
+ 'size',
175
+ 'gap',
176
+ 'radius',
177
+ 'square',
178
+ 'background',
179
+ 'mono',
180
+ 'theme',
181
+ ];
182
+ if (redrawProperties.some((propertyName) => changedProperties.has(propertyName))) {
183
+ this.syncRenderer(this.loading ? 'loading' : null);
184
+ }
185
+ if (changedProperties.has('theme')) {
186
+ this.stopAmbientThemeObservation();
187
+ this.observeAmbientTheme();
188
+ }
189
+ if (changedProperties.has('loading')) {
190
+ this.syncRenderer(this.loading ? 'loading' : null);
191
+ }
192
+ }
193
+
194
+ protected themeChanged(): void {
195
+ this.syncRenderer(this.loading ? 'loading' : null);
196
+ }
197
+
198
+ public play(): void {
199
+ if (!this.renderer || !this.isConnected) return;
200
+ if (this.prefersReducedMotion()) this.renderer.drawStatic();
201
+ else this.renderer.start('in');
202
+ }
203
+
204
+ public wave(): void {
205
+ if (!this.renderer || !this.isConnected) return;
206
+ if (this.prefersReducedMotion()) this.renderer.drawStatic();
207
+ else this.renderer.start('wave');
208
+ }
209
+
210
+ public dismiss(): void {
211
+ if (!this.renderer || !this.isConnected) return;
212
+ if (this.prefersReducedMotion()) this.renderer.clear();
213
+ else this.renderer.start('out');
214
+ }
215
+
216
+ private get normalizedSize(): number {
217
+ return Number.isFinite(this.size) ? Math.max(1, this.size) : 40;
218
+ }
219
+
220
+ private getRendererOptions() {
221
+ this.lastDevicePixelRatio = window.devicePixelRatio || 1;
222
+ return {
223
+ size: this.normalizedSize,
224
+ dpr: this.lastDevicePixelRatio,
225
+ gap: Number.isFinite(this.gap) ? Math.max(0, this.gap) : 2,
226
+ radius: Number.isFinite(this.radius) ? Math.max(0, this.radius) : 16,
227
+ square: this.square,
228
+ background: this.background,
229
+ mono: this.mono,
230
+ light: this.isLightBackdrop(),
231
+ };
232
+ }
233
+
234
+ private syncRenderer(animation: 'loading' | null = null): void {
235
+ if (!this.renderer) return;
236
+ this.renderer.stop();
237
+ this.renderer.setOptions(this.getRendererOptions());
238
+ if (animation && this.isConnected && !this.prefersReducedMotion()) {
239
+ this.renderer.start(animation);
240
+ }
241
+ else this.renderer.drawStatic();
242
+ }
243
+
244
+ private prefersReducedMotion(): boolean {
245
+ return this.reducedMotionMedia?.matches ??
246
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ??
247
+ false;
248
+ }
249
+
250
+ private isLightBackdrop(): boolean {
251
+ if (this.theme === 'light') return true;
252
+ if (this.theme === 'dark') return false;
253
+
254
+ let element: Element | null = this;
255
+ while (element) {
256
+ const color = getComputedStyle(element).backgroundColor;
257
+ const match = color.match(
258
+ /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)/,
259
+ );
260
+ if (match) {
261
+ const alpha = match[4] === undefined ? 1 : Number.parseFloat(match[4]);
262
+ if (alpha > 0.2) {
263
+ const luminance =
264
+ (0.2126 * Number(match[1]) +
265
+ 0.7152 * Number(match[2]) +
266
+ 0.0722 * Number(match[3])) /
267
+ 255;
268
+ return luminance > 0.55;
269
+ }
270
+ }
271
+ const root = element.getRootNode();
272
+ element = element.parentElement || (root instanceof ShadowRoot ? root.host : null);
273
+ }
274
+ return window.matchMedia?.('(prefers-color-scheme: light)').matches ?? false;
275
+ }
276
+
277
+ private observeAmbientTheme(): void {
278
+ if (this.theme !== 'auto' || this.themeObserver) return;
279
+ this.themeObserver = new MutationObserver(this.handleAmbientChange);
280
+ this.themeObserver.observe(document.documentElement, {
281
+ attributes: true,
282
+ attributeFilter: ['data-sio-theme', 'data-theme', 'class', 'style'],
283
+ });
284
+ this.colorSchemeMedia = window.matchMedia?.('(prefers-color-scheme: light)') ?? null;
285
+ this.colorSchemeMedia?.addEventListener('change', this.handleAmbientChange);
286
+ }
287
+
288
+ private stopAmbientThemeObservation(): void {
289
+ this.themeObserver?.disconnect();
290
+ this.themeObserver = null;
291
+ this.colorSchemeMedia?.removeEventListener('change', this.handleAmbientChange);
292
+ this.colorSchemeMedia = null;
293
+ }
294
+
295
+ private observeReducedMotion(): void {
296
+ if (this.reducedMotionMedia) return;
297
+ this.reducedMotionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
298
+ this.reducedMotionMedia?.addEventListener('change', this.handleReducedMotionChange);
299
+ }
300
+
301
+ private stopReducedMotionObservation(): void {
302
+ this.reducedMotionMedia?.removeEventListener('change', this.handleReducedMotionChange);
303
+ this.reducedMotionMedia = null;
304
+ }
305
+
306
+ private ensureAccessibleHost(): void {
307
+ if (this.hasAttribute('aria-hidden')) return;
308
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
309
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', 'social.io');
310
+ }
311
+ }
@@ -52,8 +52,8 @@ const calendarChannel: ISioTabBarChannel = {
52
52
  *
53
53
  * A liquid-glass bar (blur 28px saturate 180%, 0.5px top hairline) with one
54
54
  * flex-equal tab per channel: 22px icon over a 10.5px label. The active tab
55
- * lights up in its channel color; 30px bottom padding clears the iOS home
56
- * indicator. Selecting a tab dispatches `tab-change` with `{ id, channel }`
55
+ * lights up in its channel color; safe-area-aware bottom padding clears the
56
+ * iOS home indicator. Selecting a tab dispatches `tab-change` with `{ id, channel }`
57
57
  * (bubbles, composed).
58
58
  */
59
59
  @customElement('sio-tab-bar')
@@ -107,7 +107,7 @@ export class SioTabBar extends DeesElement {
107
107
  /* toolbar glass, hairline on the top edge (the bar sits at the bottom) */
108
108
  .bar {
109
109
  display: flex;
110
- padding: 8px 0 30px; /* 30px clears the iOS home indicator */
110
+ padding: 8px 0 max(30px, var(--sio-safe-area-bottom, env(safe-area-inset-bottom, 0px)));
111
111
  background: var(--sio-toolbar-glass-bg);
112
112
  backdrop-filter: blur(28px) saturate(180%);
113
113
  -webkit-backdrop-filter: blur(28px) saturate(180%);