@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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Tier-1 invariant tests for `PromptInput`.
3
+ *
4
+ * Per Dev Spec ยง6.6 chunk-B10 acceptance: "PromptInput's character
5
+ * cap (cap enforced; trims correctly; does not break on multi-byte
6
+ * characters)."
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { fireEvent, render, screen } from '@testing-library/react';
11
+ import { useState } from 'react';
12
+
13
+ import { PromptInput, type PromptInputProps } from './prompt-input.js';
14
+
15
+ /**
16
+ * Most tests need React state semantics โ€” `onChange` updates a
17
+ * caller-side store; the next render reflects the cap behaviour.
18
+ * The harness wires that store inline so the test files don't have
19
+ * to re-implement it per case.
20
+ */
21
+ const Harness = (props: Omit<PromptInputProps, 'value' | 'onChange'> & {
22
+ readonly initialValue?: string;
23
+ readonly onChangeSpy?: (next: string) => void;
24
+ }) => {
25
+ const { initialValue, onChangeSpy, ...rest } = props;
26
+ const [value, setValue] = useState(initialValue ?? '');
27
+ return (
28
+ <PromptInput
29
+ {...rest}
30
+ value={value}
31
+ onChange={(next) => {
32
+ onChangeSpy?.(next);
33
+ setValue(next);
34
+ }}
35
+ />
36
+ );
37
+ };
38
+
39
+ describe('PromptInput', () => {
40
+ it('renders a controlled textarea with the supplied value', () => {
41
+ render(<Harness initialValue="hello" maxChars={10} onSubmit={vi.fn()} />);
42
+ expect(screen.getByRole('textbox')).toHaveProperty('value', 'hello');
43
+ });
44
+
45
+ it('forwards under-cap input verbatim to onChange', () => {
46
+ const onChangeSpy = vi.fn();
47
+ render(<Harness maxChars={10} onSubmit={vi.fn()} onChangeSpy={onChangeSpy} />);
48
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });
49
+ expect(onChangeSpy).toHaveBeenLastCalledWith('hello');
50
+ });
51
+
52
+ it('truncates over-cap ASCII pastes to the cap before reaching onChange', () => {
53
+ const onChangeSpy = vi.fn();
54
+ render(<Harness maxChars={5} onSubmit={vi.fn()} onChangeSpy={onChangeSpy} />);
55
+ fireEvent.change(screen.getByRole('textbox'), {
56
+ target: { value: 'helloWORLD' },
57
+ });
58
+ expect(onChangeSpy).toHaveBeenLastCalledWith('hello');
59
+ });
60
+
61
+ it('counts emoji as one grapheme โ€” not two โ€” in the visible counter and the cap', () => {
62
+ const onChangeSpy = vi.fn();
63
+ render(
64
+ <Harness
65
+ initialValue={'๐Ÿ˜€๐Ÿ˜€'}
66
+ maxChars={3}
67
+ onSubmit={vi.fn()}
68
+ onChangeSpy={onChangeSpy}
69
+ />,
70
+ );
71
+ expect(screen.getByText('2/3')).toBeTruthy();
72
+
73
+ // A paste that would exceed the cap is grapheme-truncated.
74
+ fireEvent.change(screen.getByRole('textbox'), {
75
+ target: { value: '๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€' },
76
+ });
77
+ expect(onChangeSpy).toHaveBeenLastCalledWith('๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€');
78
+ });
79
+
80
+ it('does not split a surrogate pair on truncation', () => {
81
+ const onChangeSpy = vi.fn();
82
+ render(<Harness maxChars={2} onSubmit={vi.fn()} onChangeSpy={onChangeSpy} />);
83
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a๐Ÿ˜€b' } });
84
+ const value = onChangeSpy.mock.calls[0]?.[0];
85
+ expect(value).toBe('a๐Ÿ˜€');
86
+ // Sanity: the truncation did not slice mid-surrogate (length === 3).
87
+ expect((value as string).length).toBe(3);
88
+ });
89
+
90
+ it('counts ZWJ family clusters as one grapheme', () => {
91
+ const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}';
92
+ render(
93
+ <Harness initialValue={family} maxChars={5} onSubmit={vi.fn()} />,
94
+ );
95
+ expect(screen.getByText('1/5')).toBeTruthy();
96
+ });
97
+
98
+ it('submits the trimmed value on Enter when valid', () => {
99
+ const onSubmit = vi.fn();
100
+ render(<Harness initialValue=" hello " maxChars={20} onSubmit={onSubmit} />);
101
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
102
+ expect(onSubmit).toHaveBeenCalledWith('hello');
103
+ });
104
+
105
+ it('does not submit on Enter when the value is empty after trim', () => {
106
+ const onSubmit = vi.fn();
107
+ render(<Harness initialValue=" " maxChars={20} onSubmit={onSubmit} />);
108
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
109
+ expect(onSubmit).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('does not submit on Enter while pending', () => {
113
+ const onSubmit = vi.fn();
114
+ render(<Harness initialValue="hello" maxChars={20} onSubmit={onSubmit} pending />);
115
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
116
+ expect(onSubmit).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('does not submit on Enter while disabled', () => {
120
+ const onSubmit = vi.fn();
121
+ render(<Harness initialValue="hello" maxChars={20} onSubmit={onSubmit} disabled />);
122
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
123
+ expect(onSubmit).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('lets Shift+Enter pass through (newline insertion)', () => {
127
+ const onSubmit = vi.fn();
128
+ render(<Harness initialValue="hello" maxChars={20} onSubmit={onSubmit} />);
129
+ const event = fireEvent.keyDown(screen.getByRole('textbox'), {
130
+ key: 'Enter',
131
+ shiftKey: true,
132
+ });
133
+ expect(onSubmit).not.toHaveBeenCalled();
134
+ // fireEvent returns false when default was prevented; true otherwise.
135
+ expect(event).toBe(true);
136
+ });
137
+
138
+ it('locks the textarea and surfaces the pending hint when pending=true', () => {
139
+ render(<Harness initialValue="hello" maxChars={20} onSubmit={vi.fn()} pending />);
140
+ expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true);
141
+ expect(screen.getByText('Sendingโ€ฆ')).toBeTruthy();
142
+ });
143
+ });
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `PromptInput` โ€” text submission with grapheme cap and Enter-to-submit.
5
+ */
6
+
7
+ import * as React from 'react';
8
+
9
+ import { graphemeCount, truncateToGraphemes } from '../lib/char-count.js';
10
+ import { cn } from '../lib/cn.js';
11
+
12
+ export type PromptInputProps = {
13
+ readonly value: string;
14
+ readonly onChange: (next: string) => void;
15
+ readonly onSubmit: (final: string) => void;
16
+ readonly maxChars: number;
17
+ readonly placeholder?: string;
18
+ readonly disabled?: boolean;
19
+ readonly pending?: boolean;
20
+ readonly label?: string;
21
+ readonly id?: string;
22
+ readonly className?: string;
23
+ readonly autoFocus?: boolean;
24
+ };
25
+
26
+ export const PromptInput = ({
27
+ value,
28
+ onChange,
29
+ onSubmit,
30
+ maxChars,
31
+ placeholder,
32
+ disabled = false,
33
+ pending = false,
34
+ label,
35
+ id,
36
+ className,
37
+ autoFocus = false,
38
+ }: PromptInputProps): React.ReactElement => {
39
+ const count = graphemeCount(value);
40
+ const isLocked = disabled || pending;
41
+ const trimmed = value.trim();
42
+ const canSubmit = !isLocked && trimmed.length > 0 && count <= maxChars;
43
+
44
+ const handleChange = React.useCallback(
45
+ (event: React.ChangeEvent<HTMLTextAreaElement>) => {
46
+ const next = event.target.value;
47
+ const capped = graphemeCount(next) > maxChars
48
+ ? truncateToGraphemes(next, maxChars)
49
+ : next;
50
+ onChange(capped);
51
+ },
52
+ [maxChars, onChange],
53
+ );
54
+
55
+ const handleKeyDown = React.useCallback(
56
+ (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
57
+ if (event.key !== 'Enter' || event.shiftKey) return;
58
+ if (!canSubmit) {
59
+ event.preventDefault();
60
+ return;
61
+ }
62
+ event.preventDefault();
63
+ onSubmit(trimmed);
64
+ },
65
+ [canSubmit, onSubmit, trimmed],
66
+ );
67
+
68
+ const counterClass = count > maxChars
69
+ ? 'text-destructive'
70
+ : count >= maxChars
71
+ ? 'text-primary'
72
+ : 'text-muted-foreground';
73
+
74
+ return (
75
+ <div className={cn('flex flex-col gap-2', className)}>
76
+ {label !== undefined ? (
77
+ <label
78
+ htmlFor={id}
79
+ className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
80
+ >
81
+ {label}
82
+ </label>
83
+ ) : null}
84
+ <textarea
85
+ id={id}
86
+ autoFocus={autoFocus}
87
+ aria-label={label}
88
+ aria-disabled={isLocked || undefined}
89
+ disabled={isLocked}
90
+ value={value}
91
+ placeholder={placeholder}
92
+ onChange={handleChange}
93
+ onKeyDown={handleKeyDown}
94
+ rows={3}
95
+ data-pending={pending || undefined}
96
+ className={cn(
97
+ 'w-full resize-none rounded-lg border border-input bg-card p-3',
98
+ 'text-base text-foreground',
99
+ 'placeholder:text-muted-foreground',
100
+ 'focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring',
101
+ 'disabled:cursor-not-allowed disabled:opacity-60',
102
+ )}
103
+ />
104
+ <div className="flex items-center justify-between text-xs">
105
+ <span aria-live="polite" className="text-muted-foreground">
106
+ {pending ? 'Sendingโ€ฆ' : '\u00A0'}
107
+ </span>
108
+ <span className={cn('tabular-nums', counterClass)} aria-live="polite">
109
+ {count}/{maxChars}
110
+ </span>
111
+ </div>
112
+ </div>
113
+ );
114
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `RecoveryCodeBadge` โ€” host-only recovery code surface for orphan seats.
3
+ *
4
+ * Per Platform Spec ยง3.11.1 the recovery code appears only on the Host
5
+ * shell as the social-trust mechanism. Renders nothing when no code is
6
+ * active beyond an empty-state hint.
7
+ */
8
+
9
+ import * as React from 'react';
10
+
11
+ import { cn } from '../lib/cn.js';
12
+
13
+ export type RecoveryCodeBadgeProps = {
14
+ readonly code: string | null;
15
+ readonly hint?: React.ReactNode;
16
+ readonly label?: React.ReactNode;
17
+ readonly emptyHint?: React.ReactNode;
18
+ readonly className?: string;
19
+ readonly testId?: string;
20
+ };
21
+
22
+ export const RecoveryCodeBadge = ({
23
+ code,
24
+ hint,
25
+ label,
26
+ emptyHint,
27
+ className,
28
+ testId = 'ui-kit-recovery-code',
29
+ }: RecoveryCodeBadgeProps): React.ReactElement => (
30
+ <section
31
+ aria-live="polite"
32
+ className={cn(
33
+ 'flex flex-col gap-3 rounded-xl border border-border bg-card p-6 shadow-sm',
34
+ className,
35
+ )}
36
+ >
37
+ {label !== undefined ? (
38
+ <h2 className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
39
+ {label}
40
+ </h2>
41
+ ) : null}
42
+ {code ? (
43
+ <>
44
+ <p
45
+ data-testid={testId}
46
+ className="font-mono text-4xl font-semibold tracking-[0.3em] text-primary"
47
+ >
48
+ {code}
49
+ </p>
50
+ {hint !== undefined ? (
51
+ <p className="text-sm text-muted-foreground">{hint}</p>
52
+ ) : null}
53
+ </>
54
+ ) : (
55
+ <p className="text-sm text-muted-foreground">{emptyHint}</p>
56
+ )}
57
+ </section>
58
+ );
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `ResponseCard` โ€” player submission display with optional reveal animation.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+
9
+ export type ResponseCardAttribution = {
10
+ readonly displayName: string;
11
+ readonly visible: boolean;
12
+ };
13
+
14
+ export type ResponseCardProps = {
15
+ readonly body: React.ReactNode;
16
+ readonly attribution: ResponseCardAttribution;
17
+ readonly state: 'hidden' | 'revealing' | 'revealed';
18
+ readonly badge?: React.ReactNode;
19
+ readonly className?: string;
20
+ };
21
+
22
+ export const ResponseCard = ({
23
+ body,
24
+ attribution,
25
+ state,
26
+ badge,
27
+ className,
28
+ }: ResponseCardProps): React.ReactElement => {
29
+ const isHidden = state === 'hidden';
30
+ const isRevealed = state === 'revealed';
31
+ const showAuthor = isRevealed && attribution.visible;
32
+
33
+ return (
34
+ <article
35
+ data-state={state}
36
+ data-revealed={isRevealed || undefined}
37
+ className={cn(
38
+ 'wibly-response-card flex flex-col rounded-xl border border-border bg-card shadow-sm',
39
+ 'transition-all duration-500 ease-out',
40
+ state === 'revealing' && 'blur-[1px]',
41
+ className,
42
+ )}
43
+ >
44
+ <header className="flex items-center justify-between gap-4 border-b border-border/70 pb-3">
45
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
46
+ {showAuthor ? attribution.displayName : 'Anonymous'}
47
+ </span>
48
+ {badge !== undefined ? (
49
+ <span className="shrink-0 rounded-full bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground">
50
+ {badge}
51
+ </span>
52
+ ) : null}
53
+ </header>
54
+ <div
55
+ aria-hidden={isHidden}
56
+ className={cn(
57
+ 'pt-4 text-base leading-relaxed text-foreground',
58
+ 'transition-opacity duration-500',
59
+ isHidden && 'select-none opacity-0',
60
+ )}
61
+ >
62
+ {isHidden ? (
63
+ <span className="block h-14 w-full rounded-lg bg-border/50" aria-hidden="true" />
64
+ ) : (
65
+ body
66
+ )}
67
+ </div>
68
+ </article>
69
+ );
70
+ };
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Full-screen overlay shown when a live session is aborted by the host.
5
+ */
6
+
7
+ import * as React from 'react';
8
+
9
+ import { cn } from '../lib/cn.js';
10
+
11
+ export type SessionAbortedOverlayProps = {
12
+ readonly gameName: string;
13
+ readonly onAck: () => void;
14
+ /** Auto-invoke `onAck` after this many milliseconds. Defaults to 10s. */
15
+ readonly redirectMs?: number;
16
+ readonly className?: string;
17
+ readonly testId?: string;
18
+ };
19
+
20
+ export const SessionAbortedOverlay = ({
21
+ gameName,
22
+ onAck,
23
+ redirectMs = 10_000,
24
+ className,
25
+ testId = 'session-aborted-overlay',
26
+ }: SessionAbortedOverlayProps): React.ReactElement => {
27
+ React.useEffect(() => {
28
+ const timer = window.setTimeout(onAck, redirectMs);
29
+ return () => window.clearTimeout(timer);
30
+ }, [onAck, redirectMs]);
31
+
32
+ return (
33
+ <div
34
+ role="dialog"
35
+ aria-modal="true"
36
+ aria-labelledby="session-aborted-title"
37
+ data-testid={testId}
38
+ className={cn(
39
+ 'fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-sm',
40
+ className,
41
+ )}
42
+ >
43
+ <section className="flex w-full max-w-md flex-col items-center gap-6 rounded-3xl border border-foreground/10 bg-card px-8 py-10 text-center shadow-lg">
44
+ <h1
45
+ id="session-aborted-title"
46
+ className="font-display text-2xl font-bold text-foreground md:text-3xl"
47
+ >
48
+ {gameName} has been aborted by the Host
49
+ </h1>
50
+ <button
51
+ type="button"
52
+ onClick={onAck}
53
+ className="rounded-xl bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
54
+ >
55
+ Okay
56
+ </button>
57
+ </section>
58
+ </div>
59
+ );
60
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Tier-1 invariant tests for `Timer`.
3
+ *
4
+ * Per Dev Spec ยง6.6 chunk-B10 acceptance: "Tests for components
5
+ * that have meaningful logic (Timer's deadline math, โ€ฆ)."
6
+ *
7
+ * The component renders under happy-dom with vitest's fake timers.
8
+ * `nowMs` is a controllable thunk so the tests advance simulated
9
+ * server time without waiting on the wall clock.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { act, render, screen } from '@testing-library/react';
14
+
15
+ import { Timer } from './timer.js';
16
+
17
+ beforeEach(() => {
18
+ vi.useFakeTimers();
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ const readDisplay = (): string => {
26
+ // The countdown is the only `<span>` with a numeric `M:SS` /
27
+ // `SS` body inside the timer surface; selecting by role + class
28
+ // would over-couple to internals, so the value goes via the
29
+ // role+aria-label combination.
30
+ const timer = screen.getByRole('timer');
31
+ return timer.textContent?.trim() ?? '';
32
+ };
33
+
34
+ describe('Timer', () => {
35
+ it('renders the initial remaining duration on first paint', () => {
36
+ const fixedNow = 1_000_000;
37
+ render(<Timer deadlineMs={fixedNow + 30_500} nowMs={() => fixedNow} />);
38
+ expect(readDisplay()).toBe('0:31');
39
+ });
40
+
41
+ it('updates the displayed countdown as nowMs advances', () => {
42
+ let now = 1_000_000;
43
+ render(<Timer deadlineMs={now + 10_000} nowMs={() => now} tickMs={250} />);
44
+ expect(readDisplay()).toBe('0:10');
45
+
46
+ now += 5_000;
47
+ act(() => {
48
+ vi.advanceTimersByTime(250);
49
+ });
50
+ expect(readDisplay()).toBe('0:05');
51
+
52
+ now += 4_000;
53
+ act(() => {
54
+ vi.advanceTimersByTime(250);
55
+ });
56
+ expect(readDisplay()).toBe('0:01');
57
+ });
58
+
59
+ it('reads `nowMs` via a ref so a changing thunk identity does not resubscribe the interval', () => {
60
+ const now = 1_000_000;
61
+ const deadline = now + 10_000;
62
+
63
+ const view = render(
64
+ <Timer deadlineMs={deadline} nowMs={() => now} tickMs={250} />,
65
+ );
66
+
67
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
68
+ setIntervalSpy.mockClear();
69
+ view.rerender(
70
+ <Timer deadlineMs={deadline} nowMs={() => now} tickMs={250} />,
71
+ );
72
+ expect(setIntervalSpy).not.toHaveBeenCalled();
73
+ setIntervalSpy.mockRestore();
74
+ });
75
+
76
+ it('renders 0:00 and exposes data-expired when the deadline has passed', () => {
77
+ const now = 1_000_000;
78
+ render(<Timer deadlineMs={now - 5_000} nowMs={() => now} />);
79
+ expect(readDisplay()).toBe('0:00');
80
+ expect(screen.getByRole('timer').getAttribute('data-expired')).toBe('true');
81
+ });
82
+
83
+ it('fires onExpire exactly once when remaining crosses 0', () => {
84
+ let now = 1_000_000;
85
+ const deadline = now + 1_000;
86
+ const onExpire = vi.fn();
87
+ render(
88
+ <Timer
89
+ deadlineMs={deadline}
90
+ nowMs={() => now}
91
+ onExpire={onExpire}
92
+ tickMs={250}
93
+ />,
94
+ );
95
+ expect(onExpire).not.toHaveBeenCalled();
96
+
97
+ now += 1_500;
98
+ act(() => {
99
+ vi.advanceTimersByTime(250);
100
+ });
101
+ expect(onExpire).toHaveBeenCalledTimes(1);
102
+
103
+ now += 1_000;
104
+ act(() => {
105
+ vi.advanceTimersByTime(1_000);
106
+ });
107
+ expect(onExpire).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('resets the expired-once latch when deadlineMs changes', () => {
111
+ let now = 1_000_000;
112
+ let deadline = now + 1_000;
113
+ const onExpire = vi.fn();
114
+
115
+ const view = render(
116
+ <Timer
117
+ deadlineMs={deadline}
118
+ nowMs={() => now}
119
+ onExpire={onExpire}
120
+ tickMs={250}
121
+ />,
122
+ );
123
+
124
+ now += 1_500;
125
+ act(() => {
126
+ vi.advanceTimersByTime(250);
127
+ });
128
+ expect(onExpire).toHaveBeenCalledTimes(1);
129
+
130
+ deadline = now + 1_000;
131
+ view.rerender(
132
+ <Timer
133
+ deadlineMs={deadline}
134
+ nowMs={() => now}
135
+ onExpire={onExpire}
136
+ tickMs={250}
137
+ />,
138
+ );
139
+
140
+ now += 1_500;
141
+ act(() => {
142
+ vi.advanceTimersByTime(250);
143
+ });
144
+ expect(onExpire).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it('clears the interval on unmount', () => {
148
+ const clearSpy = vi.spyOn(globalThis, 'clearInterval');
149
+ const now = 1_000_000;
150
+ const view = render(
151
+ <Timer deadlineMs={now + 10_000} nowMs={() => now} tickMs={250} />,
152
+ );
153
+ view.unmount();
154
+ expect(clearSpy).toHaveBeenCalled();
155
+ clearSpy.mockRestore();
156
+ });
157
+ });