@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.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@social.io/catalog",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "private": false,
5
- "description": "v7 Apple-native design system and web components for social.io",
5
+ "description": "Apple-native v8 design system and web components for social.io",
6
6
  "exports": {
7
7
  ".": "./dist_ts_web/index.js"
8
8
  },
@@ -16,11 +16,11 @@
16
16
  },
17
17
  "devDependencies": {
18
18
  "@git.zone/tsbuild": "^4.4.2",
19
- "@git.zone/tsbundle": "^2.10.0",
19
+ "@git.zone/tsbundle": "^2.11.2",
20
20
  "@git.zone/tsrun": "^2.0.5",
21
21
  "@git.zone/tstest": "^3.6.6",
22
- "@git.zone/tswatch": "^3.3.2",
23
- "@types/node": "^26.1.0",
22
+ "@git.zone/tswatch": "^3.3.5",
23
+ "@types/node": "^26.1.1",
24
24
  "@design.estate/dees-wcctools": "^3.9.0"
25
25
  },
26
26
  "files": [
package/readme.hints.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @social.io/catalog — hints
2
2
 
3
- ## v2 architecture (v7 design program)
3
+ ## v2 architecture (v8 design program)
4
4
 
5
5
  - Pure web-component catalog: Lit via `@design.estate/dees-element` with
6
6
  standard TC39 decorators (`@property({ ... }) public accessor x`,
@@ -8,7 +8,7 @@
8
8
  media-recorder stack was removed.
9
9
  - Every element: `@customElement('sio-...')`, a
10
10
  `declare global { interface HTMLElementTagNameMap }` entry, a
11
- `public static demo = () => html\`...\`` with realistic v7 content, and
11
+ `public static demo = () => html\`...\`` with realistic v8 content, and
12
12
  `static styles = [...sioElementStyles, css\`...\`]`.
13
13
  - Entry points:
14
14
  - `ts_web/index.ts` → re-exports `ts_web/elements/index.ts` (the barrel:
@@ -54,9 +54,10 @@
54
54
  - Icons: `sio-icon` is a thin wrapper over the `lucide` npm package
55
55
  (never a CDN), with a small alias map (e.g. `envelope` → `mail`).
56
56
 
57
- ## Component inventory (27 files / 28 elements)
57
+ ## Component inventory (28 files / 29 elements)
58
58
 
59
- Foundation: `sio-icon`, `sio-button`, `sio-kbd`, `sio-chip`
59
+ Foundation: `sio-logo` (faceted canvas mark, theme/DPR/reduced-motion aware),
60
+ `sio-icon`, `sio-button`, `sio-kbd`, `sio-chip`
60
61
  (channel/lane/status palettes), `sio-avatar` + `sio-avatar-stack`,
61
62
  `sio-unread-badge`.
62
63
 
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @social.io/catalog
2
2
 
3
- The social.io v7 design system and web component library: Apple-native Lit components for the unified-inbox product family — feed rows, channel navigation, command palette, media primitives, and the embeddable chat-widget suite. Built on [@design.estate/dees-element](https://www.npmjs.com/package/@design.estate/dees-element) (Lit 3, standard TC39 decorators) with a CSS-custom-property token system and full light/dark theming.
3
+ The social.io v8 design system and web component library: Apple-native Lit components for the unified-inbox product family — the faceted social.io brand mark, feed rows, channel navigation, command palette, media primitives, and the embeddable chat-widget suite. Built on [@design.estate/dees-element](https://www.npmjs.com/package/@design.estate/dees-element) (Lit 3, standard TC39 decorators) with a CSS-custom-property token system and full light/dark theming.
4
4
 
5
5
  ## Issue Reporting and Security
6
6
 
@@ -14,7 +14,8 @@ pnpm add @social.io/catalog
14
14
 
15
15
  ## Design System
16
16
 
17
- Every component draws exclusively from the token layer in `tokens.ts`:
17
+ Product UI components draw from the token layer in `tokens.ts`; `sio-logo`
18
+ preserves the canonical v8 facet palette as brand artwork:
18
19
 
19
20
  - **Semantic surfaces** — `--sio-bg`, `--sio-surface`, `--sio-surface-2`, four label levels, and 0.5px hairline separators, switching between iOS-style light (`#F2F2F7`/`#FFFFFF`) and true-black dark (`#000`/`#1C1C1E`).
20
21
  - **Tint** — `--sio-tint` `#0A84FF`, deliberately brightened to `#409CFF` in dark mode, with hover and soft (translucent) variants.
@@ -42,10 +43,19 @@ public static styles = [
42
43
 
43
44
  Components auto-register as custom elements on import.
44
45
 
46
+ ```typescript
47
+ import '@social.io/catalog';
48
+ ```
49
+
50
+ ```html
51
+ <sio-logo size="30" theme="auto" no-entrance></sio-logo>
52
+ ```
53
+
45
54
  ### Foundation
46
55
 
47
56
  | Element | Purpose |
48
57
  | --- | --- |
58
+ | `sio-logo` | Faceted social.io S mark with tight/square layouts, palette modes, and reduced-motion-aware animations |
49
59
  | `sio-icon` | Lucide icon renderer (`icon`, `size`, `color`, `strokeWidth`) |
50
60
  | `sio-button` | Primary / secondary / destructive / outline / ghost variants, sm–lg sizes, status spinner |
51
61
  | `sio-avatar` / `sio-avatar-stack` | Initials-on-tint circles with presence dot, channel badge, overlapping stacks |
@@ -53,6 +63,24 @@ Components auto-register as custom elements on import.
53
63
  | `sio-unread-badge` | Count pills in channel colors, rail-sized variant |
54
64
  | `sio-kbd` | Keyboard-shortcut hint chip (mono, hairline border) |
55
65
 
66
+ #### `sio-logo`
67
+
68
+ | Property | Default | Purpose |
69
+ | --- | --- | --- |
70
+ | `size` | `40` | Mark height in CSS pixels; tight-crop width follows the brand aspect ratio |
71
+ | `background` | `transparent` | Canvas background color |
72
+ | `gap` | `2` | Inset between facets in the 500px design grid |
73
+ | `radius` | `16` | Facet corner radius in the 500px design grid |
74
+ | `square` | `false` | Render on a square tile instead of the tight S crop |
75
+ | `mono` | empty | Use one color while retaining facet depth |
76
+ | `theme` | `auto` | Select `auto`, `light`, or `dark` backdrop palette behavior |
77
+ | `loading` | `false` | Run the looping loading treatment |
78
+ | `no-entrance` | `false` | Disable the entrance animation |
79
+ | `no-hover` | `false` | Disable the pointer wave animation |
80
+
81
+ The public `play()`, `wave()`, and `dismiss()` methods trigger the entrance,
82
+ wave, and exit treatments. All motion respects `prefers-reduced-motion`.
83
+
56
84
  ### Chrome and navigation
57
85
 
58
86
  | Element | Purpose |
@@ -60,9 +88,14 @@ Components auto-register as custom elements on import.
60
88
  | `sio-glass-toolbar` | 44px glass bar with start/center/end slots |
61
89
  | `sio-segmented` | iOS-style segmented control with counts |
62
90
  | `sio-tab-bar` | Mobile bottom glass bar, channel tabs + optional calendar |
63
- | `sio-channel-rail` | 64px desktop rail with per-channel unread badges |
91
+ | `sio-channel-rail` | 64px desktop rail with the built-in v8 brand mark and per-channel unread badges |
64
92
  | `sio-dropdown-menu` | Slot-triggered menu with destructive/disabled items |
65
93
 
94
+ `sio-tab-bar` uses `max(30px, var(--sio-safe-area-bottom,
95
+ env(safe-area-inset-bottom, 0px)))` for its bottom padding. Set
96
+ `--sio-safe-area-bottom` when a screenshot or native shell supplies a known
97
+ system-reserved area.
98
+
66
99
  ### Lists and feed
67
100
 
68
101
  | Element | Purpose |
@@ -118,7 +151,7 @@ Every component ships a `static demo` rendered in the wcctools dashboard, groupe
118
151
 
119
152
  ## License and Legal Information
120
153
 
121
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license.md](./license.md) file.
154
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
122
155
 
123
156
  **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
124
157
 
@@ -130,7 +163,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
130
163
 
131
164
  ### Company Information
132
165
 
133
- Task Venture Capital GmbH
166
+ Task Venture Capital GmbH<br>
134
167
  Registered at District Court Bremen HRB 35230 HB, Germany
135
168
 
136
169
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@social.io/catalog',
6
- version: '2.0.0',
7
- description: 'v7 Apple-native design system and web components for social.io'
6
+ version: '2.1.1',
7
+ description: 'Apple-native v8 design system and web components for social.io'
8
8
  }
@@ -2,6 +2,7 @@
2
2
  export * from './tokens.js';
3
3
 
4
4
  // foundation
5
+ export * from './sio-logo.js';
5
6
  export * from './sio-icon.js';
6
7
  export * from './sio-button.js';
7
8
  export * from './sio-kbd.js';
@@ -8,6 +8,7 @@ import {
8
8
  } from '@design.estate/dees-element';
9
9
  import { sioElementStyles } from './tokens.js';
10
10
  import './sio-icon.js';
11
+ import './sio-logo.js';
11
12
 
12
13
  declare global {
13
14
  interface HTMLElementTagNameMap {
@@ -123,6 +124,15 @@ export class SioChannelRail extends DeesElement {
123
124
  border-right: var(--sio-hairline) solid var(--sio-separator);
124
125
  }
125
126
 
127
+ .brand {
128
+ height: 35px;
129
+ box-sizing: border-box;
130
+ display: flex;
131
+ align-items: flex-start;
132
+ justify-content: center;
133
+ padding: 2px 0 9px;
134
+ }
135
+
126
136
  .item {
127
137
  appearance: none;
128
138
  border: none;
@@ -204,6 +214,9 @@ export class SioChannelRail extends DeesElement {
204
214
  public render(): TemplateResult {
205
215
  return html`
206
216
  <nav class="rail" part="rail">
217
+ <div class="brand" part="brand">
218
+ <sio-logo size="24" no-entrance no-hover aria-hidden="true"></sio-logo>
219
+ </div>
207
220
  ${this.channels.map(
208
221
  (channel) => html`
209
222
  <button
@@ -0,0 +1,388 @@
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
+ export type TSioLogoRenderContext =
15
+ | CanvasRenderingContext2D
16
+ | OffscreenCanvasRenderingContext2D;
17
+
18
+ interface ISioLogoPoint {
19
+ x: number;
20
+ y: number;
21
+ }
22
+
23
+ interface ISioLogoFacet {
24
+ fill: string;
25
+ points: [[number, number], [number, number], [number, number]];
26
+ }
27
+
28
+ const facets: ISioLogoFacet[] = [
29
+ { fill: '#4AA0F5', points: [[250, 75], [150, 133.333], [250, 191.667]] },
30
+ { fill: '#0A5AC0', points: [[250, 75], [350, 133.333], [250, 191.667]] },
31
+ { fill: '#DBEBFF', points: [[150, 133.333], [250, 191.667], [150, 250]] },
32
+ { fill: '#A9CDF7', points: [[250, 191.667], [150, 250], [250, 308.333]] },
33
+ { fill: '#1E80E6', points: [[250, 191.667], [350, 250], [250, 308.333]] },
34
+ { fill: '#1466C6', points: [[350, 250], [250, 308.333], [350, 366.667]] },
35
+ { fill: '#74B4F7', points: [[250, 308.333], [350, 366.667], [250, 425]] },
36
+ { fill: '#1E80E6', points: [[250, 308.333], [150, 366.667], [250, 425]] },
37
+ ];
38
+
39
+ const lightFills = [
40
+ '#4AA0F5', '#0A5AC0', '#8FBEF5', '#77ACEE',
41
+ '#1E80E6', '#1466C6', '#5FA6F2', '#1E80E6',
42
+ ];
43
+ const monoOpacities = [0.78, 0.64, 0.96, 0.86, 0.72, 0.66, 0.82, 0.72];
44
+ const tightBounds = { x: 146, y: 72, width: 208, height: 356 };
45
+ const centerX = 250;
46
+ const centerY = 250;
47
+
48
+ export const sioLogoAspect = tightBounds.width / tightBounds.height;
49
+
50
+ const centroids = facets.map((facet) => [
51
+ (facet.points[0][0] + facet.points[1][0] + facet.points[2][0]) / 3,
52
+ (facet.points[0][1] + facet.points[1][1] + facet.points[2][1]) / 3,
53
+ ]);
54
+
55
+ const clamp = (value: number, minimum: number, maximum: number): number =>
56
+ Math.max(minimum, Math.min(maximum, value));
57
+
58
+ const finiteAtLeast = (value: number, minimum: number, fallback: number): number =>
59
+ Number.isFinite(value) ? Math.max(minimum, value) : fallback;
60
+
61
+ const normalizeOptions = (options: ISioLogoRenderOptions): ISioLogoRenderOptions => ({
62
+ ...options,
63
+ size: finiteAtLeast(options.size, 1, 40),
64
+ dpr: clamp(finiteAtLeast(options.dpr, 0.5, 1), 0.5, 4),
65
+ gap: finiteAtLeast(options.gap, 0, 2),
66
+ radius: finiteAtLeast(options.radius, 0, 16),
67
+ });
68
+
69
+ const inset = (
70
+ points: ISioLogoFacet['points'],
71
+ gap: number,
72
+ ): ISioLogoPoint[] => {
73
+ if (gap <= 0) return points.map(([x, y]) => ({ x, y }));
74
+ const centroidX = (points[0][0] + points[1][0] + points[2][0]) / 3;
75
+ const centroidY = (points[0][1] + points[1][1] + points[2][1]) / 3;
76
+ const lines: Array<ISioLogoPoint & { dx: number; dy: number }> = [];
77
+
78
+ for (let index = 0; index < points.length; index += 1) {
79
+ const start = points[index];
80
+ const end = points[(index + 1) % points.length];
81
+ let deltaX = end[0] - start[0];
82
+ let deltaY = end[1] - start[1];
83
+ const length = Math.hypot(deltaX, deltaY);
84
+ deltaX /= length;
85
+ deltaY /= length;
86
+ let normalX = -deltaY;
87
+ let normalY = deltaX;
88
+ const middleX = (start[0] + end[0]) / 2;
89
+ const middleY = (start[1] + end[1]) / 2;
90
+ if ((centroidX - middleX) * normalX + (centroidY - middleY) * normalY < 0) {
91
+ normalX = -normalX;
92
+ normalY = -normalY;
93
+ }
94
+ lines.push({
95
+ x: start[0] + normalX * gap,
96
+ y: start[1] + normalY * gap,
97
+ dx: deltaX,
98
+ dy: deltaY,
99
+ });
100
+ }
101
+
102
+ return lines.map((line, index) => {
103
+ const previous = lines[(index - 1 + lines.length) % lines.length];
104
+ const denominator = previous.dx * line.dy - previous.dy * line.dx;
105
+ const distance =
106
+ ((line.x - previous.x) * line.dy - (line.y - previous.y) * line.dx) /
107
+ denominator;
108
+ return {
109
+ x: previous.x + previous.dx * distance,
110
+ y: previous.y + previous.dy * distance,
111
+ };
112
+ });
113
+ };
114
+
115
+ const createFacetPath = (
116
+ points: ISioLogoFacet['points'],
117
+ gap: number,
118
+ radius: number,
119
+ ): Path2D => {
120
+ const insetPoints = inset(points, gap);
121
+ const path = new Path2D();
122
+ if (radius <= 0) {
123
+ path.moveTo(insetPoints[0].x, insetPoints[0].y);
124
+ for (let index = 1; index < insetPoints.length; index += 1) {
125
+ path.lineTo(insetPoints[index].x, insetPoints[index].y);
126
+ }
127
+ path.closePath();
128
+ return path;
129
+ }
130
+
131
+ for (let index = 0; index < insetPoints.length; index += 1) {
132
+ const vertex = insetPoints[index];
133
+ const previous = insetPoints[(index - 1 + insetPoints.length) % insetPoints.length];
134
+ const next = insetPoints[(index + 1) % insetPoints.length];
135
+ const previousDistance = Math.hypot(previous.x - vertex.x, previous.y - vertex.y);
136
+ const nextDistance = Math.hypot(next.x - vertex.x, next.y - vertex.y);
137
+ const previousRadius = Math.min(radius, previousDistance * 0.5);
138
+ const nextRadius = Math.min(radius, nextDistance * 0.5);
139
+ const entry = {
140
+ x: vertex.x + ((previous.x - vertex.x) / previousDistance) * previousRadius,
141
+ y: vertex.y + ((previous.y - vertex.y) / previousDistance) * previousRadius,
142
+ };
143
+ const exit = {
144
+ x: vertex.x + ((next.x - vertex.x) / nextDistance) * nextRadius,
145
+ y: vertex.y + ((next.y - vertex.y) / nextDistance) * nextRadius,
146
+ };
147
+ if (index === 0) path.moveTo(entry.x, entry.y);
148
+ else path.lineTo(entry.x, entry.y);
149
+ path.quadraticCurveTo(vertex.x, vertex.y, exit.x, exit.y);
150
+ }
151
+ path.closePath();
152
+ return path;
153
+ };
154
+
155
+ const easeOutCubic = (progress: number): number => 1 - Math.pow(1 - progress, 3);
156
+ const easeOutBack = (progress: number): number => {
157
+ const coefficient = 1.70158;
158
+ return 1 + (coefficient + 1) * Math.pow(progress - 1, 3) + coefficient * Math.pow(progress - 1, 2);
159
+ };
160
+ const easeInCubic = (progress: number): number => progress * progress * progress;
161
+ const easeInBack = (progress: number): number => {
162
+ const coefficient = 1.70158;
163
+ return (coefficient + 1) * progress * progress * progress - coefficient * progress * progress;
164
+ };
165
+
166
+ type TSioLogoFrameHandle = number | ReturnType<typeof setTimeout>;
167
+
168
+ const requestFrame = (callback: FrameRequestCallback): TSioLogoFrameHandle => {
169
+ if (typeof globalThis.requestAnimationFrame === 'function') {
170
+ return globalThis.requestAnimationFrame(callback);
171
+ }
172
+ return setTimeout(() => callback(performance.now()), 1000 / 60);
173
+ };
174
+
175
+ const cancelFrame = (handle: TSioLogoFrameHandle): void => {
176
+ if (typeof globalThis.cancelAnimationFrame === 'function' && typeof handle === 'number') {
177
+ globalThis.cancelAnimationFrame(handle);
178
+ return;
179
+ }
180
+ clearTimeout(handle);
181
+ };
182
+
183
+ export class SioLogoRenderer {
184
+ private options: ISioLogoRenderOptions;
185
+ private frameHandle: TSioLogoFrameHandle | null = null;
186
+ private phase: TSioLogoAnimationPhase | null = null;
187
+ private startedAt = 0;
188
+ private stagger = 70;
189
+ private duration = 560;
190
+ private totalDuration = 0;
191
+ private backingWidth = 0;
192
+ private backingHeight = 0;
193
+ private scale = 1;
194
+ private offsetX = 0;
195
+ private offsetY = 0;
196
+ private paths: Path2D[] = [];
197
+ private loadingPaths: Path2D[] = [];
198
+ private fills: string[] = [];
199
+ private opacities: number[] = [];
200
+
201
+ constructor(
202
+ private readonly context: TSioLogoRenderContext,
203
+ options: ISioLogoRenderOptions,
204
+ ) {
205
+ this.options = normalizeOptions(options);
206
+ this.rebuild();
207
+ }
208
+
209
+ public setOptions(options: ISioLogoRenderOptions): void {
210
+ this.options = normalizeOptions(options);
211
+ this.rebuild();
212
+ if (!this.phase) this.drawStatic();
213
+ }
214
+
215
+ public drawStatic(): void {
216
+ this.clearCanvas();
217
+ for (let index = 0; index < facets.length; index += 1) {
218
+ this.paint(index, 0, 0, 0, 1, this.opacities[index], this.paths[index]);
219
+ }
220
+ this.context.globalAlpha = 1;
221
+ }
222
+
223
+ public clear(): void {
224
+ this.stop();
225
+ this.clearCanvas();
226
+ }
227
+
228
+ public start(phase: TSioLogoAnimationPhase): void {
229
+ this.stop();
230
+ this.phase = phase;
231
+ this.startedAt = performance.now();
232
+ this.stagger = phase === 'wave' ? 55 : 70;
233
+ this.duration = phase === 'wave' ? 420 : 560;
234
+ this.totalDuration = this.stagger * (facets.length - 1) + this.duration;
235
+
236
+ const drawFrame = (time: number): void => {
237
+ if (!this.phase) return;
238
+ const elapsed = time - this.startedAt;
239
+ this.drawAnimationFrame(elapsed);
240
+ if (this.phase !== 'loading' && elapsed >= this.totalDuration) {
241
+ this.settle();
242
+ return;
243
+ }
244
+ this.frameHandle = requestFrame(drawFrame);
245
+ };
246
+ this.frameHandle = requestFrame(drawFrame);
247
+ }
248
+
249
+ public stop(): void {
250
+ if (this.frameHandle !== null) {
251
+ cancelFrame(this.frameHandle);
252
+ this.frameHandle = null;
253
+ }
254
+ this.phase = null;
255
+ }
256
+
257
+ private rebuild(): void {
258
+ const { size, dpr, square, light, mono, gap, radius } = this.options;
259
+ this.backingWidth = Math.round((square ? size : size * sioLogoAspect) * dpr);
260
+ this.backingHeight = Math.round(size * dpr);
261
+ this.context.canvas.width = this.backingWidth;
262
+ this.context.canvas.height = this.backingHeight;
263
+ if (square) {
264
+ this.scale = this.backingWidth / 500;
265
+ this.offsetX = 0;
266
+ this.offsetY = 0;
267
+ } else {
268
+ this.scale = this.backingHeight / tightBounds.height;
269
+ this.offsetX =
270
+ -tightBounds.x * this.scale +
271
+ (this.backingWidth - tightBounds.width * this.scale) / 2;
272
+ this.offsetY = -tightBounds.y * this.scale;
273
+ }
274
+ this.paths = facets.map((facet) => createFacetPath(facet.points, gap, radius));
275
+ this.loadingPaths = facets.map((facet) => createFacetPath(facet.points, gap + 3, 90));
276
+ this.fills = facets.map((facet, index) => mono || (light ? lightFills[index] : facet.fill));
277
+ this.opacities = facets.map((_, index) => (mono ? monoOpacities[index] : 1));
278
+ }
279
+
280
+ private clearCanvas(): void {
281
+ this.context.setTransform(1, 0, 0, 1, 0, 0);
282
+ this.context.clearRect(0, 0, this.backingWidth, this.backingHeight);
283
+ const { background } = this.options;
284
+ if (background && background !== 'transparent' && background !== 'none') {
285
+ this.context.fillStyle = background;
286
+ this.context.fillRect(0, 0, this.backingWidth, this.backingHeight);
287
+ }
288
+ }
289
+
290
+ private paint(
291
+ index: number,
292
+ translateX: number,
293
+ translateY: number,
294
+ rotation: number,
295
+ scale: number,
296
+ opacity: number,
297
+ path: Path2D,
298
+ ): void {
299
+ const [centroidX, centroidY] = centroids[index];
300
+ this.context.setTransform(this.scale, 0, 0, this.scale, this.offsetX, this.offsetY);
301
+ this.context.translate(translateX, translateY);
302
+ this.context.translate(centroidX, centroidY);
303
+ this.context.rotate((rotation * Math.PI) / 180);
304
+ this.context.scale(scale, scale);
305
+ this.context.translate(-centroidX, -centroidY);
306
+ this.context.globalAlpha = opacity;
307
+ this.context.fillStyle = this.fills[index];
308
+ this.context.fill(path);
309
+ }
310
+
311
+ private drawAnimationFrame(elapsed: number): void {
312
+ this.clearCanvas();
313
+ const activePhase = this.phase;
314
+ for (let index = 0; index < facets.length; index += 1) {
315
+ const [centroidX, centroidY] = centroids[index];
316
+ let translateX = 0;
317
+ let translateY = 0;
318
+ let rotation = 0;
319
+ let scale = 1;
320
+ let opacity = this.opacities[index];
321
+ let path = this.paths[index];
322
+
323
+ if (activePhase === 'in') {
324
+ const progress = clamp((elapsed - index * this.stagger) / this.duration, 0, 1);
325
+ const eased = easeOutCubic(progress);
326
+ translateX = (centerX - centroidX) * (1 - eased);
327
+ translateY = (centerY - centroidY) * (1 - eased);
328
+ rotation = (1 - eased) * -16;
329
+ scale = easeOutBack(progress);
330
+ opacity = clamp(progress * 1.6, 0, 1) * this.opacities[index];
331
+ } else if (activePhase === 'out') {
332
+ const reverseIndex = facets.length - 1 - index;
333
+ const progress = clamp((elapsed - reverseIndex * this.stagger) / this.duration, 0, 1);
334
+ const eased = easeInCubic(progress);
335
+ translateX = (centerX - centroidX) * eased;
336
+ translateY = (centerY - centroidY) * eased;
337
+ rotation = progress * 16;
338
+ scale = Math.max(0, 1 - easeInBack(progress));
339
+ opacity = clamp(1 - progress * 1.3, 0, 1) * this.opacities[index];
340
+ } else if (activePhase === 'wave') {
341
+ const progress = clamp((elapsed - index * this.stagger) / this.duration, 0, 1);
342
+ const pulse = Math.sin(progress * Math.PI);
343
+ const radialX = centroidX - centerX;
344
+ const radialY = centroidY - centerY;
345
+ const radialLength = Math.hypot(radialX, radialY) || 1;
346
+ translateX = (radialX / radialLength) * 16 * pulse;
347
+ translateY = (radialY / radialLength) * 16 * pulse;
348
+ scale = 1 - 0.08 * pulse;
349
+ } else if (activePhase === 'loading') {
350
+ const growth = clamp(elapsed / 650, 0, 1);
351
+ const easedGrowth = growth < 0.5
352
+ ? 2 * growth * growth
353
+ : 1 - Math.pow(-2 * growth + 2, 2) / 2;
354
+ const looping = elapsed >= 650;
355
+ const spin = (elapsed / 1500) * 2 * Math.PI;
356
+ const angle = -Math.PI / 2 + index * Math.PI / 4 + spin;
357
+ const destinationX = centerX + 120 * Math.cos(angle);
358
+ const destinationY = centerY + 120 * Math.sin(angle);
359
+ const brightness = 1 - index / facets.length;
360
+ translateX = (destinationX - centroidX) * easedGrowth;
361
+ translateY = (destinationY - centroidY) * easedGrowth;
362
+ scale =
363
+ (1 + (0.62 - 1) * easedGrowth) *
364
+ (looping ? 0.72 + 0.28 * brightness : 1);
365
+ opacity = (looping ? 0.28 + 0.72 * brightness : 1) * this.opacities[index];
366
+ path = looping
367
+ ? this.loadingPaths[index]
368
+ : createFacetPath(
369
+ facets[index].points,
370
+ this.options.gap + 3 * easedGrowth,
371
+ this.options.radius + (90 - this.options.radius) * easedGrowth,
372
+ );
373
+ }
374
+
375
+ this.paint(index, translateX, translateY, rotation, scale, opacity, path);
376
+ }
377
+ this.context.globalAlpha = 1;
378
+ }
379
+
380
+ private settle(): void {
381
+ const finishedPhase = this.phase;
382
+ this.frameHandle = null;
383
+ this.phase = null;
384
+ if (finishedPhase === 'out') this.clearCanvas();
385
+ else this.drawStatic();
386
+ this.context.globalAlpha = 1;
387
+ }
388
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ SioLogoRenderer,
3
+ type ISioLogoRenderOptions,
4
+ type TSioLogoAnimationPhase,
5
+ } from './sio-logo-render.js';
6
+
7
+ interface ISioLogoWorkerInitMessage {
8
+ type: 'init';
9
+ canvas: OffscreenCanvas;
10
+ options: ISioLogoRenderOptions;
11
+ phase: TSioLogoAnimationPhase | null;
12
+ }
13
+
14
+ interface ISioLogoWorkerOptionsMessage {
15
+ type: 'options';
16
+ options: ISioLogoRenderOptions;
17
+ }
18
+
19
+ interface ISioLogoWorkerPhaseMessage {
20
+ type: 'phase';
21
+ phase: TSioLogoAnimationPhase | 'stop' | 'clear';
22
+ }
23
+
24
+ type TSioLogoWorkerMessage =
25
+ | ISioLogoWorkerInitMessage
26
+ | ISioLogoWorkerOptionsMessage
27
+ | ISioLogoWorkerPhaseMessage;
28
+
29
+ interface ISioLogoWorkerScope {
30
+ onmessage: ((event: MessageEvent<TSioLogoWorkerMessage>) => void) | null;
31
+ postMessage: (message: { type: 'ready' | 'initialized' }) => void;
32
+ }
33
+
34
+ const workerScope = globalThis as unknown as ISioLogoWorkerScope;
35
+ let renderer: SioLogoRenderer | null = null;
36
+
37
+ workerScope.onmessage = (event): void => {
38
+ const message = event.data;
39
+ if (message.type === 'init') {
40
+ const context = message.canvas.getContext('2d');
41
+ if (!context) throw new Error('Unable to create the sio-logo worker canvas context.');
42
+ renderer = new SioLogoRenderer(context, message.options);
43
+ if (message.phase) renderer.start(message.phase);
44
+ else renderer.drawStatic();
45
+ workerScope.postMessage({ type: 'initialized' });
46
+ return;
47
+ }
48
+
49
+ if (!renderer) return;
50
+ if (message.type === 'options') {
51
+ renderer.setOptions(message.options);
52
+ return;
53
+ }
54
+
55
+ if (message.phase === 'stop') {
56
+ renderer.stop();
57
+ renderer.drawStatic();
58
+ } else if (message.phase === 'clear') {
59
+ renderer.clear();
60
+ } else {
61
+ renderer.start(message.phase);
62
+ }
63
+ };
64
+
65
+ // The canvas is transferred only after the module and its renderer dependency
66
+ // have loaded, leaving the HTMLCanvasElement intact for pre-transfer fallback.
67
+ workerScope.postMessage({ type: 'ready' });