// 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 (
);
}
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.
);
}
function CoachReview({ review }) {
return (
coach · private to you
{review.ready_to_offer ? '✓ looks ready to offer' : '· not there yet'}
{review.note && (
{review.note}
)}
{review.issues.length > 0 && (
issues
{review.issues.map((iss, i) => (
{iss.kind} · {iss.severity}
"{iss.quote}"
{iss.reason}
))}
)}
{review.missing.length > 0 && (
missing
{review.missing.map((m, i) => - {m}
)}
)}
{review.suggestions.length > 0 && (
directions (not rewrites)
{review.suggestions.map((s, i) => - {s}
)}
)}
);
}
// -------- incoming offer card --------
function IncomingOffer({ seat, theirs, otherName, myName, onRespond }) {
const status = theirs.status;
const offer = theirs.offer;
const response = theirs.response;
if (!status || status === 'drafting') {
return (
from {otherName}
{status === 'drafting' ? `${otherName} is drafting something. you'll see it only if they choose to send it.` : `nothing has arrived yet.`}
);
}
if (status === 'skipped') {
return (
from {otherName}
{otherName} is not offering a repair at this time.
);
}
if (status === 'offered') {
return (
from {otherName}
{!response &&
your turn}
{offer?.text}
{!response ? (
) : (
you responded · {response.kind.replace('-', ' ')}
{response.note &&
{response.note}
}
)}
);
}
return null;
}
function ResponderControls({ onRespond, otherName }) {
const [mode, setMode] = useState(null); // 'more' | 'decline' | null
const [note, setNote] = useState('');
if (mode === null) {
return (
);
}
const placeholder = mode === 'more'
? `what more would help? (optional, visible to ${otherName})`
: `a one-line reason? (optional, visible to ${otherName})`;
return (
);
}
function ConfirmModal({ draft, otherName, onCancel, onConfirm }) {
return (
e.stopPropagation()}
style={{ maxWidth: 520, width: '90%', padding: 22, background: 'var(--paper)' }}
>
send this to {otherName}?
{draft}
once you send, you can't retract. {otherName} will decide to accept, ask for more, or decline.
);
}
console.log('[phases-apology-live] loaded');
})();