// Phase 00 · Style Lock — host picks conversation style, guest co-signs. // Sets `shared.style = { voice, asymmetry, directness, signedA, signedB, signedAt }`. // Once both sign, style is immutable for the session. // Depends on: CG, Icons const STYLE_OPTIONS = { voice: [ { v: 'warm-informal', label: 'warm · informal', hint: 'wise friend · plain, lowercase-ish · "what\'s actually going on here?"' }, { v: 'warm-clinical', label: 'warm · clinical', hint: 'steady therapist · measured, named feelings · "it sounds like there\'s a pattern."' }, { v: 'dry-neutral', label: 'dry · neutral', hint: 'court reporter with opinions · short, no flourish · "the asymmetry is on record."' }, ], asymmetry: [ { v: 'truth-seeking', label: 'truth-seeking', hint: 'if one party is more responsible, the mediator will name it. no forced balance.' }, { v: 'symmetric', label: 'symmetric', hint: 'both sides treated equally in tone & framing. fault is not weighed.' }, { v: 'hybrid', label: 'hybrid', hint: 'asymmetry may be named — but only when both of you would co-sign that observation.' }, ], directness: [ { v: 'soften', label: 'soften the edges', hint: 'hard truths only after gentle setup. the coach will never bluntly contradict you.' }, { v: 'plain', label: 'plain', hint: 'the coach says what it sees without padding. no escalation either.' }, { v: 'challenge', label: 'challenge me', hint: 'the coach will push back on characterizations and kitchen-sinking directly.' }, ], }; const STYLE_DEFAULTS = { voice: 'warm-informal', asymmetry: 'hybrid', directness: 'plain' }; function StyleLockPhase({ seat, onPhase }) { const [, forceTick] = React.useState(0); React.useEffect(() => CG.session.onState(() => forceTick(t => t + 1)), []); React.useEffect(() => CG.onStatus(() => forceTick(t => t + 1)), []); const shared = CG.session.shared || {}; const names = (CG.session.getNames && CG.session.getNames()) || {}; const mp = CG.session.isMultiplayer && CG.session.isMultiplayer(); const isHost = !mp || seat === 'A'; const isGuest = mp && seat === 'B'; const nameA = names.A || (mp ? 'host' : 'host'); const nameB = names.B || (mp ? 'guest' : 'guest'); // Host-side draft (pre-sign). Seeded from shared.style if partially set, else defaults. const [draft, setDraft] = React.useState(() => { if (shared.style) return { voice: shared.style.voice, asymmetry: shared.style.asymmetry, directness: shared.style.directness }; return { ...STYLE_DEFAULTS }; }); // The locked/signed style lives in shared.style. A null style means nothing proposed yet. const proposed = shared.style; // { voice, asymmetry, directness, signedA, signedB, ... } | null const hostSigned = !!(proposed && proposed.signedA); const guestSigned = !!(proposed && proposed.signedB); const bothSigned = hostSigned && guestSigned; // Host · propose & sign (in solo, also auto-co-sign so user can proceed) const hostPropose = () => { const style = { voice: draft.voice, asymmetry: draft.asymmetry, directness: draft.directness, signedA: true, signedB: !mp, // solo: auto-co-sign as "both parties" signedAt: new Date().toISOString(), }; CG.session.setShared({ style }); }; // Guest · co-sign what host proposed const guestCosign = () => { if (!proposed) return; // Guest sends a patch up to host; host-authoritative setShared also runs via data channel. CG.session.setShared({ style: { ...proposed, signedB: true, signedAt: proposed.signedAt }, }); }; // Guest · request a change (wipes host signature so host can re-propose) const guestRequestChange = () => { if (!proposed) return; CG.session.setShared({ style: null }); }; const continueToTopic = () => { onPhase && onPhase('topic'); }; // Presence: broadcast what this user is doing within the style phase React.useEffect(() => { let activity = 'drafting style'; if (bothSigned) activity = 'reviewing · locked'; else if (isHost && hostSigned && !guestSigned) activity = `waiting on ${nameB}`; else if (isGuest && hostSigned && !guestSigned) activity = 'reviewing proposal'; else if (isGuest && !hostSigned) activity = `waiting on ${nameA}`; else if (isHost && !hostSigned) activity = 'drafting style'; try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {} }, [bothSigned, hostSigned, guestSigned, isHost, isGuest, nameA, nameB]); // ---- VIEWS ---- if (bothSigned) { return ( ); } // Solo (no peer yet) or host seat · host proposes if (isHost && !hostSigned) { return ( ); } // Host has signed, waiting on guest (but we're host) if (isHost && hostSigned && !guestSigned) { return ( ); } // Guest view · waiting for host to propose if (isGuest && !proposed) { return ; } // Guest view · host has proposed, guest decides if (isGuest && proposed && !guestSigned) { return ( ); } // Fallback return
loading…
; } // ---------- HOST · PROPOSE ---------- function HostProposeView({ draft, setDraft, onSign, mp, nameA, nameB }) { const canSign = draft.voice && draft.asymmetry && draft.directness; return (
phase 00 · pre-room · style

set the tone before you start.

three choices shape how the agents in this session talk. pick them now, together, once.{' '} once co-signed, the style is locked for the whole session — no quiet mid-argument edits.

setDraft({ ...draft, voice: v })} /> setDraft({ ...draft, asymmetry: v })} /> setDraft({ ...draft, directness: v })} />
what this produces
{mp ? <>{nameB} will see your proposal and can co-sign or request a change. : <>solo mode · you're co-signing on both parties' behalf.}
); } // ---------- HOST · WAITING ---------- function HostWaitingView({ style, nameA, nameB, onWithdraw }) { return (
phase 00 · waiting on {nameB}

signed · waiting for {nameB} to co-sign.

they see your proposal below. if they request a change, you can re-propose.

signed · {nameA} waiting on {nameB}…
); } // ---------- GUEST · WAITING ---------- function GuestWaitingView({ nameA }) { return (
phase 00 · waiting on {nameA}

{nameA} is setting the tone.

before the room opens, the host picks three knobs — voice, asymmetry policy, directness — and proposes them to you. you co-sign, or request a change. neither of you can edit mid-session.

status
waiting for proposal…
); } // ---------- GUEST · CO-SIGN ---------- function GuestCosignView({ style, nameA, nameB, onCosign, onRequestChange }) { return (
phase 00 · pre-room · co-sign

{nameA} proposes this style.

read what this will actually do to the agents (below), and either co-sign as {nameB} or send it back for a rework.

what the agents will be told
); } // ---------- LOCKED ---------- function StyleLockedCard({ style, nameA, nameB, onContinue }) { return (
style locked
signed · {nameA} signed · {nameB}
the agents now speak in your agreed voice. on to the topic.
); } // ---------- atoms ---------- function StyleKnobGroup({ title, k, value, onChange }) { const opts = STYLE_OPTIONS[k] || []; return (
{title}
{opts.map(o => { const active = value === o.v; return (
onChange(o.v)} style={{ cursor: 'pointer', padding: 12, border: active ? '1.5px solid var(--ink)' : '1px dashed var(--rule)', background: active ? 'var(--paper-2)' : 'transparent', transition: 'all 0.15s', position: 'relative', }} >
{o.label}
{active && }
{o.hint}
); })}
); } function StyleSummaryCard({ style, highlight, center }) { const voice = STYLE_OPTIONS.voice.find(o => o.v === style.voice); const asym = STYLE_OPTIONS.asymmetry.find(o => o.v === style.asymmetry); const direct = STYLE_OPTIONS.directness.find(o => o.v === style.directness); const rows = [ { k: 'voice', v: voice }, { k: 'asymmetry', v: asym }, { k: 'directness', v: direct }, ]; return (
{rows.map((r, i) => (
{r.k}
{r.v ? r.v.label : '—'} {r.v && — {r.v.hint} }
))}
); } function StylePreamblePreview({ draft }) { const text = CG.buildStylePreamble ? CG.buildStylePreamble({ ...draft, signedA: true, signedB: true }) : ''; return (
      {text.trim() || '(pick all three to preview)'}
    
); } Object.assign(window, { StyleLockPhase }); console.log('[phases-style] loaded');