// Phase 07 · LIVE apology on-ramp. // // SYMMETRIC: either party can offer a repair. Either can skip. Neither is required. // // Layout (single column, two stacked cards): // TOP · your offer to them — private drafter + coach flags + send // BOTTOM · their offer to you — shows status (nothing yet / drafting / received / skipped), // plus receiver controls once received: accept · ask for more · decline // // Coach runs ONLY when you click 'coach · review'. No auto-fire. // Only a finished, confirmed offer crosses the firewall. Drafts-in-progress never leak. (function () { const { useState, useEffect, useRef } = React; window.LiveApologyPhase = function LiveApologyPhase({ seat, onPhase }) { const [, tick] = useState(0); useEffect(() => CG.session.onState(() => tick(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 otherSeat = seat === 'A' ? 'B' : 'A'; const myName = names[seat] || (seat === 'A' ? 'host' : 'guest'); const otherName = names[otherSeat] || (otherSeat === 'A' ? 'host' : 'guest'); // Guard: require at least one established truth from phase 05. const truths = (shared.truths || []).filter(t => t.confirmedA && t.confirmedB); if (truths.length === 0) { return (
phase 07 · apology on-ramp

not ready.

establish at least one truth together before offering repair.{' '} onPhase && onPhase('room')} style={{ color: 'var(--blue)', cursor: 'pointer' }}>← back to the room

); } const ap = shared.apology || { A: {}, B: {}, continueA: false, continueB: false }; const mine = ap[seat] || {}; const theirs = ap[otherSeat] || {}; const myContinue = seat === 'A' ? ap.continueA : ap.continueB; const theirContinue = seat === 'A' ? ap.continueB : ap.continueA; // persistent private draft const [draft, setDraft] = useState(''); useEffect(() => { (async () => { const s = await CG.store.get(`apology_draft_${seat}`); if (s && typeof s === 'string') setDraft(s); })(); }, [seat]); useEffect(() => { CG.store.set(`apology_draft_${seat}`, draft); }, [draft, seat]); const [review, setReview] = useState(null); const [reviewing, setReviewing] = useState(false); const [reviewError, setReviewError] = useState(''); const [confirmOpen, setConfirmOpen] = useState(false); const setMine = (partial) => { const payload = { apology: { [seat]: partial } }; if (isHost) { const curAp = shared.apology || { A: {}, B: {}, continueA: false, continueB: false }; CG.session.setShared({ apology: { ...curAp, [seat]: { ...(curAp[seat] || {}), ...partial } } }); } else { CG.peer.send('state:propose', payload); } }; const runReview = async () => { const t = draft.trim(); if (!t || reviewing) return; setReviewing(true); setReviewError(''); setReview(null); try { const r = await CG.agents.apologyCoach.review(seat, t); setReview(r); if (!mine.status) setMine({ status: 'drafting' }); } catch (e) { setReviewError(String(e.message || e)); } finally { setReviewing(false); } }; const sendOffer = () => { const t = draft.trim(); if (!t) return; const offer = { text: t, ts: Date.now() }; setMine({ status: 'offered', offer }); setConfirmOpen(false); }; const skipOffer = () => { setMine({ status: 'skipped' }); }; // Receiver controls — I am responding to their offer. const respond = (kind, note = '') => { const payload = { apology: { [otherSeat]: { response: { kind, note, ts: Date.now() } } } }; if (isHost) { const curAp = shared.apology || { A: {}, B: {}, continueA: false, continueB: false }; CG.session.setShared({ apology: { ...curAp, [otherSeat]: { ...(curAp[otherSeat] || {}), response: payload.apology[otherSeat].response } }, }); } else { CG.peer.send('state:propose', payload); } }; const setContinue = (v) => { if (seat === 'A') { if (isHost) CG.session.setShared({ apology: { ...ap, continueA: !!v } }); } else { if (isHost) CG.session.setShared({ apology: { ...ap, continueB: !!v } }); else CG.peer.send('state:propose', { apology: { continueB: !!v } }); } }; const bothDecided = (status) => ['offered', 'skipped', 'accepted', 'more-asked', 'declined'].includes(status); const readyToAdvance = // each side has made a decision: either offered (and gotten a receiver response) or skipped (mine.status === 'skipped' || (mine.status === 'offered' && theirs.response)) && (theirs.status === 'skipped' || (theirs.status === 'offered' && mine.response)); // Presence useEffect(() => { let activity = 'considering offer'; if (readyToAdvance) activity = 'ready to advance'; else if (mine.status === 'offered' && !theirs.response) activity = `waiting for ${otherName}`; else if (mine.status === 'skipped') activity = 'skipped'; else if (reviewing) activity = 'coach reviewing'; else if ((draft || '').trim().length > 0) activity = 'drafting offer'; try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {} }, [mine.status, theirs.response, readyToAdvance, reviewing, draft, otherName]); return (
{/* header */}
phase 07 · apology on-ramp · live

repair, if there is one to make. nothing is required.

either of you may offer, either may skip. the other has to answer.
{/* YOUR OFFER */} setConfirmOpen(true)} onSkip={skipOffer} onReset={() => { setMine({ status: 'drafting' }); setReview(null); }} />
{/* THEIR OFFER */}
{/* continue */}
when you're both done here
{myContinue ? '✓ you ' : '· you '} {theirContinue ? `· ✓ ${otherName}` : `· ${otherName} pending`}
{myContinue && !theirContinue && (
waiting on {otherName} to mark ready.
)}
{myContinue && theirContinue && ( )}
{!readyToAdvance && (
both of you need to either offer (and get a response) or skip before continuing.
)}
{confirmOpen && ( setConfirmOpen(false)} onConfirm={sendOffer} /> )}
); }; // -------- your offer drafter -------- function OfferDrafter({ seat, mine, otherName, draft, setDraft, review, setReview, reviewing, reviewError, onReview, onOpenConfirm, onSkip, onReset }) { // States: unset → drafting → offered | skipped const status = mine.status; if (status === 'offered') { return (
your offer · sent to {otherName}
offered
{mine.offer?.text}
waiting for {otherName}'s response. you can't retract.
); } if (status === 'skipped') { return (
your offer · skipped
you chose not to offer a repair right now. that's a valid answer.
); } return (
your offer to {otherName}
name the specific thing. own the impact. don't attach a counter-grievance. your coach will flag problems but won't rewrite.