@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 ADDED
@@ -0,0 +1,7 @@
1
+ # `@wibly/ui-kit` — Changelog
2
+
3
+ ## 0.1.1 — 2026-05-30
4
+
5
+ Initial public npm release. Eight shared React components plus the Wibly
6
+ shell theme stylesheet (`styles/wibly-shell-theme.css`), meant to be bundled
7
+ into Experience `host.mjs` / `player.mjs` alongside React.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@wibly/ui-kit",
3
+ "version": "0.1.1",
4
+ "description": "Wibly @wibly/ui-kit",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./styles/wibly-shell-theme.css": "./styles/wibly-shell-theme.css"
11
+ },
12
+ "license": "UNLICENSED",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/wibly/wibly"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "dependencies": {
21
+ "clsx": "^2.1.1"
22
+ },
23
+ "peerDependencies": {
24
+ "@wibly/sdk": "0.1.1",
25
+ "react": "^19.0.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "react": {
29
+ "optional": true
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `AvatarStage` — renders the bound persona's avatar with simple
3
+ * animation states (idle, speaking, judging).
4
+ */
5
+
6
+ import * as React from 'react';
7
+
8
+ import { cn } from '../lib/cn.js';
9
+
10
+ export type AvatarStageState = 'idle' | 'speaking' | 'judging';
11
+
12
+ export type AvatarStageProps = {
13
+ readonly state: AvatarStageState;
14
+ readonly children?: React.ReactNode;
15
+ /** Optional accessible label, e.g. the persona's display name. */
16
+ readonly label?: string;
17
+ /** Override the default size. */
18
+ readonly size?: 'sm' | 'md' | 'lg';
19
+ /** Show the state caption under the avatar. Default true. */
20
+ readonly showStateLabel?: boolean;
21
+ readonly className?: string;
22
+ };
23
+
24
+ const SIZE_CLASS: Record<NonNullable<AvatarStageProps['size']>, string> = {
25
+ sm: 'size-24',
26
+ md: 'size-40',
27
+ lg: 'size-64',
28
+ };
29
+
30
+ const STATE_SHELL: Record<AvatarStageState, string> = {
31
+ idle: cn(
32
+ 'wibly-avatar-stage-idle border border-border bg-gradient-to-br from-card via-card to-secondary',
33
+ 'shadow-sm',
34
+ ),
35
+ speaking: cn(
36
+ 'wibly-avatar-stage-speaking border-2 border-primary bg-gradient-to-br from-card via-primary/5 to-card',
37
+ ),
38
+ judging: cn(
39
+ 'wibly-avatar-stage-judging border-2 border-accent bg-gradient-to-br from-card via-accent/10 to-card',
40
+ ),
41
+ };
42
+
43
+ const STATE_LABEL: Record<AvatarStageState, string> = {
44
+ idle: 'Idle',
45
+ speaking: 'Speaking',
46
+ judging: 'Thinking',
47
+ };
48
+
49
+ const AvatarFallback = (): React.ReactElement => (
50
+ <div className="flex size-full flex-col items-center justify-center gap-1 bg-gradient-to-br from-primary/10 via-card to-mint/15">
51
+ <svg
52
+ aria-hidden="true"
53
+ viewBox="0 0 24 24"
54
+ className="size-1/3 text-primary/70"
55
+ fill="currentColor"
56
+ >
57
+ <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
58
+ </svg>
59
+ <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
60
+ Avatar
61
+ </span>
62
+ </div>
63
+ );
64
+
65
+ export const AvatarStage = ({
66
+ state,
67
+ children,
68
+ label,
69
+ size = 'md',
70
+ showStateLabel = true,
71
+ className,
72
+ }: AvatarStageProps): React.ReactElement => (
73
+ <div className={cn('inline-flex shrink-0 flex-col items-center gap-2.5', className)}>
74
+ <div
75
+ role="img"
76
+ aria-label={label ?? `Persona avatar, ${STATE_LABEL[state]}`}
77
+ data-state={state}
78
+ className={cn(
79
+ 'relative shrink-0 overflow-hidden rounded-full',
80
+ 'aspect-square',
81
+ SIZE_CLASS[size],
82
+ STATE_SHELL[state],
83
+ )}
84
+ >
85
+ <div className="flex size-full items-center justify-center overflow-hidden rounded-full">
86
+ {children ?? <AvatarFallback />}
87
+ </div>
88
+ </div>
89
+ {showStateLabel ? (
90
+ <span
91
+ aria-live="polite"
92
+ className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
93
+ >
94
+ {STATE_LABEL[state]}
95
+ </span>
96
+ ) : null}
97
+ </div>
98
+ );
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { ConsentRequiredPayload } from '@wibly/sdk';
4
+
5
+ import { resolveConsentDialogCopy } from './consent-dialog.js';
6
+ import { CONSENT_DIALOG_DEFAULTS } from '../lib/consent-meta.js';
7
+
8
+ const basePayload = (
9
+ overrides: Partial<ConsentRequiredPayload> = {},
10
+ ): ConsentRequiredPayload => ({
11
+ personaId: 'psn_test',
12
+ personaDisplayName: 'Crumb',
13
+ scope: 'player',
14
+ ...overrides,
15
+ });
16
+
17
+ describe('resolveConsentDialogCopy', () => {
18
+ describe('kind=mid-session', () => {
19
+ it('falls back to the platform default when no per-Persona override is present', () => {
20
+ const copy = resolveConsentDialogCopy('mid-session', basePayload());
21
+ expect(copy.title).toBe(
22
+ CONSENT_DIALOG_DEFAULTS['mid-session'].title('Crumb'),
23
+ );
24
+ expect(copy.description).toBe(
25
+ CONSENT_DIALOG_DEFAULTS['mid-session'].description,
26
+ );
27
+ });
28
+ });
29
+
30
+ describe('kind=settings', () => {
31
+ it('uses the settings-surface defaults distinct from mid-session', () => {
32
+ const copy = resolveConsentDialogCopy('settings', basePayload());
33
+ expect(copy.title).toBe(CONSENT_DIALOG_DEFAULTS.settings.title('Crumb'));
34
+ expect(copy.title).not.toBe(
35
+ CONSENT_DIALOG_DEFAULTS['mid-session'].title('Crumb'),
36
+ );
37
+ });
38
+ });
39
+ });
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import type {
6
+ ConsentRequiredCallback,
7
+ ConsentRequiredPayload,
8
+ } from '@wibly/sdk';
9
+
10
+ import {
11
+ type ConsentDialogKind,
12
+ CONSENT_DIALOG_DEFAULTS,
13
+ } from '../lib/consent-meta.js';
14
+ import { type ConsentFlowState } from '../lib/consent-flow.js';
15
+ import {
16
+ ConsentPrompt,
17
+ type ConsentPromptCopyOverride,
18
+ } from './consent-prompt.js';
19
+
20
+ export type { ConsentDialogKind };
21
+
22
+ export type ConsentDialogProps = {
23
+ readonly kind: ConsentDialogKind;
24
+ readonly payload: ConsentRequiredPayload;
25
+ readonly onResolve: ConsentRequiredCallback;
26
+ readonly onResolved?: (state: ConsentFlowState) => void;
27
+ };
28
+
29
+ export const resolveConsentDialogCopy = (
30
+ kind: ConsentDialogKind,
31
+ payload: ConsentRequiredPayload,
32
+ ): Required<ConsentPromptCopyOverride> => {
33
+ const surfaceDefaults = CONSENT_DIALOG_DEFAULTS[kind];
34
+ return {
35
+ title:
36
+ payload.consentCopy?.title ??
37
+ surfaceDefaults.title(payload.personaDisplayName),
38
+ description:
39
+ payload.consentCopy?.description ?? surfaceDefaults.description,
40
+ };
41
+ };
42
+
43
+ export const ConsentDialog = ({
44
+ kind,
45
+ payload,
46
+ onResolve,
47
+ onResolved,
48
+ }: ConsentDialogProps): React.ReactElement | null => {
49
+ const copy = resolveConsentDialogCopy(kind, payload);
50
+ return (
51
+ <ConsentPrompt
52
+ payload={payload}
53
+ onResolve={onResolve}
54
+ copy={copy}
55
+ {...(onResolved ? { onResolved } : {})}
56
+ />
57
+ );
58
+ };
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import type {
6
+ ConsentRequiredCallback,
7
+ ConsentRequiredPayload,
8
+ } from '@wibly/sdk';
9
+
10
+ import {
11
+ type ConsentFlowState,
12
+ consentFlowReducer,
13
+ initialConsentFlowState,
14
+ } from '../lib/consent-flow.js';
15
+ import { CONSENT_PROMPT_COPY } from '../lib/consent-meta.js';
16
+
17
+ export type ConsentPromptCopyOverride = {
18
+ readonly title?: string;
19
+ readonly description?: string;
20
+ };
21
+
22
+ export type ConsentPromptProps = {
23
+ readonly payload: ConsentRequiredPayload;
24
+ readonly onResolve: ConsentRequiredCallback;
25
+ readonly onResolved?: (state: ConsentFlowState) => void;
26
+ readonly copy?: ConsentPromptCopyOverride;
27
+ };
28
+
29
+ export const ConsentPrompt = ({
30
+ payload,
31
+ onResolve,
32
+ onResolved,
33
+ copy,
34
+ }: ConsentPromptProps): React.ReactElement | null => {
35
+ const [state, dispatch] = React.useReducer(
36
+ consentFlowReducer,
37
+ initialConsentFlowState(payload),
38
+ );
39
+
40
+ React.useEffect(() => {
41
+ if (state.kind !== 'submitting') return;
42
+ let cancelled = false;
43
+ void (async () => {
44
+ try {
45
+ await onResolve(state.payload);
46
+ } finally {
47
+ if (!cancelled) dispatch({ type: 'confirmed' });
48
+ }
49
+ })();
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [state, onResolve]);
54
+
55
+ React.useEffect(() => {
56
+ if (state.kind === 'resolved') onResolved?.(state);
57
+ }, [state, onResolved]);
58
+
59
+ if (state.kind === 'resolved') return null;
60
+
61
+ const submitting = state.kind === 'submitting';
62
+ const titleId = 'consent-prompt-title';
63
+ const title =
64
+ copy?.title ?? CONSENT_PROMPT_COPY.title(payload.personaDisplayName);
65
+ const description = copy?.description ?? CONSENT_PROMPT_COPY.description;
66
+
67
+ return (
68
+ <section
69
+ role="dialog"
70
+ aria-modal="true"
71
+ aria-labelledby={titleId}
72
+ className="mx-auto flex w-full max-w-md flex-col items-stretch gap-4 rounded-2xl border border-border bg-card p-6 shadow-sm"
73
+ >
74
+ <h2
75
+ id={titleId}
76
+ className="font-display text-lg font-semibold text-foreground"
77
+ >
78
+ {title}
79
+ </h2>
80
+ <p className="text-sm text-muted-foreground">
81
+ {description}
82
+ </p>
83
+ <div className="flex flex-col gap-2 self-stretch">
84
+ <button
85
+ type="button"
86
+ disabled={submitting}
87
+ onClick={() => dispatch({ type: 'accept' })}
88
+ className="rounded-lg bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground disabled:opacity-60"
89
+ >
90
+ {CONSENT_PROMPT_COPY.acceptLabel}
91
+ </button>
92
+ <button
93
+ type="button"
94
+ disabled={submitting}
95
+ onClick={() => dispatch({ type: 'decline' })}
96
+ className="rounded-lg border border-border px-4 py-3 text-sm text-foreground disabled:opacity-60"
97
+ >
98
+ {CONSENT_PROMPT_COPY.declineLabel}
99
+ </button>
100
+ </div>
101
+ <p className="text-xs text-muted-foreground">
102
+ {CONSENT_PROMPT_COPY.managementHint}
103
+ </p>
104
+ </section>
105
+ );
106
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `JoinCodeBadge` — large, accessible join code display for session
3
+ * lobbies.
4
+ *
5
+ * Sized to be legible on a TV (host) or phone (player). The consumer
6
+ * formats and supplies the code; the badge does not validate format.
7
+ */
8
+
9
+ import * as React from 'react';
10
+
11
+ import { cn } from '../lib/cn.js';
12
+
13
+ export type JoinCodeBadgeProps = {
14
+ /** The 6-character code, or null while the Runtime hasn't issued one. */
15
+ readonly code: string | null;
16
+ /** Optional copy under the code (e.g. "Visit play.wibly.com"). */
17
+ readonly hint?: React.ReactNode;
18
+ /** Optional small label above the code (e.g. "JOIN CODE"). */
19
+ readonly label?: React.ReactNode;
20
+ /** Visual size; the host shell uses `'lg'`. */
21
+ readonly size?: 'md' | 'lg';
22
+ readonly className?: string;
23
+ readonly testId?: string;
24
+ };
25
+
26
+ const PLACEHOLDER = '— — — — — —';
27
+
28
+ const SIZE_CLASS: Record<NonNullable<JoinCodeBadgeProps['size']>, string> = {
29
+ md: 'text-4xl tracking-[0.3em]',
30
+ lg: 'text-6xl tracking-[0.4em]',
31
+ };
32
+
33
+ export const JoinCodeBadge = ({
34
+ code,
35
+ hint,
36
+ label,
37
+ size = 'lg',
38
+ className,
39
+ testId = 'ui-kit-join-code',
40
+ }: JoinCodeBadgeProps): React.ReactElement => {
41
+ const displayCode = code ?? PLACEHOLDER;
42
+ return (
43
+ <div
44
+ className={cn(
45
+ 'flex flex-col gap-3 rounded-xl border border-border bg-card p-6 shadow-sm',
46
+ className,
47
+ )}
48
+ >
49
+ {label !== undefined ? (
50
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
51
+ {label}
52
+ </span>
53
+ ) : null}
54
+ <p
55
+ aria-label={code ? `Join code ${code.split('').join(' ')}` : 'Join code pending'}
56
+ data-testid={testId}
57
+ className={cn(
58
+ 'font-mono font-semibold text-foreground',
59
+ SIZE_CLASS[size],
60
+ )}
61
+ >
62
+ {displayCode}
63
+ </p>
64
+ {hint !== undefined ? (
65
+ <span className="text-sm text-muted-foreground">{hint}</span>
66
+ ) : null}
67
+ </div>
68
+ );
69
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `Leaderboard` — score display with dimensions.
3
+ *
4
+ * Uses CSS grid (not `<table>`) so layout stays stable under Tailwind v4
5
+ * Preflight and when utility classes are compiled from `@wibly/ui-kit`.
6
+ */
7
+
8
+ import * as React from 'react';
9
+
10
+ import { cn } from '../lib/cn.js';
11
+
12
+ export type LeaderboardDimension = {
13
+ readonly id: string;
14
+ readonly label: string;
15
+ };
16
+
17
+ export type LeaderboardRow = {
18
+ readonly playerId: string;
19
+ readonly displayName: string;
20
+ /** Per-dimension scores; missing keys render as a dash. */
21
+ readonly scores: Readonly<Record<string, number>>;
22
+ /** Optional precomputed total — when omitted, no total column renders. */
23
+ readonly total?: number;
24
+ /** Optional dense-rank position (1, 2, 2, 4, …). When omitted, derived from row order. */
25
+ readonly position?: number;
26
+ /** Highlight the row (the local player, the leader, etc.). */
27
+ readonly highlighted?: boolean;
28
+ };
29
+
30
+ export type LeaderboardProps = {
31
+ readonly dimensions: readonly LeaderboardDimension[];
32
+ readonly rows: readonly LeaderboardRow[];
33
+ readonly showTotal?: boolean;
34
+ readonly className?: string;
35
+ readonly emptyState?: React.ReactNode;
36
+ };
37
+
38
+ const formatScore = (value: number | undefined): string => {
39
+ if (value === undefined || Number.isNaN(value)) return '—';
40
+ return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
41
+ };
42
+
43
+ const buildGridTemplate = (
44
+ dimensionCount: number,
45
+ showTotal: boolean,
46
+ ): string => {
47
+ const scoreColumns = Array.from({ length: dimensionCount }, () => '4.5rem').join(' ');
48
+ const totalColumn = showTotal ? ' 4.5rem' : '';
49
+ return `2.75rem minmax(7rem, 1.4fr)${scoreColumns.length > 0 ? ` ${scoreColumns}` : ''}${totalColumn}`;
50
+ };
51
+
52
+ export const Leaderboard = ({
53
+ dimensions,
54
+ rows,
55
+ showTotal = false,
56
+ className,
57
+ emptyState,
58
+ }: LeaderboardProps): React.ReactElement => {
59
+ if (rows.length === 0) {
60
+ return (
61
+ <div
62
+ className={cn(
63
+ 'rounded-xl border border-dashed border-border bg-card p-6 text-center text-sm text-muted-foreground shadow-sm',
64
+ className,
65
+ )}
66
+ >
67
+ {emptyState ?? 'No scores yet.'}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ const gridTemplateColumns = buildGridTemplate(dimensions.length, showTotal);
73
+
74
+ return (
75
+ <div
76
+ className={cn(
77
+ 'overflow-x-auto rounded-xl border border-border bg-card shadow-sm',
78
+ className,
79
+ )}
80
+ >
81
+ <div
82
+ className="min-w-[20rem]"
83
+ role="table"
84
+ aria-label="Leaderboard"
85
+ >
86
+ <div
87
+ className="grid items-center border-b border-border bg-secondary text-[11px] uppercase tracking-wide text-muted-foreground"
88
+ style={{ gridTemplateColumns }}
89
+ role="row"
90
+ >
91
+ <div className="px-3 py-2.5 font-medium" role="columnheader">
92
+ #
93
+ </div>
94
+ <div className="px-3 py-2.5 font-medium" role="columnheader">
95
+ Player
96
+ </div>
97
+ {dimensions.map((dim) => (
98
+ <div
99
+ key={dim.id}
100
+ className="px-3 py-2.5 text-right font-medium"
101
+ role="columnheader"
102
+ >
103
+ {dim.label}
104
+ </div>
105
+ ))}
106
+ {showTotal ? (
107
+ <div className="px-3 py-2.5 text-right font-medium" role="columnheader">
108
+ Total
109
+ </div>
110
+ ) : null}
111
+ </div>
112
+
113
+ {rows.map((row, index) => (
114
+ <div
115
+ key={row.playerId}
116
+ data-highlighted={row.highlighted || undefined}
117
+ className={cn(
118
+ 'grid items-center border-b border-border text-sm last:border-b-0',
119
+ row.highlighted ? 'bg-secondary/70' : 'bg-card',
120
+ )}
121
+ style={{ gridTemplateColumns }}
122
+ role="row"
123
+ >
124
+ <div
125
+ className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground"
126
+ role="cell"
127
+ >
128
+ {row.position ?? index + 1}
129
+ </div>
130
+ <div
131
+ className="min-w-0 truncate px-3 py-2.5 font-medium text-foreground"
132
+ role="cell"
133
+ title={row.displayName}
134
+ >
135
+ {row.displayName}
136
+ </div>
137
+ {dimensions.map((dim) => (
138
+ <div
139
+ key={dim.id}
140
+ className="px-3 py-2.5 text-right font-mono tabular-nums text-foreground"
141
+ role="cell"
142
+ >
143
+ {formatScore(row.scores[dim.id])}
144
+ </div>
145
+ ))}
146
+ {showTotal ? (
147
+ <div
148
+ className="px-3 py-2.5 text-right font-mono font-semibold tabular-nums text-foreground"
149
+ role="cell"
150
+ >
151
+ {formatScore(row.total)}
152
+ </div>
153
+ ) : null}
154
+ </div>
155
+ ))}
156
+ </div>
157
+ </div>
158
+ );
159
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `PausedBanner` — banner for paused session state.
3
+ *
4
+ * Overlays above the Experience surface during a `pause_session`
5
+ * lifecycle response (Platform Spec §3.8.2).
6
+ */
7
+
8
+ import * as React from 'react';
9
+
10
+ import { cn } from '../lib/cn.js';
11
+
12
+ export type PausedBannerReason = 'host' | 'system' | 'safety';
13
+
14
+ export type PausedBannerProps = {
15
+ readonly reason: PausedBannerReason;
16
+ readonly headline: React.ReactNode;
17
+ readonly description?: React.ReactNode;
18
+ /** Action slot (e.g. a "Resume" button rendered by the User Portal). */
19
+ readonly action?: React.ReactNode;
20
+ readonly className?: string;
21
+ readonly testId?: string;
22
+ };
23
+
24
+ const REASON_LABEL: Record<PausedBannerReason, string> = {
25
+ host: 'Paused by host',
26
+ system: 'Paused by system',
27
+ safety: 'Paused — safety review',
28
+ };
29
+
30
+ const REASON_TONE: Record<PausedBannerReason, string> = {
31
+ host: 'border-primary/30 text-primary',
32
+ system: 'border-accent/40 text-accent-foreground',
33
+ safety: 'border-destructive/40 text-destructive',
34
+ };
35
+
36
+ export const PausedBanner = ({
37
+ reason,
38
+ headline,
39
+ description,
40
+ action,
41
+ className,
42
+ testId = 'session-paused-banner',
43
+ }: PausedBannerProps): React.ReactElement => (
44
+ <section
45
+ role="status"
46
+ aria-live="polite"
47
+ data-reason={reason}
48
+ data-testid={testId}
49
+ className={cn(
50
+ 'flex w-full flex-col items-center justify-center gap-3 rounded-xl border bg-card px-6 py-8 text-center shadow-sm',
51
+ REASON_TONE[reason],
52
+ className,
53
+ )}
54
+ >
55
+ <span className="text-[11px] font-medium uppercase tracking-[0.2em]">
56
+ {REASON_LABEL[reason]}
57
+ </span>
58
+ <h2 className="font-display text-2xl font-semibold text-foreground md:text-3xl">
59
+ {headline}
60
+ </h2>
61
+ {description !== undefined ? (
62
+ <p className="max-w-prose text-sm text-muted-foreground">{description}</p>
63
+ ) : null}
64
+ {action !== undefined ? <div className="pt-2">{action}</div> : null}
65
+ </section>
66
+ );