@domicile-desktop/component-library 0.0.0-alpha-01b9340aef7f → 0.0.0-alpha-7a29c8f7f7ea

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
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-01b9340aef7f",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-01b9340aef7f",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-7a29c8f7f7ea",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-7a29c8f7f7ea",
7
7
  "@pandacss/dev": "^1.12.1",
8
8
  "@phosphor-icons/react": "^2.1.10"
9
9
  },
@@ -34,6 +34,19 @@
34
34
  },
35
35
  "exports": {
36
36
  "./Accordion": "./src/Accordion/Accordion.tsx",
37
+ "./AnimatedBellIcon": "./src/AnimatedBellIcon/AnimatedBellIcon.tsx",
38
+ "./AnimatedBluetoothIcon": "./src/AnimatedBluetoothIcon/AnimatedBluetoothIcon.tsx",
39
+ "./AnimatedChatIcon": "./src/AnimatedChatIcon/AnimatedChatIcon.tsx",
40
+ "./AnimatedCheckIcon": "./src/AnimatedCheckIcon/AnimatedCheckIcon.tsx",
41
+ "./AnimatedEyeIcon": "./src/AnimatedEyeIcon/AnimatedEyeIcon.tsx",
42
+ "./AnimatedGearIcon": "./src/AnimatedGearIcon/AnimatedGearIcon.tsx",
43
+ "./AnimatedKeyboardIcon": "./src/AnimatedKeyboardIcon/AnimatedKeyboardIcon.tsx",
44
+ "./AnimatedLightningIcon": "./src/AnimatedLightningIcon/AnimatedLightningIcon.tsx",
45
+ "./AnimatedMicrophoneIcon": "./src/AnimatedMicrophoneIcon/AnimatedMicrophoneIcon.tsx",
46
+ "./AnimatedSpeakerIcon": "./src/AnimatedSpeakerIcon/AnimatedSpeakerIcon.tsx",
47
+ "./AnimatedSquaresIcon": "./src/AnimatedSquaresIcon/AnimatedSquaresIcon.tsx",
48
+ "./AnimatedThemeIcon": "./src/AnimatedThemeIcon/AnimatedThemeIcon.tsx",
49
+ "./AnimatedWifiIcon": "./src/AnimatedWifiIcon/AnimatedWifiIcon.tsx",
37
50
  "./AppChooser": "./src/AppChooser/AppChooser.tsx",
38
51
  "./Autocomplete": "./src/Autocomplete/Autocomplete.tsx",
39
52
  "./Avatar": "./src/Avatar/Avatar.tsx",
@@ -133,5 +146,5 @@
133
146
  "test:unit": "bun test"
134
147
  },
135
148
  "type": "module",
136
- "version": "0.0.0-alpha-01b9340aef7f"
149
+ "version": "0.0.0-alpha-7a29c8f7f7ea"
137
150
  }
@@ -0,0 +1,66 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { BELL_FILL } from "../_icon/phosphor-parts";
5
+ import { useCue } from "../_icon/useCue";
6
+
7
+ /** Where the bell hangs from. */
8
+ const PIVOT = [128, 24] as const;
9
+
10
+ type Props = {
11
+ /** Swings the bell each time it changes, such as to a higher unread count. */
12
+ cue?: unknown;
13
+ /** Names the icon; without one it is decorative. */
14
+ label?: string | undefined;
15
+ /** Strikes the bell through, such as while do not disturb is on. */
16
+ off?: boolean | undefined;
17
+ size: number;
18
+ };
19
+
20
+ /**
21
+ * Phosphor's filled `BellSimple`. It swings, its clapper lagging, on each
22
+ * cue, and nudges while the control it is in is pointed at. A slash draws
23
+ * across it while it is `off`.
24
+ */
25
+ export const AnimatedBellIcon = ({ cue, label, off = false, size }: Props) => {
26
+ const cued = useCue(cue);
27
+ return (
28
+ <IconFrame label={label} size={size} slash={{ shown: off, weight: "fill" }}>
29
+ <IconPart
30
+ cue={cued}
31
+ cueStyles={bodySwingStyles}
32
+ d={BELL_FILL.body}
33
+ hoverStyles={bodyNudgeStyles}
34
+ name="body"
35
+ origin={PIVOT}
36
+ />
37
+ <IconPart
38
+ cue={cued}
39
+ cueStyles={clapperSwingStyles}
40
+ d={BELL_FILL.clapper}
41
+ name="clapper"
42
+ origin={PIVOT}
43
+ />
44
+ </IconFrame>
45
+ );
46
+ };
47
+
48
+ const bodySwingStyles = css({
49
+ "&[data-cued]": {
50
+ animation:
51
+ "iconBellSwing calc({durations.slowest} + {durations.normal}) {easings.linear}",
52
+ },
53
+ });
54
+
55
+ const clapperSwingStyles = css({
56
+ "&[data-cued]": {
57
+ animation:
58
+ "iconBellClapper calc({durations.slowest} + {durations.normal}) {easings.linear} {durations.fastest}",
59
+ },
60
+ });
61
+
62
+ const bodyNudgeStyles = css({
63
+ _controlHover: {
64
+ animation: "iconBellNudge {durations.slowest} {easings.in-out}",
65
+ },
66
+ });
@@ -0,0 +1,84 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { BLUETOOTH_BOLD } from "../_icon/phosphor-parts";
5
+
6
+ type Props = {
7
+ /** Names the icon; without one it is decorative. */
8
+ label?: string | undefined;
9
+ size: number;
10
+ /** Off is slashed; connected shows the dots beside the rune. */
11
+ state: "connected" | "off" | "on";
12
+ };
13
+
14
+ /**
15
+ * Phosphor's bold `Bluetooth`, `BluetoothConnected` and `BluetoothSlash` in
16
+ * one. The dots slide out of the rune when a device connects, back in when
17
+ * it goes, and blink while the control the icon is in is pointed at.
18
+ */
19
+ export const AnimatedBluetoothIcon = ({ label, size, state }: Props) => {
20
+ const dots = state === "connected" ? "shown" : "hidden";
21
+ return (
22
+ <IconFrame
23
+ label={label}
24
+ size={size}
25
+ slash={{ shown: state === "off", weight: "bold" }}
26
+ >
27
+ <IconPart d={BLUETOOTH_BOLD.rune} name="rune" />
28
+ <IconPart
29
+ d={BLUETOOTH_BOLD.dotStart}
30
+ hoverStyles={state === "connected" ? dotBlinkStyles : undefined}
31
+ name="dot"
32
+ origin={[56, 128]}
33
+ state={dots}
34
+ stateStyles={dotStartStyles}
35
+ />
36
+ <IconPart
37
+ d={BLUETOOTH_BOLD.dotEnd}
38
+ hoverStyles={state === "connected" ? dotBlinkStyles : undefined}
39
+ name="dot"
40
+ order={1}
41
+ origin={[208, 128]}
42
+ state={dots}
43
+ stateStyles={dotEndStyles}
44
+ />
45
+ </IconFrame>
46
+ );
47
+ };
48
+
49
+ const dotStartStyles = css({
50
+ "&[data-state=hidden]": {
51
+ opacity: 0,
52
+ transform: "translateX(28px) scale(0.3)",
53
+ transition:
54
+ "opacity {durations.normal} {easings.in}, transform {durations.normal} {easings.in}",
55
+ },
56
+ "&[data-state=shown]": {
57
+ opacity: 1,
58
+ transform: "none",
59
+ transition:
60
+ "opacity {durations.slow} {easings.out}, transform {durations.slower} {easings.outBack}",
61
+ },
62
+ });
63
+
64
+ const dotEndStyles = css({
65
+ "&[data-state=hidden]": {
66
+ opacity: 0,
67
+ transform: "translateX(-28px) scale(0.3)",
68
+ transition:
69
+ "opacity {durations.normal} {easings.in}, transform {durations.normal} {easings.in}",
70
+ },
71
+ "&[data-state=shown]": {
72
+ opacity: 1,
73
+ transform: "none",
74
+ transition:
75
+ "opacity {durations.slow} {easings.out}, transform {durations.slower} {easings.outBack}",
76
+ },
77
+ });
78
+
79
+ const dotBlinkStyles = css({
80
+ _controlHover: {
81
+ animation:
82
+ "iconDotBlink {durations.slower} {easings.in-out} calc(var(--part-order) * {durations.fast}) both",
83
+ },
84
+ });
@@ -0,0 +1,69 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { CHAT_BOLD } from "../_icon/phosphor-parts";
5
+ import { useCue } from "../_icon/useCue";
6
+
7
+ /** Where each line starts writing from. */
8
+ const LINE_STARTS = [
9
+ [84, 108],
10
+ [84, 148],
11
+ ] as const;
12
+
13
+ type Props = {
14
+ /** Plays each time it changes, such as to a higher unread count. */
15
+ cue?: unknown;
16
+ /** Names the icon; without one it is decorative. */
17
+ label?: string | undefined;
18
+ size: number;
19
+ };
20
+
21
+ /**
22
+ * Phosphor's bold `ChatCircleText`. On each cue the bubble tips on its tail
23
+ * and its lines write in, one after the other; pointed at, it tips.
24
+ */
25
+ export const AnimatedChatIcon = ({ cue, label, size }: Props) => {
26
+ const cued = useCue(cue);
27
+ return (
28
+ <IconFrame label={label} size={size}>
29
+ <IconPart
30
+ cue={cued}
31
+ cueStyles={bubbleTipStyles}
32
+ d={CHAT_BOLD.bubble}
33
+ hoverStyles={bubbleHoverStyles}
34
+ name="bubble"
35
+ origin={[32, 228]}
36
+ />
37
+ {CHAT_BOLD.lines.map((d, index) => (
38
+ <IconPart
39
+ cue={cued}
40
+ cueStyles={lineWriteStyles}
41
+ d={d}
42
+ key={d}
43
+ name="line"
44
+ order={index}
45
+ origin={LINE_STARTS[index]}
46
+ />
47
+ ))}
48
+ </IconFrame>
49
+ );
50
+ };
51
+
52
+ const bubbleTipStyles = css({
53
+ "&[data-cued]": {
54
+ animation: "iconBubbleTip {durations.slowest} {easings.in-out}",
55
+ },
56
+ });
57
+
58
+ const bubbleHoverStyles = css({
59
+ _controlHover: {
60
+ animation: "iconBubbleTip {durations.slower} {easings.in-out}",
61
+ },
62
+ });
63
+
64
+ const lineWriteStyles = css({
65
+ "&[data-cued]": {
66
+ animation:
67
+ "iconLineWrite {durations.slow} {easings.out} calc({durations.faster} + var(--part-order) * {durations.fast}) both",
68
+ },
69
+ });
@@ -0,0 +1,24 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { CHECK_BOLD } from "../_icon/phosphor-parts";
5
+
6
+ type Props = {
7
+ /** Names the icon; without one it is decorative. */
8
+ label?: string | undefined;
9
+ size: number;
10
+ };
11
+
12
+ /**
13
+ * Phosphor's bold `Check`. It draws from its start when it appears, such as
14
+ * when something has finished.
15
+ */
16
+ export const AnimatedCheckIcon = ({ label, size }: Props) => (
17
+ <IconFrame label={label} size={size}>
18
+ <IconPart d={CHECK_BOLD.check} name="check" stateStyles={drawStyles} />
19
+ </IconFrame>
20
+ );
21
+
22
+ const drawStyles = css({
23
+ animation: "iconCheckDraw {durations.slow} {easings.out}",
24
+ });
@@ -0,0 +1,96 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { EYE_BOLD } from "../_icon/phosphor-parts";
5
+
6
+ type Props = {
7
+ /** Names the icon; without one it is decorative. */
8
+ label?: string | undefined;
9
+ /** Phosphor's `Eye` while open, `EyeClosed` while shut. */
10
+ open: boolean;
11
+ size: number;
12
+ };
13
+
14
+ /**
15
+ * Phosphor's bold `Eye` and `EyeClosed` in one. Shutting blinks the eye
16
+ * closed; opening widens it and the pupil comes back. Pointed at while open,
17
+ * the pupil looks one way and the other.
18
+ */
19
+ export const AnimatedEyeIcon = ({ label, open, size }: Props) => {
20
+ const openState = open ? "shown" : "hidden";
21
+ return (
22
+ <IconFrame label={label} size={size}>
23
+ <IconPart
24
+ d={EYE_BOLD.outline}
25
+ name="outline"
26
+ state={openState}
27
+ stateStyles={outlineStyles}
28
+ />
29
+ <IconPart
30
+ d={EYE_BOLD.pupil}
31
+ hoverStyles={open ? pupilGlanceStyles : undefined}
32
+ name="pupil"
33
+ state={openState}
34
+ stateStyles={pupilStyles}
35
+ />
36
+ <IconPart
37
+ d={EYE_BOLD.closed}
38
+ name="closed"
39
+ state={open ? "hidden" : "shown"}
40
+ stateStyles={closedStyles}
41
+ />
42
+ </IconFrame>
43
+ );
44
+ };
45
+
46
+ // Shutting squeezes the outline flat, then lets it go.
47
+ const outlineStyles = css({
48
+ "&[data-state=hidden]": {
49
+ opacity: 0,
50
+ transform: "scaleY(0.08)",
51
+ transition:
52
+ "transform {durations.fast} {easings.in}, opacity {durations.fastest} {easings.linear} {durations.fast}",
53
+ },
54
+ "&[data-state=shown]": {
55
+ opacity: 1,
56
+ transform: "none",
57
+ transition:
58
+ "transform {durations.slow} {easings.outBack} {durations.faster}, opacity {durations.fastest} {easings.linear} {durations.faster}",
59
+ },
60
+ });
61
+
62
+ const pupilStyles = css({
63
+ "&[data-state=hidden]": {
64
+ opacity: 0,
65
+ transform: "scaleY(0.08)",
66
+ transition:
67
+ "opacity {durations.fast} {easings.in}, transform {durations.fast} {easings.in}",
68
+ },
69
+ "&[data-state=shown]": {
70
+ opacity: 1,
71
+ transform: "none",
72
+ transition:
73
+ "opacity {durations.slow} {easings.out} {durations.normal}, transform {durations.slow} {easings.outBack} {durations.normal}",
74
+ },
75
+ });
76
+
77
+ const closedStyles = css({
78
+ "&[data-state=hidden]": {
79
+ opacity: 0,
80
+ transform: "translateY(-10px) scaleY(0.5)",
81
+ transition:
82
+ "opacity {durations.fast} {easings.in}, transform {durations.fast} {easings.in}",
83
+ },
84
+ "&[data-state=shown]": {
85
+ opacity: 1,
86
+ transform: "none",
87
+ transition:
88
+ "opacity {durations.normal} {easings.out} {durations.fast}, transform {durations.normal} {easings.out} {durations.fast}",
89
+ },
90
+ });
91
+
92
+ const pupilGlanceStyles = css({
93
+ _controlHover: {
94
+ animation: "iconPupilGlance calc({durations.slowest} * 2) {easings.in-out}",
95
+ },
96
+ });
@@ -0,0 +1,26 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { GEAR_REGULAR } from "../_icon/phosphor-parts";
5
+
6
+ type Props = {
7
+ /** Names the icon; without one it is decorative. */
8
+ label?: string | undefined;
9
+ size: number;
10
+ };
11
+
12
+ /**
13
+ * Phosphor's regular `GearSix`. It turns one tooth and settles while the
14
+ * control it is in is pointed at.
15
+ */
16
+ export const AnimatedGearIcon = ({ label, size }: Props) => (
17
+ <IconFrame label={label} size={size}>
18
+ <IconPart d={GEAR_REGULAR.gear} hoverStyles={gearTurnStyles} name="gear" />
19
+ </IconFrame>
20
+ );
21
+
22
+ const gearTurnStyles = css({
23
+ _controlHover: {
24
+ animation: "iconGearTurn {durations.slowest} {easings.outBack}",
25
+ },
26
+ });
@@ -0,0 +1,50 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { KEYBOARD_BOLD } from "../_icon/phosphor-parts";
5
+ import { useCue } from "../_icon/useCue";
6
+
7
+ type Props = {
8
+ /** Plays each time it changes, such as to the keymap in use. */
9
+ cue?: unknown;
10
+ /** Names the icon; without one it is decorative. */
11
+ label?: string | undefined;
12
+ size: number;
13
+ };
14
+
15
+ /**
16
+ * Phosphor's bold `Keyboard`. On each cue its keys press down in a wave, top
17
+ * row first; pointed at, one key presses.
18
+ */
19
+ export const AnimatedKeyboardIcon = ({ cue, label, size }: Props) => {
20
+ const cued = useCue(cue);
21
+ return (
22
+ <IconFrame label={label} size={size}>
23
+ <IconPart d={KEYBOARD_BOLD.frame} name="frame" />
24
+ {KEYBOARD_BOLD.keys.map((d, index) => (
25
+ <IconPart
26
+ cue={cued}
27
+ cueStyles={keyWaveStyles}
28
+ d={d}
29
+ hoverStyles={index === 1 ? keyHoverStyles : undefined}
30
+ key={d}
31
+ name="key"
32
+ order={index}
33
+ />
34
+ ))}
35
+ </IconFrame>
36
+ );
37
+ };
38
+
39
+ const keyWaveStyles = css({
40
+ "&[data-cued]": {
41
+ animation:
42
+ "iconKeyPress {durations.slow} {easings.in-out} calc(var(--part-order) * {durations.fastest}) both",
43
+ },
44
+ });
45
+
46
+ const keyHoverStyles = css({
47
+ _controlHover: {
48
+ animation: "iconKeyPress {durations.slow} {easings.in-out}",
49
+ },
50
+ });
@@ -0,0 +1,27 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { LIGHTNING_FILL } from "../_icon/phosphor-parts";
5
+
6
+ type Props = {
7
+ /** Names the icon; without one it is decorative. */
8
+ label?: string | undefined;
9
+ size: number;
10
+ /** Where it sits, inside an outer drawing such as a battery. */
11
+ x?: number | undefined;
12
+ y?: number | undefined;
13
+ };
14
+
15
+ /**
16
+ * Phosphor's filled `Lightning`. It strikes down from its top when it
17
+ * appears, such as when charging starts.
18
+ */
19
+ export const AnimatedLightningIcon = ({ label, size, x, y }: Props) => (
20
+ <IconFrame label={label} size={size} x={x} y={y}>
21
+ <IconPart d={LIGHTNING_FILL.bolt} name="bolt" stateStyles={strikeStyles} />
22
+ </IconFrame>
23
+ );
24
+
25
+ const strikeStyles = css({
26
+ animation: "iconBoltStrike {durations.slowest} {easings.linear}",
27
+ });
@@ -0,0 +1,53 @@
1
+ import { css } from "../../styled-system/css";
2
+ import { IconFrame } from "../_icon/IconFrame";
3
+ import { IconPart } from "../_icon/IconPart";
4
+ import { MICROPHONE_FILL, MICROPHONE_REGULAR } from "../_icon/phosphor-parts";
5
+ import { useCue } from "../_icon/useCue";
6
+
7
+ const PARTS = { fill: MICROPHONE_FILL, regular: MICROPHONE_REGULAR };
8
+
9
+ type Props = {
10
+ /** Names the icon; without one it is decorative. */
11
+ label?: string | undefined;
12
+ muted: boolean;
13
+ size: number;
14
+ weight: keyof typeof PARTS;
15
+ };
16
+
17
+ /**
18
+ * Phosphor's `Microphone` and `MicrophoneSlash` in one. Muting draws the
19
+ * slash; unmuting, or pointing at the control the icon is in, bobs the
20
+ * capsule.
21
+ */
22
+ export const AnimatedMicrophoneIcon = ({
23
+ label,
24
+ muted,
25
+ size,
26
+ weight,
27
+ }: Props) => {
28
+ const unmuted = useCue(muted ? undefined : "live");
29
+ return (
30
+ <IconFrame label={label} size={size} slash={{ shown: muted, weight }}>
31
+ <IconPart
32
+ cue={unmuted}
33
+ cueStyles={capsuleBobStyles}
34
+ d={PARTS[weight].capsule}
35
+ hoverStyles={muted ? undefined : capsuleHoverStyles}
36
+ name="capsule"
37
+ />
38
+ <IconPart d={PARTS[weight].stand} name="stand" />
39
+ </IconFrame>
40
+ );
41
+ };
42
+
43
+ const capsuleBobStyles = css({
44
+ "&[data-cued]": {
45
+ animation: "iconCapsuleBob {durations.slowest} {easings.in-out}",
46
+ },
47
+ });
48
+
49
+ const capsuleHoverStyles = css({
50
+ _controlHover: {
51
+ animation: "iconCapsuleBob {durations.slowest} {easings.in-out}",
52
+ },
53
+ });
@@ -0,0 +1,122 @@
1
+ import { useState } from "react";
2
+
3
+ import { css } from "../../styled-system/css";
4
+ import type { Weight } from "../_icon/IconFrame";
5
+ import { IconFrame } from "../_icon/IconFrame";
6
+ import { IconPart } from "../_icon/IconPart";
7
+ import {
8
+ SPEAKER_BOLD,
9
+ SPEAKER_FILL,
10
+ SPEAKER_REGULAR,
11
+ } from "../_icon/phosphor-parts";
12
+
13
+ const PARTS = {
14
+ bold: SPEAKER_BOLD,
15
+ fill: SPEAKER_FILL,
16
+ regular: SPEAKER_REGULAR,
17
+ } satisfies Record<Weight, unknown>;
18
+
19
+ /** The volume at and above which the second wave shows, as in `SpeakerHigh`. */
20
+ const LOUD = 0.5;
21
+
22
+ /** Where the waves leave the cone. */
23
+ const MOUTH = [168, 128] as const;
24
+
25
+ type Props = {
26
+ /** Names the icon; without one it is decorative. */
27
+ label?: string | undefined;
28
+ muted: boolean;
29
+ size: number;
30
+ /** 0 through 1. */
31
+ volume: number;
32
+ weight: Weight;
33
+ };
34
+
35
+ /**
36
+ * Phosphor's `SpeakerHigh`, `SpeakerLow`, `SpeakerNone` and `SpeakerSlash` in
37
+ * one. A wave shows for each step of volume; the waves push out when it goes
38
+ * up and pull in when it goes down, and push out while the control the icon
39
+ * is in is pointed at. Muting tucks them away and draws the slash.
40
+ */
41
+ export const AnimatedSpeakerIcon = ({
42
+ label,
43
+ muted,
44
+ size,
45
+ volume,
46
+ weight,
47
+ }: Props) => {
48
+ const pulse = usePulse(volume);
49
+ const parts = PARTS[weight];
50
+ return (
51
+ <IconFrame label={label} size={size} slash={{ shown: muted, weight }}>
52
+ <IconPart d={parts.cone} name="cone" />
53
+ {parts.waves.map((d, index) => (
54
+ <IconPart
55
+ cue={pulse.count}
56
+ cueStyles={pulse.louder ? wavePushStyles : wavePullStyles}
57
+ d={d}
58
+ hoverStyles={muted ? undefined : waveHoverStyles}
59
+ key={d}
60
+ name="wave"
61
+ order={index}
62
+ origin={MOUTH}
63
+ state={waveState(index, volume, muted)}
64
+ stateStyles={waveStyles}
65
+ />
66
+ ))}
67
+ </IconFrame>
68
+ );
69
+ };
70
+
71
+ const waveStyles = css({
72
+ "&[data-state=hidden]": {
73
+ opacity: 0,
74
+ transform: "translateX(-14px) scaleY(0.7)",
75
+ transition:
76
+ "opacity {durations.normal} {easings.in}, transform {durations.normal} {easings.in}",
77
+ },
78
+ "&[data-state=shown]": {
79
+ opacity: 1,
80
+ transform: "none",
81
+ transition:
82
+ "opacity {durations.normal} {easings.out}, transform {durations.slow} {easings.outBack}",
83
+ },
84
+ });
85
+
86
+ const wavePushStyles = css({
87
+ "&[data-cued]": {
88
+ animation:
89
+ "iconWavePush {durations.slower} {easings.in-out} calc(var(--part-order) * {durations.faster}) both",
90
+ },
91
+ });
92
+
93
+ const wavePullStyles = css({
94
+ "&[data-cued]": {
95
+ animation:
96
+ "iconWavePull {durations.slower} {easings.in-out} calc(var(--part-order) * {durations.faster}) both",
97
+ },
98
+ });
99
+
100
+ const waveHoverStyles = css({
101
+ _controlHover: {
102
+ animation:
103
+ "iconWavePush {durations.slower} {easings.in-out} calc(var(--part-order) * {durations.faster}) both",
104
+ },
105
+ });
106
+
107
+ /** How many times `volume` has changed since mount, and whether it last went up. */
108
+ const usePulse = (volume: number) => {
109
+ const [last, setLast] = useState(volume);
110
+ const [pulse, setPulse] = useState({ count: 0, louder: true });
111
+ if (volume !== last) {
112
+ setLast(volume);
113
+ setPulse({ count: pulse.count + 1, louder: volume > last });
114
+ }
115
+ return pulse;
116
+ };
117
+
118
+ /** The first wave shows above silence, the second from `LOUD`; neither while muted. */
119
+ const waveState = (index: number, volume: number, muted: boolean) => {
120
+ const heard = index === 0 ? volume > 0 : volume >= LOUD;
121
+ return !muted && heard ? "shown" : "hidden";
122
+ };