@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
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
|
+
);
|