Line data Source code
1 : import * as React from 'react';
2 :
3 : import { AgentFace, AgentFaceMode, DEFAULT_AGENT_FACE_TUNING, useFlavorAgentFaceBrand, type AgentFaceTuning } from '../../agent/agent_face.js';
4 : import { DASHQL_BRAND, SIDB_BRAND } from '../../agent/agent_face_morph.js';
5 : import { AgentGreeting } from '../../agent/agent_greeting.js';
6 : import { Button, ButtonSize, ButtonVariant } from '../../ui/foundations/button.js';
7 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
8 : import * as styles from './agent_face_page.module.css';
9 :
10 : /// A slider of a tuning knob
11 : interface Knob {
12 : key: keyof AgentFaceTuning;
13 : label: string;
14 : min: number;
15 : max: number;
16 : step: number;
17 : }
18 :
19 : /// The knobs, grouped by what they shape
20 1 : const KNOB_GROUPS: { title: string; knobs: Knob[] }[] = [
21 1 : {
22 1 : title: 'Morph',
23 1 : knobs: [
24 1 : { key: 'stiffness', label: 'Spring stiffness', min: 10, max: 300, step: 1 },
25 1 : { key: 'damping', label: 'Spring damping', min: 1, max: 40, step: 0.5 },
26 1 : { key: 'lag', label: 'Point lag', min: 0, max: 0.9, step: 0.01 },
27 1 : { key: 'ripple', label: 'Ripple', min: 0, max: 12, step: 0.1 },
28 1 : { key: 'gooBlur', label: 'Goo blur', min: 0, max: 16, step: 0.1 },
29 1 : ],
30 1 : },
31 1 : {
32 1 : title: 'Liquid face',
33 1 : knobs: [
34 1 : { key: 'wobble', label: 'Wobble', min: 0, max: 0.08, step: 0.001 },
35 1 : { key: 'wobbleSpeed', label: 'Wobble speed', min: 0, max: 4, step: 0.05 },
36 1 : ],
37 1 : },
38 1 : {
39 1 : title: 'Eyes',
40 1 : knobs: [
41 1 : { key: 'glanceIntervalMs', label: 'Glance interval (ms)', min: 400, max: 8000, step: 100 },
42 1 : { key: 'glanceAmplitude', label: 'Glance amplitude', min: 0, max: 20, step: 0.5 },
43 1 : { key: 'blinkIntervalMs', label: 'Blink interval (ms)', min: 1000, max: 12000, step: 100 },
44 1 : ],
45 1 : },
46 1 : {
47 1 : title: 'Cycle',
48 1 : knobs: [
49 1 : { key: 'faceHoldMs', label: 'Face hold (ms)', min: 1000, max: 20000, step: 100 },
50 1 : { key: 'logoHoldMs', label: 'Logo hold (ms)', min: 500, max: 10000, step: 100 },
51 1 : ],
52 1 : },
53 1 : ];
54 :
55 1 : const BRANDS = [
56 1 : { label: 'DashQL', brand: DASHQL_BRAND },
57 1 : { label: 'SIDB', brand: SIDB_BRAND },
58 1 : ];
59 : /// The demo cycles faster than the app, to watch the morph without waiting
60 1 : const DEMO_TUNING: AgentFaceTuning = { ...DEFAULT_AGENT_FACE_TUNING, faceHoldMs: 4000, logoHoldMs: 2000 };
61 :
62 : /// The eyes follow the mouse pointer
63 1 : const FOLLOW_POINTER = { pointer: true };
64 :
65 1 : const MODES = [AgentFaceMode.Cycle, AgentFaceMode.Face, AgentFaceMode.Logo];
66 1 : const MODE_LABELS = ['Cycle', 'Face', 'Logo'];
67 1 : const SURFACES = ['light', 'dark'] as const;
68 1 : const INKS = [
69 1 : { label: 'Neutral', color: 'var(--base_color_neutral_6)' },
70 1 : { label: 'Coral', color: 'var(--base_color_coral_5)' },
71 1 : { label: 'Blue', color: 'var(--base_color_blue_5)' },
72 1 : ];
73 1 : const SIZES = [24, 48, 96];
74 :
75 : /// A playground of the agent face that morphs into the DashQL brand mark, to tune its motion
76 1 : function AgentFacePage(): React.ReactElement {
77 1 : const [tuning, setTuning] = React.useState<AgentFaceTuning>(DEMO_TUNING);
78 : // The demo starts with the brand mark of the flavor, `demos.html?flavor=sidb#/agent_face`
79 1 : const flavorBrand = useFlavorAgentFaceBrand();
80 1 : const [brand, setBrand] = React.useState(() => Math.max(0, BRANDS.findIndex(entry => entry.brand === flavorBrand)));
81 1 : const [mode, setMode] = React.useState(0);
82 1 : const [followPointer, setFollowPointer] = React.useState(1);
83 : // The face gets excited about every key typed into the try field
84 1 : const lastInput = React.useRef<number | null>(null);
85 1 : const readLastInput = React.useCallback(() => lastInput.current, []);
86 1 : const [surface, setSurface] = React.useState(0);
87 1 : const [ink, setInk] = React.useState(0);
88 1 : const [copied, setCopied] = React.useState(false);
89 :
90 1 : const tuningText = React.useMemo(() => JSON.stringify(tuning, null, 4), [tuning]);
91 1 : const copyTuning = async () => {
92 0 : await navigator.clipboard.writeText(tuningText);
93 0 : setCopied(true);
94 0 : setTimeout(() => setCopied(false), 1200);
95 0 : };
96 :
97 1 : const faceProps = { brand: BRANDS[brand].brand, tuning, lastInput: readLastInput, gaze: followPointer === 1 ? FOLLOW_POINTER : undefined, mode: MODES[mode], style: { color: INKS[ink].color } };
98 1 : return (
99 1 : <div className={styles.root}>
100 1 : <div className={styles.header}>
101 1 : <h1 className={styles.title}>Agent Face</h1>
102 1 : <p className={styles.subtitle}>
103 : The face of the agent on the empty session, it glances around and now and then melts into a brand mark.
104 1 : </p>
105 1 : </div>
106 1 : <div className={styles.layout}>
107 1 : <div className={styles.stage} data-surface={SURFACES[surface]}>
108 1 : <AgentFace {...faceProps} className={styles.stage_face} label="Agent face" />
109 1 : <div className={styles.greeting}>
110 1 : <AgentGreeting face={{ ...faceProps, className: styles.greeting_face, label: 'Agent face next to its question' }}
111 1 : questionClassName={styles.greeting_text} questionElement="p" />
112 1 : </div>
113 1 : <input id="agent-face-try" className={styles.try_input} placeholder="Type here, the face gets excited"
114 1 : aria-label="Type to excite the face" onKeyDown={() => { lastInput.current = performance.now(); }} />
115 1 : <div className={styles.sizes}>
116 1 : {SIZES.map(size => (
117 1 : <div key={size} className={styles.size}>
118 1 : <AgentFace {...faceProps} width={size} height={size} label={`Agent face at ${size} pixels`} />
119 1 : <span className={styles.size_label}>{size}px</span>
120 1 : </div>
121 1 : ))}
122 1 : </div>
123 1 : </div>
124 1 : <div className={styles.controls}>
125 1 : <div className={styles.control_row}>
126 1 : <span className={styles.control_label}>Brand</span>
127 1 : <SegmentedControl aria-label="Brand" size={SegmentedControlSize.Small} onChange={setBrand}>
128 1 : {BRANDS.map((entry, i) => (
129 1 : <SegmentedControl.Button key={entry.label} selected={brand === i}>{entry.label}</SegmentedControl.Button>
130 1 : ))}
131 1 : </SegmentedControl>
132 1 : </div>
133 1 : <div className={styles.control_row}>
134 1 : <span className={styles.control_label}>Gaze</span>
135 1 : <SegmentedControl aria-label="What the eyes look at" size={SegmentedControlSize.Small} onChange={setFollowPointer}>
136 1 : <SegmentedControl.Button selected={followPointer === 0}>Around</SegmentedControl.Button>
137 1 : <SegmentedControl.Button selected={followPointer === 1}>Pointer</SegmentedControl.Button>
138 1 : </SegmentedControl>
139 1 : </div>
140 1 : <div className={styles.control_row}>
141 1 : <span className={styles.control_label}>Shows</span>
142 1 : <SegmentedControl aria-label="What the face shows" size={SegmentedControlSize.Small} onChange={setMode}>
143 1 : {MODE_LABELS.map((label, i) => (
144 1 : <SegmentedControl.Button key={label} selected={mode === i}>{label}</SegmentedControl.Button>
145 1 : ))}
146 1 : </SegmentedControl>
147 1 : </div>
148 1 : <div className={styles.control_row}>
149 1 : <span className={styles.control_label}>Surface</span>
150 1 : <SegmentedControl aria-label="Surface" size={SegmentedControlSize.Small} onChange={setSurface}>
151 1 : <SegmentedControl.Button selected={surface === 0}>Light</SegmentedControl.Button>
152 1 : <SegmentedControl.Button selected={surface === 1}>Dark</SegmentedControl.Button>
153 1 : </SegmentedControl>
154 1 : </div>
155 1 : <div className={styles.control_row}>
156 1 : <span className={styles.control_label}>Ink</span>
157 1 : <SegmentedControl aria-label="Ink" size={SegmentedControlSize.Small} onChange={setInk}>
158 1 : {INKS.map((entry, i) => (
159 1 : <SegmentedControl.Button key={entry.label} selected={ink === i}>{entry.label}</SegmentedControl.Button>
160 1 : ))}
161 1 : </SegmentedControl>
162 1 : </div>
163 1 : {KNOB_GROUPS.map(group => (
164 1 : <fieldset key={group.title} className={styles.group}>
165 1 : <legend className={styles.group_title}>{group.title}</legend>
166 1 : {group.knobs.map(knob => (
167 1 : <label key={knob.key} className={styles.knob}>
168 1 : <span className={styles.knob_label}>{knob.label}</span>
169 1 : <input id={`agent-face-${knob.key}`} type="range" min={knob.min} max={knob.max} step={knob.step}
170 1 : value={tuning[knob.key]}
171 1 : onChange={e => {
172 0 : const value = Number(e.target.value);
173 0 : setTuning(t => ({ ...t, [knob.key]: value }));
174 1 : }} />
175 1 : <span className={styles.knob_value}>{tuning[knob.key]}</span>
176 1 : </label>
177 1 : ))}
178 1 : </fieldset>
179 1 : ))}
180 1 : <div className={styles.control_row}>
181 1 : <Button size={ButtonSize.Small} variant={ButtonVariant.Default} onClick={() => setTuning(DEMO_TUNING)}>
182 : Reset
183 1 : </Button>
184 1 : <Button size={ButtonSize.Small} variant={ButtonVariant.Primary} onClick={copyTuning}>
185 1 : {copied ? 'Copied' : 'Copy tuning'}
186 1 : </Button>
187 1 : </div>
188 1 : <pre className={styles.tuning}>{tuningText}</pre>
189 1 : </div>
190 1 : </div>
191 1 : </div>
192 : );
193 1 : }
194 :
195 : export default AgentFacePage;
|