@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,104 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `Timer` — counts down to a server-anchored deadline.
5
+ *
6
+ * Per Dev Spec §6.6 chunk-B10 build: "Timer — counts down to a
7
+ * server-anchored deadline; uses `time.serverNow()`." The component
8
+ * does NOT import `@platform/sdk` (Dev Spec §3.3 dependency
9
+ * direction). The consumer passes a `nowMs` thunk — typically a
10
+ * pre-bound `() => session.time.serverNow()` — which the component
11
+ * polls at a configurable interval.
12
+ *
13
+ * The component is the rendering surface, never the source of truth:
14
+ *
15
+ * - It stores no countdown state outside `useState`'s React-internal
16
+ * bookkeeping (the local interval is presentation only — the
17
+ * deadline is the server's, the clock skew is the SDK's).
18
+ * - It clears its interval on unmount and on `deadlineMs` change.
19
+ * - It calls `onExpire` exactly once per (deadline, mount) pair.
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ import { cn } from '../lib/cn.js';
25
+ import { formatRemaining, remainingMs, type DurationFormat } from '../lib/duration.js';
26
+
27
+ export type TimerProps = {
28
+ /** The server-anchored deadline in unix ms. */
29
+ readonly deadlineMs: number;
30
+ /**
31
+ * Returns server time in unix ms. Typically wired from
32
+ * `() => session.time.serverNow()` (B7 SDK).
33
+ */
34
+ readonly nowMs: () => number;
35
+ /** Fires once when remaining time crosses 0. */
36
+ readonly onExpire?: () => void;
37
+ /** Tick interval in ms; default 250. */
38
+ readonly tickMs?: number;
39
+ /** Render format; default `'mm:ss'`. */
40
+ readonly format?: DurationFormat;
41
+ /** Optional accessible label (e.g. "round timer"). */
42
+ readonly label?: string;
43
+ readonly className?: string;
44
+ };
45
+
46
+ export const Timer = ({
47
+ deadlineMs,
48
+ nowMs,
49
+ onExpire,
50
+ tickMs = 250,
51
+ format = 'mm:ss',
52
+ label,
53
+ className,
54
+ }: TimerProps): React.ReactElement => {
55
+ const [remaining, setRemaining] = React.useState(() =>
56
+ remainingMs(deadlineMs, nowMs()),
57
+ );
58
+
59
+ // `nowMs` and `onExpire` are kept in refs so the interval doesn't
60
+ // resubscribe on every render. The interval rebinds only when
61
+ // `deadlineMs` or `tickMs` change.
62
+ const nowMsRef = React.useRef(nowMs);
63
+ nowMsRef.current = nowMs;
64
+ const onExpireRef = React.useRef(onExpire);
65
+ onExpireRef.current = onExpire;
66
+ const expiredRef = React.useRef(false);
67
+
68
+ React.useEffect(() => {
69
+ expiredRef.current = false;
70
+ setRemaining(remainingMs(deadlineMs, nowMsRef.current()));
71
+
72
+ const interval = setInterval(() => {
73
+ const next = remainingMs(deadlineMs, nowMsRef.current());
74
+ setRemaining(next);
75
+ if (next <= 0 && !expiredRef.current) {
76
+ expiredRef.current = true;
77
+ onExpireRef.current?.();
78
+ }
79
+ }, Math.max(50, tickMs));
80
+
81
+ return () => clearInterval(interval);
82
+ }, [deadlineMs, tickMs]);
83
+
84
+ const expired = remaining <= 0;
85
+
86
+ return (
87
+ <div
88
+ role="timer"
89
+ aria-label={label}
90
+ aria-live="polite"
91
+ data-expired={expired || undefined}
92
+ className={cn(
93
+ 'inline-flex items-baseline gap-2 rounded-lg border border-border bg-card px-4 py-2 font-mono tabular-nums text-foreground shadow-sm',
94
+ expired && 'border-destructive text-destructive',
95
+ !expired && remaining < 5_000 && 'text-primary',
96
+ className,
97
+ )}
98
+ >
99
+ <span className="text-2xl font-semibold">
100
+ {formatRemaining(remaining, format)}
101
+ </span>
102
+ </div>
103
+ );
104
+ };
@@ -0,0 +1,116 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `VoteGrid` — vote on a set of options with images or text.
5
+ *
6
+ * Per Dev Spec §6.6 chunk-B10 build. The grid is keyboard-navigable
7
+ * (arrow keys move focus, Enter / Space selects), respects the
8
+ * server-driven `disabledOptionIds` (e.g. for "voted-already"
9
+ * surfaces), and presents the selected option visibly via a
10
+ * highlighted ring + `aria-pressed`.
11
+ *
12
+ * Decoupled from the SDK: the consumer passes the option list +
13
+ * the current selection + an `onSelect` callback. Predictive vote
14
+ * UI is the consumer's job — toggling `disabled` while the optimistic
15
+ * vote is in flight is enough to express "in progress."
16
+ */
17
+
18
+ import * as React from 'react';
19
+
20
+ import { cn } from '../lib/cn.js';
21
+
22
+ export type VoteOption = {
23
+ readonly id: string;
24
+ readonly label: string;
25
+ /** Optional image URL — when set, renders the image above the label. */
26
+ readonly imageUrl?: string;
27
+ /** Optional secondary copy (sub-label / attribution). */
28
+ readonly hint?: string;
29
+ };
30
+
31
+ export type VoteGridProps = {
32
+ readonly options: readonly VoteOption[];
33
+ readonly selectedOptionId: string | null;
34
+ readonly onSelect: (optionId: string) => void;
35
+ readonly disabled?: boolean;
36
+ readonly disabledOptionIds?: readonly string[];
37
+ readonly columns?: 2 | 3 | 4;
38
+ readonly className?: string;
39
+ readonly label?: string;
40
+ };
41
+
42
+ const COLUMN_CLASS: Record<2 | 3 | 4, string> = {
43
+ 2: 'grid-cols-1 sm:grid-cols-2',
44
+ 3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
45
+ 4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
46
+ };
47
+
48
+ export const VoteGrid = ({
49
+ options,
50
+ selectedOptionId,
51
+ onSelect,
52
+ disabled = false,
53
+ disabledOptionIds,
54
+ columns = 3,
55
+ className,
56
+ label,
57
+ }: VoteGridProps): React.ReactElement => {
58
+ const disabledSet = React.useMemo(
59
+ () => new Set(disabledOptionIds ?? []),
60
+ [disabledOptionIds],
61
+ );
62
+
63
+ return (
64
+ <div
65
+ role="radiogroup"
66
+ aria-label={label}
67
+ aria-disabled={disabled || undefined}
68
+ className={cn('grid gap-3', COLUMN_CLASS[columns], className)}
69
+ >
70
+ {options.map((option) => {
71
+ const isSelected = option.id === selectedOptionId;
72
+ const isDisabled = disabled || disabledSet.has(option.id);
73
+ return (
74
+ <button
75
+ key={option.id}
76
+ type="button"
77
+ role="radio"
78
+ aria-checked={isSelected}
79
+ aria-disabled={isDisabled || undefined}
80
+ disabled={isDisabled}
81
+ tabIndex={isSelected ? 0 : isDisabled ? -1 : 0}
82
+ onClick={() => {
83
+ if (!isDisabled) onSelect(option.id);
84
+ }}
85
+ data-selected={isSelected || undefined}
86
+ className={cn(
87
+ 'flex flex-col items-stretch gap-2 overflow-hidden rounded-xl border border-border bg-card text-left',
88
+ 'p-3 shadow-sm transition-colors duration-150',
89
+ 'focus:outline-none focus:ring-2 focus:ring-ring',
90
+ isSelected && 'border-primary ring-2 ring-primary',
91
+ !isSelected && !isDisabled && 'hover:border-primary/60',
92
+ isDisabled && 'cursor-not-allowed opacity-50',
93
+ )}
94
+ >
95
+ {option.imageUrl !== undefined ? (
96
+ <img
97
+ src={option.imageUrl}
98
+ alt=""
99
+ aria-hidden="true"
100
+ className="aspect-video w-full rounded-sm object-cover"
101
+ />
102
+ ) : null}
103
+ <span className="text-sm font-medium text-foreground">
104
+ {option.label}
105
+ </span>
106
+ {option.hint !== undefined ? (
107
+ <span className="text-xs text-muted-foreground">
108
+ {option.hint}
109
+ </span>
110
+ ) : null}
111
+ </button>
112
+ );
113
+ })}
114
+ </div>
115
+ );
116
+ };
package/src/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `@platform/ui-kit` — shared React components for the Wibly shells
3
+ * and Experience bundles.
4
+ *
5
+ * Built in chunk B10 (Dev Spec §6.6). Per Platform Spec §3.4 the kit
6
+ * is "assistance, not constraint" — first-party Experiences (Rashomon,
7
+ * Flatterer) and the platform shells use parts of it; ignoring it
8
+ * entirely is allowed.
9
+ *
10
+ * Dependency direction (Dev Spec §3.3):
11
+ *
12
+ * `packages/sdk → packages/ui-kit`
13
+ * `packages/ui-kit → packages/shared` (and `clsx` for class merging)
14
+ *
15
+ * The kit MUST NOT import `@platform/sdk`, `@platform/protocol`, or
16
+ * `@platform/manifest`. SDK integrations (`time.serverNow`,
17
+ * `submitWithPrediction`) are surfaced as plain callback / function
18
+ * props — see `Timer.nowMs` and `PromptInput.onSubmit` / `pending`.
19
+ *
20
+ * Visual review for the kit lives in `apps/uikit-preview`.
21
+ */
22
+
23
+ export {
24
+ PromptInput,
25
+ type PromptInputProps,
26
+ } from './components/prompt-input.js';
27
+
28
+ export {
29
+ VoteGrid,
30
+ type VoteGridProps,
31
+ type VoteOption,
32
+ } from './components/vote-grid.js';
33
+
34
+ export {
35
+ Leaderboard,
36
+ type LeaderboardDimension,
37
+ type LeaderboardProps,
38
+ type LeaderboardRow,
39
+ } from './components/leaderboard.js';
40
+
41
+ export { Timer, type TimerProps } from './components/timer.js';
42
+
43
+ export {
44
+ AvatarStage,
45
+ type AvatarStageProps,
46
+ type AvatarStageState,
47
+ } from './components/avatar-stage.js';
48
+
49
+ export {
50
+ ResponseCard,
51
+ type ResponseCardAttribution,
52
+ type ResponseCardProps,
53
+ } from './components/response-card.js';
54
+
55
+ export {
56
+ JoinCodeBadge,
57
+ type JoinCodeBadgeProps,
58
+ } from './components/join-code-badge.js';
59
+
60
+ export {
61
+ RecoveryCodeBadge,
62
+ type RecoveryCodeBadgeProps,
63
+ } from './components/recovery-code-badge.js';
64
+
65
+ export {
66
+ PausedBanner,
67
+ type PausedBannerProps,
68
+ type PausedBannerReason,
69
+ } from './components/paused-banner.js';
70
+
71
+ export {
72
+ SessionAbortedOverlay,
73
+ type SessionAbortedOverlayProps,
74
+ } from './components/session-aborted-overlay.js';
75
+
76
+ export {
77
+ ConsentDialog,
78
+ resolveConsentDialogCopy,
79
+ type ConsentDialogKind,
80
+ type ConsentDialogProps,
81
+ } from './components/consent-dialog.js';
82
+
83
+ export {
84
+ ConsentPrompt,
85
+ type ConsentPromptCopyOverride,
86
+ type ConsentPromptProps,
87
+ } from './components/consent-prompt.js';
88
+
89
+ export {
90
+ CONSENT_DIALOG_DEFAULTS,
91
+ CONSENT_PROMPT_COPY,
92
+ } from './lib/consent-meta.js';
93
+
94
+ export {
95
+ consentFlowReducer,
96
+ initialConsentFlowState,
97
+ isAccepted,
98
+ type ConsentFlowEvent,
99
+ type ConsentFlowState,
100
+ type ConsentDecision,
101
+ type ConsentRequiredCallback,
102
+ type ConsentRequiredPayload,
103
+ } from './lib/consent-flow.js';
104
+
105
+ // Pure helpers — exposed so consumers can render the same counter /
106
+ // counter format the components use (e.g. inside a custom shell-only
107
+ // surface) without re-implementing the grapheme math.
108
+ export { graphemeCount, truncateToGraphemes } from './lib/char-count.js';
109
+ export {
110
+ formatRemaining,
111
+ remainingMs,
112
+ type DurationFormat,
113
+ } from './lib/duration.js';
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { graphemeCount, truncateToGraphemes } from './char-count.js';
4
+
5
+ describe('graphemeCount', () => {
6
+ it('counts ASCII characters one-for-one', () => {
7
+ expect(graphemeCount('')).toBe(0);
8
+ expect(graphemeCount('a')).toBe(1);
9
+ expect(graphemeCount('hello world')).toBe(11);
10
+ });
11
+
12
+ it('counts emoji as one cluster (no surrogate-pair double-count)', () => {
13
+ expect(graphemeCount('😀')).toBe(1);
14
+ expect(graphemeCount('a😀b')).toBe(3);
15
+ });
16
+
17
+ it('counts ZWJ sequences as one cluster (family emoji)', () => {
18
+ // U+1F468 + ZWJ + U+1F469 + ZWJ + U+1F467 = a single visible
19
+ // family glyph; native `.length` is 8.
20
+ const family = '👨\u200D👩\u200D👧';
21
+ expect(family.length).toBeGreaterThan(1);
22
+ expect(graphemeCount(family)).toBe(1);
23
+ });
24
+
25
+ it('counts CJK characters one-for-one', () => {
26
+ expect(graphemeCount('한국어')).toBe(3);
27
+ expect(graphemeCount('日本語')).toBe(3);
28
+ expect(graphemeCount('中文')).toBe(2);
29
+ });
30
+
31
+ it('counts a combined-form character as one grapheme', () => {
32
+ // 'é' authored as `e + COMBINING ACUTE ACCENT` (U+0065 U+0301)
33
+ // — `.length` is 2; the user perceives it as a single `é`.
34
+ const combined = 'e\u0301';
35
+ expect(combined.length).toBe(2);
36
+ expect(graphemeCount(combined)).toBe(1);
37
+ });
38
+ });
39
+
40
+ describe('truncateToGraphemes', () => {
41
+ it('returns the input untouched when within the cap', () => {
42
+ expect(truncateToGraphemes('', 10)).toBe('');
43
+ expect(truncateToGraphemes('hello', 10)).toBe('hello');
44
+ expect(truncateToGraphemes('hello', 5)).toBe('hello');
45
+ });
46
+
47
+ it('truncates ASCII to the cap length', () => {
48
+ expect(truncateToGraphemes('hello world', 5)).toBe('hello');
49
+ expect(truncateToGraphemes('hello world', 0)).toBe('');
50
+ });
51
+
52
+ it('does not split a surrogate pair when truncating just before an emoji', () => {
53
+ // Cap of 2 against `'a😀b'` should yield `'a😀'`, not `'a'` and
54
+ // not the broken half of the emoji.
55
+ expect(truncateToGraphemes('a😀b', 2)).toBe('a😀');
56
+ });
57
+
58
+ it('preserves ZWJ family clusters as one unit when within the cap', () => {
59
+ const family = '👨\u200D👩\u200D👧';
60
+ expect(truncateToGraphemes(family, 1)).toBe(family);
61
+ expect(truncateToGraphemes(`x${family}y`, 2)).toBe(`x${family}`);
62
+ });
63
+
64
+ it('truncates CJK input to the requested grapheme count', () => {
65
+ expect(truncateToGraphemes('한국어 화이팅', 3)).toBe('한국어');
66
+ });
67
+
68
+ it('treats a non-positive cap as the empty string', () => {
69
+ expect(truncateToGraphemes('hello', 0)).toBe('');
70
+ expect(truncateToGraphemes('hello', -3)).toBe('');
71
+ });
72
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Grapheme-aware character helpers 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)." The native `string.length` counts UTF-16 code
7
+ * units, so an emoji or a surrogate-pair character is reported as
8
+ * `2`. That mismatch is a footgun — a 280-character cap that uses
9
+ * `.length` lets a player type 140 emoji and call it full while a
10
+ * latin-only player gets 280. We use `Intl.Segmenter` (built into
11
+ * V8 since Node 16) to count user-perceived characters instead.
12
+ *
13
+ * `Intl.Segmenter` is not in `lib.es2022.d.ts`'s typings yet; the
14
+ * helpers cast through `unknown` rather than pulling in the
15
+ * `intl-segmenter` types polyfill. The cast lives in one place so
16
+ * the type imprecision doesn't leak to call sites.
17
+ */
18
+
19
+ type SegmentLike = { readonly segment: string };
20
+ type SegmenterLike = {
21
+ segment: (input: string) => Iterable<SegmentLike>;
22
+ };
23
+ type SegmenterCtor = new (locale?: string, opts?: { granularity: 'grapheme' }) => SegmenterLike;
24
+
25
+ const SegmenterCtorRef: SegmenterCtor | null = (() => {
26
+ const candidate = (globalThis as unknown as { Intl?: { Segmenter?: SegmenterCtor } }).Intl
27
+ ?.Segmenter;
28
+ return candidate ?? null;
29
+ })();
30
+
31
+ const segmenter: SegmenterLike | null = SegmenterCtorRef
32
+ ? new SegmenterCtorRef(undefined, { granularity: 'grapheme' })
33
+ : null;
34
+
35
+ /**
36
+ * Count user-perceived characters (grapheme clusters) in `input`.
37
+ *
38
+ * - `'hello'` → 5
39
+ * - `'😀'` → 1 (surrogate pair, not 2)
40
+ * - `'👨‍👩‍👧'` → 1 (ZWJ family sequence, not 5)
41
+ * - `'한국어'` → 3
42
+ *
43
+ * Falls back to `string.length` only on runtimes without
44
+ * `Intl.Segmenter`. All supported runtimes (Node 20 LTS, modern
45
+ * browsers per Dev Spec §2.10) ship it.
46
+ */
47
+ export const graphemeCount = (input: string): number => {
48
+ if (input === '') return 0;
49
+ if (segmenter === null) return input.length;
50
+ let count = 0;
51
+ for (const _segment of segmenter.segment(input)) {
52
+ void _segment;
53
+ count += 1;
54
+ }
55
+ return count;
56
+ };
57
+
58
+ /**
59
+ * Truncate `input` to at most `maxChars` grapheme clusters,
60
+ * preserving the leading slice. Returns `input` unchanged when it
61
+ * is already within the cap (no allocation).
62
+ *
63
+ * Used by `PromptInput`'s `onChange` interceptor — when a paste
64
+ * pushes the value over the cap, the cap value is what reaches
65
+ * `props.onChange`. (The component does not silently drop the tail;
66
+ * it surfaces the truncation via the visible counter.)
67
+ */
68
+ export const truncateToGraphemes = (input: string, maxChars: number): string => {
69
+ if (maxChars <= 0) return '';
70
+ if (input === '') return '';
71
+ if (segmenter === null) {
72
+ return input.length <= maxChars ? input : input.slice(0, maxChars);
73
+ }
74
+ let count = 0;
75
+ let truncated = '';
76
+ for (const segment of segmenter.segment(input)) {
77
+ if (count >= maxChars) break;
78
+ truncated += segment.segment;
79
+ count += 1;
80
+ }
81
+ return truncated;
82
+ };
package/src/lib/cn.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Class-name composer (`clsx` re-export wrapped at the package boundary).
3
+ *
4
+ * Components in `@platform/ui-kit` use `cn(...)` to merge static
5
+ * Tailwind classes with conditional ones without leaking `clsx` as
6
+ * a transitive dep into consumers' import sites. The wrapper makes
7
+ * the dependency swappable later (e.g. `tailwind-merge` if dedup
8
+ * becomes useful) without touching every component.
9
+ */
10
+
11
+ import { clsx, type ClassValue } from 'clsx';
12
+
13
+ export const cn = (...inputs: ClassValue[]): string => clsx(...inputs);
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ type ConsentDecision,
5
+ type ConsentRequiredCallback,
6
+ type ConsentRequiredPayload,
7
+ consentFlowReducer,
8
+ initialConsentFlowState,
9
+ isAccepted,
10
+ } from './consent-flow.js';
11
+
12
+ const samplePayload = (
13
+ overrides: Partial<ConsentRequiredPayload> = {},
14
+ ): ConsentRequiredPayload => ({
15
+ personaId: 'psn_test',
16
+ personaDisplayName: 'Crumb',
17
+ scope: 'player',
18
+ ...overrides,
19
+ });
20
+
21
+ describe('consentFlowReducer', () => {
22
+ it('accept branch resolves to accepted', () => {
23
+ const state0 = initialConsentFlowState(samplePayload());
24
+ const state1 = consentFlowReducer(state0, { type: 'accept' });
25
+ const state2 = consentFlowReducer(state1, { type: 'confirmed' });
26
+ expect(isAccepted(state2)).toBe(true);
27
+ });
28
+
29
+ it('decline branch resolves to declined', () => {
30
+ const state0 = initialConsentFlowState(samplePayload());
31
+ const state1 = consentFlowReducer(state0, { type: 'decline' });
32
+ const state2 = consentFlowReducer(state1, { type: 'confirmed' });
33
+ expect(isAccepted(state2)).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe('SDK callback signature', () => {
38
+ it('matches the documented shape', async () => {
39
+ const callback: ConsentRequiredCallback = async () => 'accepted';
40
+ const decision = await callback(samplePayload());
41
+ expect(decision).toBe('accepted');
42
+ const decisions: readonly ConsentDecision[] = ['accepted', 'declined'];
43
+ expect(decisions).toHaveLength(2);
44
+ });
45
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure state machine for the Persona consent prompt (B6/B15).
3
+ */
4
+
5
+ import type {
6
+ ConsentDecision,
7
+ ConsentRequiredCallback,
8
+ ConsentRequiredPayload,
9
+ } from '@wibly/sdk';
10
+
11
+ export type { ConsentDecision, ConsentRequiredCallback, ConsentRequiredPayload };
12
+
13
+ export type ConsentFlowState =
14
+ | { readonly kind: 'prompting'; readonly payload: ConsentRequiredPayload }
15
+ | {
16
+ readonly kind: 'submitting';
17
+ readonly payload: ConsentRequiredPayload;
18
+ readonly decision: ConsentDecision;
19
+ }
20
+ | {
21
+ readonly kind: 'resolved';
22
+ readonly payload: ConsentRequiredPayload;
23
+ readonly decision: ConsentDecision;
24
+ };
25
+
26
+ export type ConsentFlowEvent =
27
+ | { readonly type: 'accept' }
28
+ | { readonly type: 'decline' }
29
+ | { readonly type: 'confirmed' };
30
+
31
+ export const initialConsentFlowState = (
32
+ payload: ConsentRequiredPayload,
33
+ ): ConsentFlowState => ({ kind: 'prompting', payload });
34
+
35
+ export const consentFlowReducer = (
36
+ state: ConsentFlowState,
37
+ event: ConsentFlowEvent,
38
+ ): ConsentFlowState => {
39
+ switch (event.type) {
40
+ case 'accept': {
41
+ if (state.kind === 'prompting') {
42
+ return {
43
+ kind: 'submitting',
44
+ payload: state.payload,
45
+ decision: 'accepted',
46
+ };
47
+ }
48
+ return state;
49
+ }
50
+ case 'decline': {
51
+ if (state.kind === 'prompting') {
52
+ return {
53
+ kind: 'submitting',
54
+ payload: state.payload,
55
+ decision: 'declined',
56
+ };
57
+ }
58
+ return state;
59
+ }
60
+ case 'confirmed': {
61
+ if (state.kind === 'submitting') {
62
+ return {
63
+ kind: 'resolved',
64
+ payload: state.payload,
65
+ decision: state.decision,
66
+ };
67
+ }
68
+ return state;
69
+ }
70
+ default: {
71
+ const _exhaustive: never = event;
72
+ void _exhaustive;
73
+ return state;
74
+ }
75
+ }
76
+ };
77
+
78
+ export const isAccepted = (state: ConsentFlowState): boolean =>
79
+ state.kind !== 'prompting' && state.decision === 'accepted';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Platform-default consent prompt copy (chunk B15).
3
+ *
4
+ * Surface-specific framing lives in `CONSENT_DIALOG_DEFAULTS`; per-
5
+ * Persona overrides arrive on the wire as `payload.consentCopy`.
6
+ */
7
+
8
+ export const CONSENT_PROMPT_COPY = {
9
+ title: (personaDisplayName: string) =>
10
+ `${personaDisplayName} wants to remember you across sessions.`,
11
+ description:
12
+ 'Granting consent lets this Persona keep notes about how you played — your style, the choices you’ve made, jokes that landed. You can revoke this any time from Settings.',
13
+ acceptLabel: 'Allow',
14
+ declineLabel: 'Not this time',
15
+ managementHint:
16
+ 'You can revoke this any time from Settings — “Memory & Personas”.',
17
+ } as const;
18
+
19
+ export type ConsentDialogKind = 'mid-session' | 'settings';
20
+
21
+ export const CONSENT_DIALOG_DEFAULTS: Record<
22
+ ConsentDialogKind,
23
+ {
24
+ title: (personaDisplayName: string) => string;
25
+ description: string;
26
+ }
27
+ > = {
28
+ 'mid-session': {
29
+ title: CONSENT_PROMPT_COPY.title,
30
+ description: CONSENT_PROMPT_COPY.description,
31
+ },
32
+ settings: {
33
+ title: (personaDisplayName: string) =>
34
+ `Grant ${personaDisplayName} permission to remember you.`,
35
+ description:
36
+ 'You’re granting this Persona consent to keep notes about how you play across sessions. Revoke any time from this page.',
37
+ },
38
+ };