Add authorable per-level briefing checklist

The case briefing showed a wall of body text. Add a checklist of short,
player-tickable steps authored per level: a new `checklist` on the level brief
(table osint.level_brief_checklist_items), read/written with the board, cloned
on template freeze/instantiate, and carried through the mystery importer and the
mystery:pull exporter. Player tick state is guidance-only, persisted locally per
level rather than on the shared board. Convert the Barricelli inventor-proof
brief from prose into a short intro plus a six-step checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 22:50:50 +02:00
co-authored by Claude Opus 4.8
parent 95b17e945c
commit e37dc651f3
9 changed files with 53 additions and 12 deletions
+13 -3
View File
@@ -615,6 +615,7 @@ export function App() {
<div className="case-heading"><h1>{caseState.title}</h1></div>
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
levelId={caseState.id}
brief={caseState.brief}
goals={caseState.goals}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
@@ -1215,14 +1216,21 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div>
}
function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
function BriefPanel({ levelId, brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { levelId: string; brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
const [minimized, setMinimized] = useState(false)
const partyById = new Map(parties.map(party => [party.id, party]))
// Player-side checklist ticks: guidance only, so persist locally per level rather than
// on the shared board.
const checklist = brief.checklist || []
const checkedKey = `gupi-osint-board:checklist:${levelId}`
const [checked, setChecked] = useState<Set<string>>(() => { try { return new Set<string>(JSON.parse(localStorage.getItem(checkedKey) || '[]')) } catch { return new Set() } })
const toggleChecked = (item: string) => setChecked(previous => { const next = new Set(previous); next.has(item) ? next.delete(item) : next.add(item); localStorage.setItem(checkedKey, JSON.stringify([...next])); return next })
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pending = goals.filter(goal => goal.status === 'pending').length
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
{checklist.length > 0 && <ul className="brief-checklist">{checklist.map((item, index) => { const done = checked.has(item); return <li key={index}><button type="button" className={done ? 'done' : ''} aria-pressed={done} onClick={() => toggleChecked(item)}><i>{done ? '☑' : '☐'}</i><span>{item}</span></button></li> })}</ul>}
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
@@ -1246,12 +1254,14 @@ function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; ha
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [checklist, setChecklist] = useState((brief.checklist || []).join('\n'))
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), checklist: checklist.split('\n').map(item => item.trim()).filter(Boolean), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={4} value={body} onChange={event => setBody(event.target.value)}/></label>
<label className="field"><span>CHECKLIST · ONE STEP PER LINE</span><textarea aria-label="Level checklist" rows={5} value={checklist} onChange={event => setChecklist(event.target.value)} placeholder={'Find a reliable source online\nPaste the screenshot onto the board\nConnect it to the claim with red thread\nSubmit the Case Report'}/></label>
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
+7
View File
@@ -262,6 +262,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
.brief-panel > p { margin: 16px; padding: 13px; white-space: pre-line; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-checklist { margin: 0 16px 16px; padding: 0; list-style: none; display: grid; gap: 5px; }
.brief-checklist button { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; align-items: start; width: 100%; padding: 9px 11px; text-align: left; cursor: pointer; background: #d5d5ca; border: 1px solid #89928b; color: #293b35; font: 12px/1.4 Special Elite; }
.brief-checklist button:hover { background: #dcdccf; }
.brief-checklist button > i { font-style: normal; font-size: 15px; line-height: 1.1; color: #9a5f2e; }
.brief-checklist button.done { background: #cbd9cc; border-color: #78927d; }
.brief-checklist button.done > i { color: #437558; }
.brief-checklist button.done > span { color: #5b6862; text-decoration: line-through; }
.brief-goals { margin: 0 16px 16px; display: grid; gap: 7px; }.brief-goals section { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid #89928b; background: #d5d5ca; }.brief-goals section > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #9b6232; border-radius: 50%; color: #854d25; font: 600 10px IBM Plex Mono; font-style: normal; }.brief-goals section > div { display: grid; gap: 4px; }.brief-goals b { color: #293b35; font: 600 9px IBM Plex Mono; }.brief-goals span { color: #5b6862; font: 10px/1.4 Special Elite; }.brief-goals em { color: #9a5f2e; font: 600 7px IBM Plex Mono; font-style: normal; letter-spacing: .08em; }.brief-goals section.complete { background: #cbd9cc; border-color: #78927d; }.brief-goals section.complete > i { border-color: #437558; background: #4d7f60; color: #f1f1e7; }.brief-goals section.complete em { color: #34644a; }
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
+1 -1
View File
@@ -163,7 +163,7 @@ export interface BriefConcept {
resolvedPartyExhibitId?: string
}
export interface LevelBrief { body: string; concepts: BriefConcept[] }
export interface LevelBrief { body: string; concepts: BriefConcept[]; checklist?: string[] }
export interface LevelGoal {
/** Present in author mode so the goal can be edited; omitted in play mode. */