@social.io/catalog 2.0.0 → 2.1.1

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,527 @@
1
+ import {
2
+ DeesElement,
3
+ css,
4
+ customElement,
5
+ directives,
6
+ html,
7
+ property,
8
+ state,
9
+ type TemplateResult,
10
+ } from '@design.estate/dees-element';
11
+ import {
12
+ SioLogoRenderer,
13
+ sioLogoAspect,
14
+ type ISioLogoRenderOptions,
15
+ type TSioLogoAnimationPhase,
16
+ } from './sio-logo-render.js';
17
+ import { sioElementStyles } from './tokens.js';
18
+
19
+ export type TSioLogoTheme = 'auto' | 'light' | 'dark';
20
+
21
+ type TSioLogoBackendState =
22
+ | 'idle'
23
+ | 'worker-loading'
24
+ | 'worker-transferred'
25
+ | 'worker'
26
+ | 'main';
27
+
28
+ type TSioLogoPhaseCommand = TSioLogoAnimationPhase | 'stop' | 'clear';
29
+
30
+ declare global {
31
+ interface HTMLElementTagNameMap {
32
+ 'sio-logo': SioLogo;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The social.io faceted S brand mark from the v8 design prototype.
38
+ *
39
+ * Geometry and animation run on an OffscreenCanvas in a module worker when the
40
+ * browser and consumer bundle support it. The same renderer is used on the
41
+ * main thread as a safe fallback.
42
+ */
43
+ @customElement('sio-logo')
44
+ export class SioLogo extends DeesElement {
45
+ public static demo = () => html`
46
+ <div style="display:flex;align-items:center;gap:32px;padding:24px;background:#101012;">
47
+ <sio-logo size="120" theme="dark"></sio-logo>
48
+ <sio-logo size="120" square background="#556270" radius="22" theme="dark"></sio-logo>
49
+ <sio-logo size="120" loading theme="dark"></sio-logo>
50
+ <sio-logo size="120" mono="#ffffff" no-entrance theme="dark"></sio-logo>
51
+ </div>
52
+ `;
53
+
54
+ /** rendered height in CSS pixels; tight-crop width follows the brand aspect */
55
+ @property({ type: Number })
56
+ public accessor size = 40;
57
+
58
+ @property({ type: String })
59
+ public accessor background = 'transparent';
60
+
61
+ /** inward inset per facet, in 500px design-grid units */
62
+ @property({ type: Number })
63
+ public accessor gap = 2;
64
+
65
+ /** facet corner radius, in 500px design-grid units */
66
+ @property({ type: Number })
67
+ public accessor radius = 16;
68
+
69
+ /** render on a square tile instead of the tight S crop */
70
+ @property({ type: Boolean })
71
+ public accessor square = false;
72
+
73
+ /** collapse the palette to one color while retaining facet depth */
74
+ @property({ type: String })
75
+ public accessor mono = '';
76
+
77
+ /** backdrop palette; auto follows the nearest opaque surface */
78
+ @property({ type: String })
79
+ public accessor theme: TSioLogoTheme = 'auto';
80
+
81
+ @property({ type: Boolean, reflect: true })
82
+ public accessor loading = false;
83
+
84
+ @property({ type: Boolean, attribute: 'no-entrance' })
85
+ public accessor noEntrance = false;
86
+
87
+ @property({ type: Boolean, attribute: 'no-hover' })
88
+ public accessor noHover = false;
89
+
90
+ @state()
91
+ private accessor canvasGeneration = 0;
92
+
93
+ public static styles = [
94
+ ...sioElementStyles,
95
+ css`
96
+ :host {
97
+ display: inline-flex;
98
+ flex: none;
99
+ line-height: 0;
100
+ }
101
+ canvas {
102
+ display: block;
103
+ }
104
+ `,
105
+ ];
106
+
107
+ private backendState: TSioLogoBackendState = 'idle';
108
+ private worker: Worker | null = null;
109
+ private renderer: SioLogoRenderer | null = null;
110
+ private canvasTransferred = false;
111
+ private forceMainThread = false;
112
+ private initializationTimeout: ReturnType<typeof setTimeout> | null = null;
113
+ private initializationGeneration = 0;
114
+ private lifecycleGeneration = 0;
115
+ private initialUpdateHandled = false;
116
+ private queuedOptions = false;
117
+ private queuedPhase: TSioLogoPhaseCommand | null = null;
118
+ private themeObserver: MutationObserver | null = null;
119
+ private reducedMotionMedia: MediaQueryList | null = null;
120
+ private lastDevicePixelRatio = 0;
121
+ private lastLight: boolean | null = null;
122
+
123
+ private readonly handlePointerEnter = (): void => {
124
+ if (!this.noHover && !this.loading && !this.prefersReducedMotion()) this.wave();
125
+ };
126
+
127
+ private readonly handleAmbientChange = (): void => {
128
+ if (this.theme !== 'auto') return;
129
+ const light = this.isLightBackdrop();
130
+ if (light === this.lastLight) return;
131
+ this.lastLight = light;
132
+ this.syncRendererOptions();
133
+ };
134
+
135
+ private readonly handleReducedMotionChange = (): void => {
136
+ if (this.prefersReducedMotion()) this.sendPhase('stop');
137
+ else if (this.loading) this.sendPhase('loading');
138
+ };
139
+
140
+ private readonly handleResize = (): void => {
141
+ const devicePixelRatio = window.devicePixelRatio || 1;
142
+ if (devicePixelRatio === this.lastDevicePixelRatio) return;
143
+ this.syncRendererOptions();
144
+ };
145
+
146
+ public async connectedCallback(): Promise<void> {
147
+ const generation = ++this.lifecycleGeneration;
148
+ const connection = super.connectedCallback();
149
+ this.ensureAccessibleHost();
150
+ this.addEventListener('mouseenter', this.handlePointerEnter);
151
+ window.addEventListener('resize', this.handleResize);
152
+ this.observeReducedMotion();
153
+ await connection;
154
+ await this.updateComplete;
155
+ if (!this.isConnected || generation !== this.lifecycleGeneration) return;
156
+ this.observeAmbientTheme();
157
+ if (this.hasUpdated && this.backendState === 'idle') this.initializeCanvas();
158
+ }
159
+
160
+ public async disconnectedCallback(): Promise<void> {
161
+ const generation = ++this.lifecycleGeneration;
162
+ this.cleanupOwnedResources(true);
163
+ await super.disconnectedCallback();
164
+ if (!this.isConnected && generation === this.lifecycleGeneration) {
165
+ this.cleanupOwnedResources(true);
166
+ }
167
+ }
168
+
169
+ private cleanupOwnedResources(replaceTransferredCanvas: boolean): void {
170
+ this.removeEventListener('mouseenter', this.handlePointerEnter);
171
+ window.removeEventListener('resize', this.handleResize);
172
+ this.stopAmbientThemeObservation();
173
+ this.stopReducedMotionObservation();
174
+ this.destroyBackend(replaceTransferredCanvas);
175
+ }
176
+
177
+ public render(): TemplateResult {
178
+ const height = this.normalizedSize;
179
+ const width = this.square ? height : Math.round(height * sioLogoAspect);
180
+ return html`${directives.keyed(
181
+ this.canvasGeneration,
182
+ html`<canvas aria-hidden="true" style="width:${width}px;height:${height}px"></canvas>`,
183
+ )}`;
184
+ }
185
+
186
+ public firstUpdated(changedProperties: Map<PropertyKey, unknown>): void {
187
+ super.firstUpdated(changedProperties);
188
+ this.observeAmbientTheme();
189
+ this.initializeCanvas();
190
+ }
191
+
192
+ public updated(changedProperties: Map<PropertyKey, unknown>): void {
193
+ if (!this.initialUpdateHandled) {
194
+ this.initialUpdateHandled = true;
195
+ return;
196
+ }
197
+ if (!this.isConnected) return;
198
+ if (changedProperties.has('canvasGeneration')) {
199
+ this.initializeCanvas();
200
+ return;
201
+ }
202
+
203
+ const shapeProperties: PropertyKey[] = [
204
+ 'size',
205
+ 'gap',
206
+ 'radius',
207
+ 'square',
208
+ 'background',
209
+ 'mono',
210
+ ];
211
+ if (shapeProperties.some((propertyName) => changedProperties.has(propertyName))) {
212
+ this.syncRendererOptions();
213
+ }
214
+ if (changedProperties.has('theme')) {
215
+ this.stopAmbientThemeObservation();
216
+ this.observeAmbientTheme();
217
+ this.syncRendererOptions();
218
+ }
219
+ if (changedProperties.has('loading')) {
220
+ this.sendPhase(
221
+ this.loading && !this.prefersReducedMotion() ? 'loading' : 'stop',
222
+ );
223
+ }
224
+ }
225
+
226
+ protected themeChanged(): void {
227
+ this.handleAmbientChange();
228
+ }
229
+
230
+ public play(): void {
231
+ if (!this.isConnected) return;
232
+ this.sendPhase(this.prefersReducedMotion() ? 'stop' : 'in');
233
+ }
234
+
235
+ public wave(): void {
236
+ if (!this.isConnected) return;
237
+ this.sendPhase(this.prefersReducedMotion() ? 'stop' : 'wave');
238
+ }
239
+
240
+ public dismiss(): void {
241
+ if (!this.isConnected) return;
242
+ this.sendPhase(this.prefersReducedMotion() ? 'clear' : 'out');
243
+ }
244
+
245
+ private initializeCanvas(): void {
246
+ if (!this.isConnected || this.backendState !== 'idle') return;
247
+ const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>('canvas');
248
+ if (!canvas) return;
249
+ const generation = ++this.initializationGeneration;
250
+ const supportsWorker =
251
+ !this.forceMainThread &&
252
+ typeof Worker === 'function' &&
253
+ typeof canvas.transferControlToOffscreen === 'function';
254
+
255
+ if (!supportsWorker) {
256
+ this.initializeMainThread(canvas, generation);
257
+ return;
258
+ }
259
+
260
+ try {
261
+ const worker = new Worker(new URL('./sio-logo-worker.js', import.meta.url), {
262
+ type: 'module',
263
+ name: 'sio-logo',
264
+ });
265
+ this.worker = worker;
266
+ this.backendState = 'worker-loading';
267
+ worker.onmessage = (event: MessageEvent<{ type?: string }>): void => {
268
+ if (event.data?.type === 'ready') {
269
+ this.handleWorkerReady(worker, canvas, generation);
270
+ } else if (event.data?.type === 'initialized') {
271
+ this.handleWorkerInitialized(worker, generation);
272
+ }
273
+ };
274
+ worker.onerror = (): void => this.handleWorkerFailure(worker, canvas, generation);
275
+ worker.onmessageerror = (): void => this.handleWorkerFailure(worker, canvas, generation);
276
+ this.armInitializationTimeout(worker, canvas, generation);
277
+ } catch {
278
+ this.forceMainThread = true;
279
+ this.initializeMainThread(canvas, generation);
280
+ }
281
+ }
282
+
283
+ private handleWorkerReady(
284
+ worker: Worker,
285
+ canvas: HTMLCanvasElement,
286
+ generation: number,
287
+ ): void {
288
+ if (!this.isCurrentWorker(worker, generation) || this.backendState !== 'worker-loading') return;
289
+ this.clearInitializationTimeout();
290
+ try {
291
+ const offscreenCanvas = canvas.transferControlToOffscreen();
292
+ this.canvasTransferred = true;
293
+ this.backendState = 'worker-transferred';
294
+ this.lastLight = this.isLightBackdrop();
295
+ worker.postMessage(
296
+ {
297
+ type: 'init',
298
+ canvas: offscreenCanvas,
299
+ options: this.getRendererOptions(),
300
+ phase: this.getInitialPhase(),
301
+ },
302
+ [offscreenCanvas],
303
+ );
304
+ this.queuedOptions = false;
305
+ this.armInitializationTimeout(worker, canvas, generation);
306
+ } catch {
307
+ this.handleWorkerFailure(worker, canvas, generation);
308
+ }
309
+ }
310
+
311
+ private handleWorkerInitialized(worker: Worker, generation: number): void {
312
+ if (!this.isCurrentWorker(worker, generation) || this.backendState !== 'worker-transferred') return;
313
+ this.clearInitializationTimeout();
314
+ this.backendState = 'worker';
315
+ if (this.queuedOptions) {
316
+ this.queuedOptions = false;
317
+ this.syncRendererOptions();
318
+ }
319
+ if (this.queuedPhase) {
320
+ const phase = this.queuedPhase;
321
+ this.queuedPhase = null;
322
+ this.sendPhase(phase);
323
+ }
324
+ }
325
+
326
+ private handleWorkerFailure(
327
+ worker: Worker,
328
+ canvas: HTMLCanvasElement,
329
+ generation: number,
330
+ ): void {
331
+ if (!this.isCurrentWorker(worker, generation)) return;
332
+ const requiresNewCanvas = this.canvasTransferred;
333
+ this.forceMainThread = true;
334
+ this.destroyBackend(requiresNewCanvas);
335
+ if (!requiresNewCanvas && this.isConnected) {
336
+ this.initializeMainThread(canvas, generation);
337
+ }
338
+ }
339
+
340
+ private initializeMainThread(canvas: HTMLCanvasElement, generation: number): void {
341
+ if (!this.isConnected || generation !== this.initializationGeneration) return;
342
+ const context = canvas.getContext('2d');
343
+ if (!context) return;
344
+ this.backendState = 'main';
345
+ const options = this.getRendererOptions();
346
+ this.lastLight = options.light;
347
+ this.renderer = new SioLogoRenderer(context, options);
348
+ const phase = this.getInitialPhase();
349
+ if (phase) this.renderer.start(phase);
350
+ else this.renderer.drawStatic();
351
+ this.queuedOptions = false;
352
+ if (this.queuedPhase) {
353
+ const queuedPhase = this.queuedPhase;
354
+ this.queuedPhase = null;
355
+ this.sendPhase(queuedPhase);
356
+ }
357
+ }
358
+
359
+ private armInitializationTimeout(
360
+ worker: Worker,
361
+ canvas: HTMLCanvasElement,
362
+ generation: number,
363
+ ): void {
364
+ this.clearInitializationTimeout();
365
+ this.initializationTimeout = setTimeout(() => {
366
+ this.handleWorkerFailure(worker, canvas, generation);
367
+ }, 2000);
368
+ }
369
+
370
+ private clearInitializationTimeout(): void {
371
+ if (this.initializationTimeout === null) return;
372
+ clearTimeout(this.initializationTimeout);
373
+ this.initializationTimeout = null;
374
+ }
375
+
376
+ private isCurrentWorker(worker: Worker, generation: number): boolean {
377
+ return this.isConnected &&
378
+ this.worker === worker &&
379
+ generation === this.initializationGeneration;
380
+ }
381
+
382
+ private destroyBackend(replaceTransferredCanvas: boolean): void {
383
+ const transferred = this.canvasTransferred;
384
+ this.clearInitializationTimeout();
385
+ if (this.worker) {
386
+ this.worker.onmessage = null;
387
+ this.worker.onerror = null;
388
+ this.worker.onmessageerror = null;
389
+ this.worker.terminate();
390
+ }
391
+ this.worker = null;
392
+ this.renderer?.stop();
393
+ this.renderer = null;
394
+ this.backendState = 'idle';
395
+ this.canvasTransferred = false;
396
+ this.queuedOptions = false;
397
+ this.queuedPhase = null;
398
+ if (replaceTransferredCanvas && transferred) this.canvasGeneration += 1;
399
+ }
400
+
401
+ private get normalizedSize(): number {
402
+ return Number.isFinite(this.size) ? Math.max(1, this.size) : 40;
403
+ }
404
+
405
+ private getRendererOptions(): ISioLogoRenderOptions {
406
+ this.lastDevicePixelRatio = window.devicePixelRatio || 1;
407
+ return {
408
+ size: this.normalizedSize,
409
+ dpr: this.lastDevicePixelRatio,
410
+ gap: Number.isFinite(this.gap) ? Math.max(0, this.gap) : 2,
411
+ radius: Number.isFinite(this.radius) ? Math.max(0, this.radius) : 16,
412
+ square: this.square,
413
+ background: this.background,
414
+ mono: this.mono,
415
+ light: this.isLightBackdrop(),
416
+ };
417
+ }
418
+
419
+ private getInitialPhase(): TSioLogoAnimationPhase | null {
420
+ if (!this.isConnected || this.prefersReducedMotion()) return null;
421
+ if (this.loading) return 'loading';
422
+ return this.noEntrance ? null : 'in';
423
+ }
424
+
425
+ private syncRendererOptions(): void {
426
+ const options = this.getRendererOptions();
427
+ this.lastLight = options.light;
428
+ if (this.backendState === 'worker') {
429
+ this.worker?.postMessage({ type: 'options', options });
430
+ } else if (this.backendState === 'main') {
431
+ this.renderer?.setOptions(options);
432
+ } else {
433
+ this.queuedOptions = true;
434
+ }
435
+ }
436
+
437
+ private sendPhase(phase: TSioLogoPhaseCommand): void {
438
+ if (this.backendState === 'worker') {
439
+ this.worker?.postMessage({ type: 'phase', phase });
440
+ return;
441
+ }
442
+ if (this.backendState === 'main' && this.renderer) {
443
+ if (phase === 'stop') {
444
+ this.renderer.stop();
445
+ this.renderer.drawStatic();
446
+ } else if (phase === 'clear') {
447
+ this.renderer.clear();
448
+ } else {
449
+ this.renderer.start(phase);
450
+ }
451
+ return;
452
+ }
453
+ this.queuedPhase = phase;
454
+ }
455
+
456
+ private prefersReducedMotion(): boolean {
457
+ return this.reducedMotionMedia?.matches ??
458
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ??
459
+ false;
460
+ }
461
+
462
+ private isLightBackdrop(): boolean {
463
+ if (this.theme === 'light') return true;
464
+ if (this.theme === 'dark') return false;
465
+
466
+ let element: Element | null = this;
467
+ while (element) {
468
+ if (element === document.body || element === document.documentElement) {
469
+ const pageTheme =
470
+ document.documentElement.getAttribute('data-sio-theme') ||
471
+ document.documentElement.getAttribute('data-theme');
472
+ if (pageTheme === 'light') return true;
473
+ if (pageTheme === 'dark') return false;
474
+ }
475
+ const color = getComputedStyle(element).backgroundColor;
476
+ const match = color.match(
477
+ /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)/,
478
+ );
479
+ if (match) {
480
+ const alpha = match[4] === undefined ? 1 : Number.parseFloat(match[4]);
481
+ if (alpha > 0.2) {
482
+ const luminance =
483
+ (0.2126 * Number(match[1]) +
484
+ 0.7152 * Number(match[2]) +
485
+ 0.0722 * Number(match[3])) /
486
+ 255;
487
+ return luminance > 0.55;
488
+ }
489
+ }
490
+ const root = element.getRootNode();
491
+ element = element.parentElement || (root instanceof ShadowRoot ? root.host : null);
492
+ }
493
+
494
+ return false;
495
+ }
496
+
497
+ private observeAmbientTheme(): void {
498
+ if (this.theme !== 'auto' || this.themeObserver) return;
499
+ this.themeObserver = new MutationObserver(this.handleAmbientChange);
500
+ this.themeObserver.observe(document.documentElement, {
501
+ attributes: true,
502
+ attributeFilter: ['data-sio-theme', 'data-theme', 'class', 'style'],
503
+ });
504
+ }
505
+
506
+ private stopAmbientThemeObservation(): void {
507
+ this.themeObserver?.disconnect();
508
+ this.themeObserver = null;
509
+ }
510
+
511
+ private observeReducedMotion(): void {
512
+ if (this.reducedMotionMedia) return;
513
+ this.reducedMotionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
514
+ this.reducedMotionMedia?.addEventListener('change', this.handleReducedMotionChange);
515
+ }
516
+
517
+ private stopReducedMotionObservation(): void {
518
+ this.reducedMotionMedia?.removeEventListener('change', this.handleReducedMotionChange);
519
+ this.reducedMotionMedia = null;
520
+ }
521
+
522
+ private ensureAccessibleHost(): void {
523
+ if (this.hasAttribute('aria-hidden')) return;
524
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
525
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', 'social.io');
526
+ }
527
+ }
@@ -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%);