@wibly/ui-kit 0.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/CHANGELOG.md +7 -0
- package/package.json +32 -0
- package/src/components/avatar-stage.tsx +98 -0
- package/src/components/consent-dialog.test.ts +39 -0
- package/src/components/consent-dialog.tsx +58 -0
- package/src/components/consent-prompt.tsx +106 -0
- package/src/components/join-code-badge.tsx +69 -0
- package/src/components/leaderboard.tsx +159 -0
- package/src/components/paused-banner.tsx +66 -0
- package/src/components/prompt-input.test.tsx +143 -0
- package/src/components/prompt-input.tsx +114 -0
- package/src/components/recovery-code-badge.tsx +58 -0
- package/src/components/response-card.tsx +70 -0
- package/src/components/session-aborted-overlay.tsx +60 -0
- package/src/components/timer.test.tsx +157 -0
- package/src/components/timer.tsx +104 -0
- package/src/components/vote-grid.tsx +116 -0
- package/src/index.ts +113 -0
- package/src/lib/char-count.test.ts +72 -0
- package/src/lib/char-count.ts +82 -0
- package/src/lib/cn.ts +13 -0
- package/src/lib/consent-flow.test.ts +45 -0
- package/src/lib/consent-flow.ts +79 -0
- package/src/lib/consent-meta.ts +38 -0
- package/src/lib/duration.test.ts +71 -0
- package/src/lib/duration.ts +57 -0
- package/src/test/setup.ts +19 -0
- package/styles/wibly-shell-theme.css +215 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatRemaining, remainingMs } from './duration.js';
|
|
4
|
+
|
|
5
|
+
describe('remainingMs', () => {
|
|
6
|
+
it('returns the positive difference for a future deadline', () => {
|
|
7
|
+
expect(remainingMs(10_000, 7_500)).toBe(2_500);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns 0 when the deadline has already passed', () => {
|
|
11
|
+
expect(remainingMs(5_000, 8_000)).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns 0 when the deadline equals now', () => {
|
|
15
|
+
expect(remainingMs(1_000, 1_000)).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns 0 for non-finite inputs (NaN guards)', () => {
|
|
19
|
+
expect(remainingMs(Number.NaN, 0)).toBe(0);
|
|
20
|
+
expect(remainingMs(0, Number.NaN)).toBe(0);
|
|
21
|
+
expect(remainingMs(Number.POSITIVE_INFINITY, 0)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('formatRemaining', () => {
|
|
26
|
+
it('formats whole minutes with zero-padded seconds (default mm:ss)', () => {
|
|
27
|
+
expect(formatRemaining(60_000)).toBe('1:00');
|
|
28
|
+
expect(formatRemaining(125_000)).toBe('2:05');
|
|
29
|
+
expect(formatRemaining(720_000)).toBe('12:00');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('ceils sub-second slivers up so the counter never lies about expiry', () => {
|
|
33
|
+
expect(formatRemaining(1)).toBe('0:01');
|
|
34
|
+
expect(formatRemaining(500)).toBe('0:01');
|
|
35
|
+
expect(formatRemaining(999)).toBe('0:01');
|
|
36
|
+
expect(formatRemaining(1_000)).toBe('0:01');
|
|
37
|
+
expect(formatRemaining(1_001)).toBe('0:02');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders 0:00 for zero or negative remaining', () => {
|
|
41
|
+
expect(formatRemaining(0)).toBe('0:00');
|
|
42
|
+
expect(formatRemaining(-1_000)).toBe('0:00');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('supports the ss format for sub-minute countdowns', () => {
|
|
46
|
+
expect(formatRemaining(5_000, 'ss')).toBe('5');
|
|
47
|
+
expect(formatRemaining(30_500, 'ss')).toBe('31');
|
|
48
|
+
expect(formatRemaining(0, 'ss')).toBe('0');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is timezone-independent — pure duration math, no Date coercion', () => {
|
|
52
|
+
// Even if the caller passes a value that looks like a wall-clock
|
|
53
|
+
// millisecond (Jan 1 2026 noon UTC), the function still treats it
|
|
54
|
+
// as a duration. A timezone shift on the host machine cannot
|
|
55
|
+
// change the output.
|
|
56
|
+
const wallClockLooking = 1_767_268_800_000;
|
|
57
|
+
const out1 = formatRemaining(wallClockLooking);
|
|
58
|
+
const out2 = formatRemaining(wallClockLooking);
|
|
59
|
+
expect(out1).toBe(out2);
|
|
60
|
+
// And the output is the duration, not derived from a Date — so
|
|
61
|
+
// if we add a year of ms, the formatted string changes
|
|
62
|
+
// mechanically by the added duration, not by a timezone offset.
|
|
63
|
+
const oneMinuteMore = formatRemaining(wallClockLooking + 60_000);
|
|
64
|
+
expect(oneMinuteMore).not.toBe(out1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles non-finite ms by rendering the expired state', () => {
|
|
68
|
+
expect(formatRemaining(Number.NaN)).toBe('0:00');
|
|
69
|
+
expect(formatRemaining(Number.NEGATIVE_INFINITY)).toBe('0:00');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration formatting helpers used by `Timer` and any consumer that
|
|
3
|
+
* needs to render a server-anchored countdown.
|
|
4
|
+
*
|
|
5
|
+
* The helpers are deliberately UTC- and timezone-agnostic: callers
|
|
6
|
+
* pass `remainingMs` (a non-negative duration in milliseconds) and
|
|
7
|
+
* receive a string. They do NOT take a `Date` or a wall-clock value,
|
|
8
|
+
* so a session running across a daylight-savings boundary or a
|
|
9
|
+
* device with a misconfigured timezone produces identical output to
|
|
10
|
+
* a normal device. (Per Dev Spec §6.6 chunk-B10 acceptance:
|
|
11
|
+
* "Timer's deadline math (countdown, expiry, timezone independence)".)
|
|
12
|
+
*
|
|
13
|
+
* `formatRemaining` uses ceiling rounding so the visible counter
|
|
14
|
+
* matches operator intuition — a half-second of remaining time
|
|
15
|
+
* still shows `0:01`, not `0:00`. The counter only flips to the
|
|
16
|
+
* expired representation when `remainingMs <= 0`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DurationFormat = 'mm:ss' | 'ss';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute the non-negative remaining milliseconds between `nowMs`
|
|
23
|
+
* and `deadlineMs`. Returns 0 when the deadline is in the past;
|
|
24
|
+
* never returns a negative number (callers don't need to clamp).
|
|
25
|
+
*/
|
|
26
|
+
export const remainingMs = (deadlineMs: number, nowMs: number): number => {
|
|
27
|
+
if (!Number.isFinite(deadlineMs) || !Number.isFinite(nowMs)) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const diff = deadlineMs - nowMs;
|
|
31
|
+
return diff > 0 ? diff : 0;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format a duration in milliseconds. Default format is `mm:ss`
|
|
36
|
+
* (`0:30`, `1:05`, `12:00`); `ss` strips the minutes for sub-minute
|
|
37
|
+
* countdowns where a leading zero is noise.
|
|
38
|
+
*
|
|
39
|
+
* Ceiling rounds: 1ms remaining still shows `0:01`. Zero / negative
|
|
40
|
+
* input shows `0:00` / `0`.
|
|
41
|
+
*/
|
|
42
|
+
export const formatRemaining = (
|
|
43
|
+
ms: number,
|
|
44
|
+
format: DurationFormat = 'mm:ss',
|
|
45
|
+
): string => {
|
|
46
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
47
|
+
return format === 'ss' ? '0' : '0:00';
|
|
48
|
+
}
|
|
49
|
+
const seconds = Math.ceil(ms / 1000);
|
|
50
|
+
if (format === 'ss') {
|
|
51
|
+
return String(seconds);
|
|
52
|
+
}
|
|
53
|
+
const minutes = Math.floor(seconds / 60);
|
|
54
|
+
const remainingSeconds = seconds % 60;
|
|
55
|
+
const padded = remainingSeconds < 10 ? `0${remainingSeconds}` : `${remainingSeconds}`;
|
|
56
|
+
return `${minutes}:${padded}`;
|
|
57
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest setup for `@platform/ui-kit` (chunk B10).
|
|
3
|
+
*
|
|
4
|
+
* Sets `IS_REACT_ACT_ENVIRONMENT = true` so React 19's `act(...)`
|
|
5
|
+
* implementation suppresses its "not configured" stderr noise. Also
|
|
6
|
+
* unmounts any leftover roots between tests by relying on
|
|
7
|
+
* `@testing-library/react`'s `cleanup`, run from `afterEach`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import '@testing-library/react';
|
|
11
|
+
import { afterEach } from 'vitest';
|
|
12
|
+
import { cleanup } from '@testing-library/react';
|
|
13
|
+
|
|
14
|
+
// React 19's act() emits a runtime warning unless this flag is set.
|
|
15
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700;9..144,800&family=Inter:wght@400;500;600;700&display=swap');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wibly shell theme — aligned with Studio / Portal (warm cream, purple
|
|
5
|
+
* primary, Fraunces display). Consumed by host-web, player-web, and
|
|
6
|
+
* uikit-preview via each app's `globals.css`.
|
|
7
|
+
*
|
|
8
|
+
* Legacy `--color-surface*` aliases remain for shell chrome that has
|
|
9
|
+
* not yet migrated to semantic Tailwind tokens.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
@theme inline {
|
|
13
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
14
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
15
|
+
--radius-lg: var(--radius);
|
|
16
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
17
|
+
--radius-2xl: calc(var(--radius) + 8px);
|
|
18
|
+
|
|
19
|
+
--color-background: var(--background);
|
|
20
|
+
--color-foreground: var(--foreground);
|
|
21
|
+
--color-card: var(--card);
|
|
22
|
+
--color-card-foreground: var(--card-foreground);
|
|
23
|
+
--color-popover: var(--popover);
|
|
24
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
25
|
+
--color-primary: var(--primary);
|
|
26
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
27
|
+
--color-secondary: var(--secondary);
|
|
28
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
29
|
+
--color-muted: var(--muted);
|
|
30
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
31
|
+
--color-accent: var(--accent);
|
|
32
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
33
|
+
--color-destructive: var(--destructive);
|
|
34
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
35
|
+
--color-border: var(--border);
|
|
36
|
+
--color-input: var(--input);
|
|
37
|
+
--color-ring: var(--ring);
|
|
38
|
+
|
|
39
|
+
--color-purple: var(--purple);
|
|
40
|
+
--color-sunbeam: var(--sunbeam);
|
|
41
|
+
--color-mint: var(--mint);
|
|
42
|
+
--color-ink: var(--ink);
|
|
43
|
+
--color-cream: var(--cream);
|
|
44
|
+
--color-paper: var(--paper);
|
|
45
|
+
|
|
46
|
+
--font-display: var(--font-fraunces), ui-serif, Georgia, serif;
|
|
47
|
+
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
|
48
|
+
--font-mono:
|
|
49
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
50
|
+
'Courier New', monospace;
|
|
51
|
+
|
|
52
|
+
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
:root {
|
|
56
|
+
--font-fraunces: 'Fraunces', ui-serif, Georgia, serif;
|
|
57
|
+
--font-inter: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
|
58
|
+
--radius: 0.875rem;
|
|
59
|
+
|
|
60
|
+
--purple: oklch(0.66 0.22 320);
|
|
61
|
+
--purple-foreground: oklch(0.99 0.01 320);
|
|
62
|
+
--sunbeam: oklch(0.86 0.18 92);
|
|
63
|
+
--mint: oklch(0.82 0.17 165);
|
|
64
|
+
--ink: oklch(0.2 0.04 300);
|
|
65
|
+
--cream: oklch(0.97 0.03 80);
|
|
66
|
+
--paper: oklch(0.93 0.05 60);
|
|
67
|
+
|
|
68
|
+
--background: var(--cream);
|
|
69
|
+
--foreground: var(--ink);
|
|
70
|
+
--card: oklch(0.99 0.015 80);
|
|
71
|
+
--card-foreground: var(--ink);
|
|
72
|
+
--popover: oklch(0.99 0.015 80);
|
|
73
|
+
--popover-foreground: var(--ink);
|
|
74
|
+
--primary: var(--purple);
|
|
75
|
+
--primary-foreground: var(--purple-foreground);
|
|
76
|
+
--secondary: var(--paper);
|
|
77
|
+
--secondary-foreground: var(--ink);
|
|
78
|
+
--muted: var(--paper);
|
|
79
|
+
--muted-foreground: oklch(0.45 0.05 300);
|
|
80
|
+
--accent: var(--sunbeam);
|
|
81
|
+
--accent-foreground: var(--ink);
|
|
82
|
+
--destructive: oklch(0.72 0.21 30);
|
|
83
|
+
--destructive-foreground: oklch(0.99 0.01 80);
|
|
84
|
+
--border: oklch(0.2 0.04 300 / 12%);
|
|
85
|
+
--input: oklch(0.2 0.04 300 / 8%);
|
|
86
|
+
--ring: var(--purple);
|
|
87
|
+
|
|
88
|
+
/* Legacy shell tokens (@platform/ui-kit backward compat) */
|
|
89
|
+
--color-surface: var(--background);
|
|
90
|
+
--color-surface-2: var(--card);
|
|
91
|
+
--color-muted-2: var(--muted-foreground);
|
|
92
|
+
--color-success: oklch(0.62 0.17 155);
|
|
93
|
+
|
|
94
|
+
/* Consent dialog bridge (Portal parity) */
|
|
95
|
+
--consent-border: var(--border);
|
|
96
|
+
--consent-surface: var(--card);
|
|
97
|
+
--consent-foreground: var(--foreground);
|
|
98
|
+
--consent-muted: var(--muted-foreground);
|
|
99
|
+
--consent-muted-2: var(--muted-foreground);
|
|
100
|
+
--consent-primary: var(--primary);
|
|
101
|
+
--consent-primary-foreground: var(--primary-foreground);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@layer base {
|
|
105
|
+
* {
|
|
106
|
+
border-color: var(--border);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
html,
|
|
110
|
+
body {
|
|
111
|
+
height: 100%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
body {
|
|
115
|
+
background-color: var(--background);
|
|
116
|
+
background-image:
|
|
117
|
+
radial-gradient(
|
|
118
|
+
ellipse at 50% 0%,
|
|
119
|
+
color-mix(in oklab, var(--purple) 12%, transparent) 0px,
|
|
120
|
+
transparent 55%
|
|
121
|
+
),
|
|
122
|
+
radial-gradient(
|
|
123
|
+
ellipse at 5% 90%,
|
|
124
|
+
color-mix(in oklab, var(--mint) 18%, transparent) 0px,
|
|
125
|
+
transparent 50%
|
|
126
|
+
);
|
|
127
|
+
background-attachment: fixed;
|
|
128
|
+
color: var(--foreground);
|
|
129
|
+
font-family: var(--font-sans);
|
|
130
|
+
margin: 0;
|
|
131
|
+
-webkit-font-smoothing: antialiased;
|
|
132
|
+
font-size: 0.9375rem;
|
|
133
|
+
line-height: 1.5;
|
|
134
|
+
-webkit-text-size-adjust: 100%;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
h1,
|
|
138
|
+
h2,
|
|
139
|
+
h3,
|
|
140
|
+
h4 {
|
|
141
|
+
font-family: var(--font-display);
|
|
142
|
+
letter-spacing: -0.02em;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.wibly-hover {
|
|
147
|
+
transition: transform 220ms var(--ease-bounce);
|
|
148
|
+
}
|
|
149
|
+
.wibly-hover:hover {
|
|
150
|
+
transform: scale(1.02);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@keyframes wibly-avatar-breathe {
|
|
154
|
+
0%,
|
|
155
|
+
100% {
|
|
156
|
+
transform: scale(1);
|
|
157
|
+
}
|
|
158
|
+
50% {
|
|
159
|
+
transform: scale(1.03);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@keyframes wibly-avatar-speak {
|
|
164
|
+
0%,
|
|
165
|
+
100% {
|
|
166
|
+
box-shadow:
|
|
167
|
+
0 0 0 4px color-mix(in oklab, var(--primary) 18%, transparent),
|
|
168
|
+
0 10px 28px color-mix(in oklab, var(--primary) 22%, transparent);
|
|
169
|
+
}
|
|
170
|
+
50% {
|
|
171
|
+
box-shadow:
|
|
172
|
+
0 0 0 10px color-mix(in oklab, var(--primary) 8%, transparent),
|
|
173
|
+
0 14px 32px color-mix(in oklab, var(--primary) 30%, transparent);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@keyframes wibly-avatar-think {
|
|
178
|
+
0%,
|
|
179
|
+
100% {
|
|
180
|
+
box-shadow:
|
|
181
|
+
0 0 0 3px color-mix(in oklab, var(--accent) 28%, transparent),
|
|
182
|
+
0 8px 20px color-mix(in oklab, var(--accent) 16%, transparent);
|
|
183
|
+
}
|
|
184
|
+
50% {
|
|
185
|
+
box-shadow:
|
|
186
|
+
0 0 0 8px color-mix(in oklab, var(--accent) 12%, transparent),
|
|
187
|
+
0 12px 26px color-mix(in oklab, var(--accent) 24%, transparent);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.wibly-avatar-stage-idle {
|
|
192
|
+
animation: wibly-avatar-breathe 4s ease-in-out infinite;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.wibly-avatar-stage-speaking {
|
|
196
|
+
animation: wibly-avatar-speak 1s ease-in-out infinite;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.wibly-avatar-stage-judging {
|
|
200
|
+
animation: wibly-avatar-think 2.5s ease-in-out infinite;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.wibly-response-card {
|
|
204
|
+
gap: 0;
|
|
205
|
+
padding: 1.25rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@media (prefers-reduced-motion: reduce) {
|
|
209
|
+
* {
|
|
210
|
+
animation-duration: 0.01ms !important;
|
|
211
|
+
animation-iteration-count: 1 !important;
|
|
212
|
+
transition-duration: 0.01ms !important;
|
|
213
|
+
scroll-behavior: auto !important;
|
|
214
|
+
}
|
|
215
|
+
}
|