@wibly/ui-kit 0.1.1 → 0.1.3

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,71 @@
1
+ /**
2
+ * `PlayerRoster` — horizontal player chips with optional running scores.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+ import { PlayerAvatar, type PlayerAvatarProps } from './player-avatar.js';
9
+
10
+ export type PlayerRosterEntry = {
11
+ readonly playerId: string;
12
+ readonly displayName: string;
13
+ readonly imageUrl?: string | null;
14
+ readonly score?: number;
15
+ readonly connected?: boolean;
16
+ readonly highlighted?: boolean;
17
+ };
18
+
19
+ export type PlayerRosterProps = {
20
+ readonly players: readonly PlayerRosterEntry[];
21
+ readonly size?: PlayerAvatarProps['size'];
22
+ readonly className?: string;
23
+ readonly emptyState?: React.ReactNode;
24
+ readonly testId?: string;
25
+ };
26
+
27
+ export const PlayerRoster = ({
28
+ players,
29
+ size = 'md',
30
+ className,
31
+ emptyState,
32
+ testId = 'player-roster',
33
+ }: PlayerRosterProps): React.ReactElement => {
34
+ if (players.length === 0) {
35
+ return (
36
+ <div
37
+ data-testid={testId}
38
+ className={cn(
39
+ 'rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted-foreground',
40
+ className,
41
+ )}
42
+ >
43
+ {emptyState ?? 'No players yet.'}
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <ul
50
+ data-testid={testId}
51
+ className={cn(
52
+ 'flex flex-wrap items-end justify-center gap-4',
53
+ className,
54
+ )}
55
+ >
56
+ {players.map((player) => (
57
+ <li key={player.playerId}>
58
+ <PlayerAvatar
59
+ playerId={player.playerId}
60
+ displayName={player.displayName}
61
+ imageUrl={player.imageUrl}
62
+ score={player.score}
63
+ connected={player.connected}
64
+ highlighted={player.highlighted}
65
+ size={size}
66
+ />
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ );
71
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `ShellBrandMark` — minimal Wibly mark for Host linked-session chrome.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+
9
+ export type ShellBrandMarkProps = {
10
+ readonly src?: string;
11
+ readonly alt?: string;
12
+ readonly className?: string;
13
+ readonly testId?: string;
14
+ };
15
+
16
+ export const ShellBrandMark = ({
17
+ src = '/logo.png',
18
+ alt = 'Wibly',
19
+ className,
20
+ testId = 'shell-brand-mark',
21
+ }: ShellBrandMarkProps): React.ReactElement => (
22
+ <div
23
+ data-testid={testId}
24
+ className={cn(
25
+ 'pointer-events-none fixed top-[max(0.75rem,env(safe-area-inset-top))] left-[max(0.75rem,env(safe-area-inset-left))] z-30 opacity-40',
26
+ className,
27
+ )}
28
+ aria-hidden
29
+ >
30
+ {/* eslint-disable-next-line @next/next/no-img-element -- ui-kit is framework-agnostic */}
31
+ <img src={src} alt={alt} className="h-7 w-auto select-none" />
32
+ </div>
33
+ );
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `ShellGlassToolbar` — floating transparent toolbar for Host linked mode.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+
9
+ export type ShellGlassToolbarProps = {
10
+ readonly children: React.ReactNode;
11
+ readonly className?: string;
12
+ readonly testId?: string;
13
+ };
14
+
15
+ export const ShellGlassToolbar = ({
16
+ children,
17
+ className,
18
+ testId = 'shell-glass-toolbar',
19
+ }: ShellGlassToolbarProps): React.ReactElement => (
20
+ <div
21
+ data-testid={testId}
22
+ className={cn(
23
+ 'fixed top-[max(0.75rem,env(safe-area-inset-top))] right-[max(0.75rem,env(safe-area-inset-right))] z-40',
24
+ 'flex items-center gap-1 rounded-full border border-foreground/10 bg-background/35 p-1 shadow-lg backdrop-blur-md',
25
+ className,
26
+ )}
27
+ role="toolbar"
28
+ aria-label="Host session controls"
29
+ >
30
+ {children}
31
+ </div>
32
+ );
33
+
34
+ export type ShellToolbarButtonProps = {
35
+ readonly label: string;
36
+ readonly onClick?: () => void;
37
+ readonly disabled?: boolean;
38
+ readonly className?: string;
39
+ readonly testId?: string;
40
+ readonly 'aria-expanded'?: boolean;
41
+ readonly 'aria-controls'?: string;
42
+ };
43
+
44
+ export const ShellToolbarButton = ({
45
+ label,
46
+ onClick,
47
+ disabled = false,
48
+ className,
49
+ testId,
50
+ 'aria-expanded': ariaExpanded,
51
+ 'aria-controls': ariaControls,
52
+ }: ShellToolbarButtonProps): React.ReactElement => (
53
+ <button
54
+ type="button"
55
+ onClick={onClick}
56
+ disabled={disabled}
57
+ data-testid={testId}
58
+ aria-expanded={ariaExpanded}
59
+ aria-controls={ariaControls}
60
+ className={cn(
61
+ 'rounded-full px-3 py-1.5 text-xs font-semibold tracking-wide text-foreground uppercase',
62
+ 'transition-colors hover:bg-foreground/10 disabled:cursor-not-allowed disabled:opacity-40',
63
+ className,
64
+ )}
65
+ >
66
+ {label}
67
+ </button>
68
+ );
69
+
70
+ export type ShellToolbarMenuProps = {
71
+ readonly triggerLabel?: string;
72
+ readonly open: boolean;
73
+ readonly onOpenChange: (open: boolean) => void;
74
+ readonly children: React.ReactNode;
75
+ readonly testId?: string;
76
+ };
77
+
78
+ export const ShellToolbarMenu = ({
79
+ triggerLabel = '…',
80
+ open,
81
+ onOpenChange,
82
+ children,
83
+ testId = 'shell-toolbar-menu',
84
+ }: ShellToolbarMenuProps): React.ReactElement => {
85
+ const menuId = React.useId();
86
+
87
+ return (
88
+ <div className="relative" data-testid={testId}>
89
+ <ShellToolbarButton
90
+ label={triggerLabel}
91
+ onClick={() => onOpenChange(!open)}
92
+ aria-expanded={open}
93
+ aria-controls={menuId}
94
+ testId={`${testId}-trigger`}
95
+ />
96
+ {open ? (
97
+ <div
98
+ id={menuId}
99
+ role="menu"
100
+ className="absolute top-full right-0 z-50 mt-2 min-w-[12rem] rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-xl"
101
+ >
102
+ {children}
103
+ </div>
104
+ ) : null}
105
+ </div>
106
+ );
107
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `ShellMenuAvatar` — Player linked-session menu trigger (top-right).
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+
9
+ export type ShellMenuAvatarProps = {
10
+ readonly imageUrl?: string | null;
11
+ readonly displayName?: string;
12
+ readonly open: boolean;
13
+ readonly onOpenChange: (open: boolean) => void;
14
+ readonly menuContent: React.ReactNode;
15
+ readonly className?: string;
16
+ readonly testId?: string;
17
+ };
18
+
19
+ const initialsFromName = (name: string): string => {
20
+ const parts = name.trim().split(/\s+/).filter(Boolean);
21
+ if (parts.length === 0) return '?';
22
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
23
+ return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase();
24
+ };
25
+
26
+ export const ShellMenuAvatar = ({
27
+ imageUrl,
28
+ displayName = 'Player',
29
+ open,
30
+ onOpenChange,
31
+ menuContent,
32
+ className,
33
+ testId = 'shell-menu-avatar',
34
+ }: ShellMenuAvatarProps): React.ReactElement => {
35
+ const menuId = React.useId();
36
+ const initials = initialsFromName(displayName);
37
+
38
+ return (
39
+ <div
40
+ className={cn(
41
+ 'fixed top-[max(0.75rem,env(safe-area-inset-top))] right-[max(0.75rem,env(safe-area-inset-right))] z-40',
42
+ className,
43
+ )}
44
+ data-testid={testId}
45
+ >
46
+ <button
47
+ type="button"
48
+ aria-label="Session menu"
49
+ aria-expanded={open}
50
+ aria-controls={menuId}
51
+ onClick={() => onOpenChange(!open)}
52
+ data-testid={`${testId}-trigger`}
53
+ className="flex size-11 items-center justify-center overflow-hidden rounded-full border border-foreground/15 bg-card/80 shadow-lg backdrop-blur-md transition-transform hover:scale-105"
54
+ >
55
+ {imageUrl ? (
56
+ // eslint-disable-next-line @next/next/no-img-element
57
+ <img src={imageUrl} alt="" className="size-full object-cover" />
58
+ ) : (
59
+ <span className="text-sm font-bold text-foreground">{initials}</span>
60
+ )}
61
+ </button>
62
+ {open ? (
63
+ <div
64
+ id={menuId}
65
+ role="menu"
66
+ className="absolute top-full right-0 z-50 mt-2 min-w-[12rem] rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-xl"
67
+ >
68
+ {menuContent}
69
+ </div>
70
+ ) : null}
71
+ </div>
72
+ );
73
+ };
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { playerLinkedMaxWidth } from './shell-session-frame.js';
4
+
5
+ describe('playerLinkedMaxWidth', () => {
6
+ it('returns 33% of viewport height', () => {
7
+ expect(playerLinkedMaxWidth(900)).toBe(297);
8
+ expect(playerLinkedMaxWidth(0)).toBe(0);
9
+ });
10
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `ShellSessionFrame` — layout wrapper for linked Host / Player sessions.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { HostDisplayViewport } from './host-display-viewport.js';
8
+ import { cn } from '../lib/cn.js';
9
+
10
+ export type ShellSessionFrameVariant = 'host' | 'player';
11
+
12
+ export type ShellSessionFrameProps = {
13
+ readonly variant: ShellSessionFrameVariant;
14
+ readonly chrome?: React.ReactNode;
15
+ readonly children: React.ReactNode;
16
+ /** Host only — contain scale/backdrop inside a preview card instead of the browser viewport. */
17
+ readonly embedded?: boolean;
18
+ /** Host only — fill a parent flex region (e.g. shell devtools body) instead of 100dvh. */
19
+ readonly fillParent?: boolean;
20
+ readonly className?: string;
21
+ readonly testId?: string;
22
+ };
23
+
24
+ export const playerLinkedMaxWidth = (viewportHeightPx: number): number =>
25
+ Math.max(0, viewportHeightPx * 0.33);
26
+
27
+ export const ShellSessionFrame = ({
28
+ variant,
29
+ chrome,
30
+ children,
31
+ embedded = false,
32
+ fillParent = false,
33
+ className,
34
+ testId,
35
+ }: ShellSessionFrameProps): React.ReactElement => {
36
+ if (variant === 'host') {
37
+ return (
38
+ <div
39
+ data-shell-mode="linked"
40
+ {...(embedded ? { 'data-shell-embedded': '' } : {})}
41
+ data-testid={testId ?? 'shell-session-frame-host'}
42
+ className={cn(
43
+ 'relative flex w-full min-h-0 flex-col overflow-hidden text-foreground',
44
+ embedded || fillParent ? 'h-full min-h-0 flex-1' : 'h-[100dvh]',
45
+ className,
46
+ )}
47
+ >
48
+ {chrome}
49
+ <HostDisplayViewport embedded={embedded}>{children}</HostDisplayViewport>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <div
56
+ data-shell-mode="linked"
57
+ data-testid={testId ?? 'shell-session-frame-player'}
58
+ className={cn(
59
+ 'flex h-[100dvh] w-full items-stretch justify-center overflow-hidden bg-background text-foreground',
60
+ className,
61
+ )}
62
+ >
63
+ <div
64
+ className={cn(
65
+ 'relative flex min-h-0 w-full max-w-[min(100vw,33dvh)] flex-col overflow-hidden',
66
+ 'pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]',
67
+ )}
68
+ >
69
+ {chrome}
70
+ <div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
71
+ {children}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ );
76
+ };
package/src/index.ts CHANGED
@@ -20,6 +20,17 @@
20
20
  * Visual review for the kit lives in `apps/uikit-preview`.
21
21
  */
22
22
 
23
+ export {
24
+ CameraCaptureModal,
25
+ type CameraCaptureModalProps,
26
+ } from './components/camera-capture-modal.js';
27
+
28
+ export {
29
+ PlayerAvatarPicker,
30
+ type PlayerAvatarPickerProps,
31
+ type PlayerAvatarPickerShape,
32
+ } from './components/player-avatar-picker.js';
33
+
23
34
  export {
24
35
  PromptInput,
25
36
  type PromptInputProps,
@@ -52,6 +63,65 @@ export {
52
63
  type ResponseCardProps,
53
64
  } from './components/response-card.js';
54
65
 
66
+ export {
67
+ ShellBrandMark,
68
+ type ShellBrandMarkProps,
69
+ } from './components/shell-brand-mark.js';
70
+
71
+ export {
72
+ ShellGlassToolbar,
73
+ ShellToolbarButton,
74
+ ShellToolbarMenu,
75
+ type ShellGlassToolbarProps,
76
+ type ShellToolbarButtonProps,
77
+ type ShellToolbarMenuProps,
78
+ } from './components/shell-glass-toolbar.js';
79
+
80
+ export {
81
+ ShellMenuAvatar,
82
+ type ShellMenuAvatarProps,
83
+ } from './components/shell-menu-avatar.js';
84
+
85
+ export {
86
+ HostDisplayViewport,
87
+ type HostDisplayViewportProps,
88
+ } from './components/host-display-viewport.js';
89
+
90
+ export {
91
+ ShellSessionFrame,
92
+ playerLinkedMaxWidth,
93
+ type ShellSessionFrameProps,
94
+ type ShellSessionFrameVariant,
95
+ } from './components/shell-session-frame.js';
96
+
97
+ export {
98
+ computeHostCanvasScale,
99
+ HOST_BACKDROP_CSS_VAR,
100
+ HOST_CANVAS_HEIGHT,
101
+ HOST_CANVAS_WIDTH,
102
+ } from './lib/host-canvas.js';
103
+
104
+ export {
105
+ buildHostIframeLoaderScript,
106
+ HOST_IFRAME_BRIDGE_KEY,
107
+ HOST_IFRAME_CLEANUP_KEY,
108
+ HOST_IFRAME_MOUNT_ID,
109
+ initHostIframeDocument,
110
+ resolveHostMountElement,
111
+ type HostIframeBridge,
112
+ } from './lib/host-mount.js';
113
+
114
+ export {
115
+ PlayerAvatar,
116
+ type PlayerAvatarProps,
117
+ } from './components/player-avatar.js';
118
+
119
+ export {
120
+ PlayerRoster,
121
+ type PlayerRosterEntry,
122
+ type PlayerRosterProps,
123
+ } from './components/player-roster.js';
124
+
55
125
  export {
56
126
  JoinCodeBadge,
57
127
  type JoinCodeBadgeProps,
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ computeHostCanvasScale,
5
+ HOST_CANVAS_HEIGHT,
6
+ HOST_CANVAS_WIDTH,
7
+ } from './host-canvas.js';
8
+
9
+ describe('HOST_CANVAS constants', () => {
10
+ it('uses 16:9 at 1080p logical resolution', () => {
11
+ expect(HOST_CANVAS_WIDTH).toBe(1920);
12
+ expect(HOST_CANVAS_HEIGHT).toBe(1080);
13
+ expect(HOST_CANVAS_WIDTH / HOST_CANVAS_HEIGHT).toBeCloseTo(16 / 9);
14
+ });
15
+ });
16
+
17
+ describe('computeHostCanvasScale', () => {
18
+ it('returns 1 when the available area matches the design canvas', () => {
19
+ expect(computeHostCanvasScale(1920, 1080)).toBe(1);
20
+ });
21
+
22
+ it('letterboxes by the limiting axis (width)', () => {
23
+ expect(computeHostCanvasScale(960, 1080)).toBe(0.5);
24
+ });
25
+
26
+ it('letterboxes by the limiting axis (height)', () => {
27
+ expect(computeHostCanvasScale(1920, 540)).toBe(0.5);
28
+ });
29
+
30
+ it('upscales on large displays without a cap', () => {
31
+ expect(computeHostCanvasScale(3840, 2160)).toBe(2);
32
+ expect(computeHostCanvasScale(7680, 4320)).toBe(4);
33
+ });
34
+
35
+ it('returns 0 for non-positive available dimensions', () => {
36
+ expect(computeHostCanvasScale(0, 1080)).toBe(0);
37
+ expect(computeHostCanvasScale(1920, 0)).toBe(0);
38
+ expect(computeHostCanvasScale(-100, 1080)).toBe(0);
39
+ });
40
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logical design canvas for Host (TV) surfaces.
3
+ *
4
+ * Experience bundles should lay out Host UI against these dimensions.
5
+ * The shell scales the canvas uniformly to fit the available viewport
6
+ * (after devtools chrome), without capping upscale on 4K / 8K displays.
7
+ */
8
+
9
+ /** Host design canvas width — 16:9 at 1080p. */
10
+ export const HOST_CANVAS_WIDTH = 1920;
11
+
12
+ /** Host design canvas height — 16:9 at 1080p. */
13
+ export const HOST_CANVAS_HEIGHT = 1080;
14
+
15
+ /** CSS custom property bundles set on the mount container for the viewport backdrop. */
16
+ export const HOST_BACKDROP_CSS_VAR = '--host-backdrop';
17
+
18
+ export const computeHostCanvasScale = (
19
+ availableWidth: number,
20
+ availableHeight: number,
21
+ ): number => {
22
+ if (availableWidth <= 0 || availableHeight <= 0) {
23
+ return 0;
24
+ }
25
+
26
+ return Math.min(
27
+ availableWidth / HOST_CANVAS_WIDTH,
28
+ availableHeight / HOST_CANVAS_HEIGHT,
29
+ );
30
+ };
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ buildHostIframeLoaderScript,
5
+ HOST_IFRAME_MOUNT_ID,
6
+ initHostIframeDocument,
7
+ } from './host-mount.js';
8
+
9
+ describe('initHostIframeDocument', () => {
10
+ it('creates a fixed-size mount node in the iframe document', () => {
11
+ const doc = document.implementation.createHTMLDocument('host');
12
+ const mount = initHostIframeDocument(doc);
13
+
14
+ expect(mount.id).toBe(HOST_IFRAME_MOUNT_ID);
15
+ expect(mount.getAttribute('data-host-mount')).not.toBeNull();
16
+ expect(doc.body.style.width).toBe('1920px');
17
+ expect(doc.body.style.height).toBe('1080px');
18
+ });
19
+ });
20
+
21
+ describe('buildHostIframeLoaderScript', () => {
22
+ it('embeds the bundle URL and mount id', () => {
23
+ const script = buildHostIframeLoaderScript(
24
+ 'https://example.test/host.js?dev=1',
25
+ );
26
+
27
+ expect(script).toContain('https://example.test/host.js?dev=1');
28
+ expect(script).toContain(HOST_IFRAME_MOUNT_ID);
29
+ expect(script).toContain('bridge.onReady?.()');
30
+ });
31
+ });
@@ -0,0 +1,68 @@
1
+ import { HOST_CANVAS_HEIGHT, HOST_CANVAS_WIDTH } from './host-canvas.js';
2
+
3
+ export const HOST_IFRAME_MOUNT_ID = 'wibly-host-mount';
4
+
5
+ export const HOST_IFRAME_BRIDGE_KEY = '__WIBLY_HOST_IFRAME_BRIDGE__';
6
+
7
+ export const HOST_IFRAME_CLEANUP_KEY = '__WIBLY_HOST_IFRAME_CLEANUP__';
8
+
9
+ export type HostIframeBridge = {
10
+ readonly getSession: () => unknown;
11
+ readonly onError: (error: Error) => void;
12
+ readonly onReady?: () => void;
13
+ };
14
+
15
+ export const initHostIframeDocument = (doc: Document): HTMLElement => {
16
+ doc.open();
17
+ doc.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=${HOST_CANVAS_WIDTH}, initial-scale=1"></head><body style="margin:0;overflow:hidden;width:${HOST_CANVAS_WIDTH}px;height:${HOST_CANVAS_HEIGHT}px"><div id="${HOST_IFRAME_MOUNT_ID}" data-host-mount style="width:100%;height:100%;overflow:hidden"></div></body></html>`);
18
+ doc.close();
19
+
20
+ const mount = doc.getElementById(HOST_IFRAME_MOUNT_ID);
21
+ if (!mount) {
22
+ throw new Error('Host iframe mount node was not created.');
23
+ }
24
+
25
+ return mount;
26
+ };
27
+
28
+ export const buildHostIframeLoaderScript = (bundleUrl: string): string => {
29
+ const urlLiteral = JSON.stringify(bundleUrl);
30
+ return `
31
+ (async () => {
32
+ const bridge = window.parent[${JSON.stringify(HOST_IFRAME_BRIDGE_KEY)}];
33
+ if (!bridge) {
34
+ throw new Error('Host iframe bridge is not available.');
35
+ }
36
+ try {
37
+ const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ ${urlLiteral});
38
+ if (typeof mod.mount !== 'function') {
39
+ throw new Error('Experience bundle does not export mount(session, container).');
40
+ }
41
+ const container = document.getElementById(${JSON.stringify(HOST_IFRAME_MOUNT_ID)});
42
+ if (!container) {
43
+ throw new Error('Host iframe mount node is missing.');
44
+ }
45
+ window.parent[${JSON.stringify(HOST_IFRAME_CLEANUP_KEY)}] = mod.mount(
46
+ bridge.getSession(),
47
+ container,
48
+ );
49
+ bridge.onReady?.();
50
+ } catch (cause) {
51
+ bridge.onError(cause instanceof Error ? cause : new Error(String(cause)));
52
+ }
53
+ })();
54
+ `.trim();
55
+ };
56
+
57
+ export const resolveHostMountElement = (canvas: HTMLElement): Element | null => {
58
+ const iframe = canvas.querySelector('iframe[data-host-mount]');
59
+ if (iframe instanceof HTMLIFrameElement) {
60
+ return (
61
+ iframe.contentDocument?.querySelector('[data-host-mount]') ??
62
+ iframe.contentDocument?.getElementById(HOST_IFRAME_MOUNT_ID) ??
63
+ null
64
+ );
65
+ }
66
+
67
+ return canvas.querySelector('[data-host-mount]');
68
+ };