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:
@@ -0,0 +1,10 @@
|
|||||||
|
-- Case-briefing checklist: an ordered list of short, player-tickable steps authored per
|
||||||
|
-- level, sitting alongside the brief body and concepts. Tick state is client-side only,
|
||||||
|
-- so the server just stores the authored item text and its order.
|
||||||
|
CREATE TABLE osint.level_brief_checklist_items (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
text TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX level_brief_checklist_items_board_order ON osint.level_brief_checklist_items (board_id, sort_order);
|
||||||
@@ -8,8 +8,15 @@
|
|||||||
"title": "The Barricelli Files",
|
"title": "The Barricelli Files",
|
||||||
"subtitle": "Scene 7 · Demonstrate OSINT skill",
|
"subtitle": "Scene 7 · Demonstrate OSINT skill",
|
||||||
"brief": {
|
"brief": {
|
||||||
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.",
|
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor, using open sources.",
|
||||||
"concepts": []
|
"checklist": [
|
||||||
|
"Find a reliable source online",
|
||||||
|
"Screenshot it and paste it onto the board",
|
||||||
|
"Fill in the source, date and URL so it can be verified",
|
||||||
|
"Connect the source to the claim with red thread",
|
||||||
|
"Explain what the evidence proves",
|
||||||
|
"Submit the Case Report"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ function serializeLevel(board: TemplateBoardExport, assetFilenames: Map<string,
|
|||||||
title: state.title,
|
title: state.title,
|
||||||
subtitle: state.subtitle || undefined,
|
subtitle: state.subtitle || undefined,
|
||||||
timelineRange: timeline?.range,
|
timelineRange: timeline?.range,
|
||||||
brief: state.brief.body || state.brief.concepts.length
|
brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length
|
||||||
? { body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })) }
|
? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist })
|
||||||
: undefined,
|
: undefined,
|
||||||
documents: documents.map(document => prune({
|
documents: documents.map(document => prune({
|
||||||
key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind,
|
key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type MysteryLevel = {
|
|||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
timelineRange?: { start: string; end: string }
|
timelineRange?: { start: string; end: string }
|
||||||
brief?: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
brief?: { body: string; concepts?: { label: string; context: string; expectedPartyKind: PartyKind }[]; checklist?: string[] }
|
||||||
documents?: MysteryDocument[]
|
documents?: MysteryDocument[]
|
||||||
folders?: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
folders?: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||||
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
|
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
|
||||||
@@ -116,7 +116,7 @@ async function importLevel(baseUrl: string, folderDir: string, level: MysteryLev
|
|||||||
}
|
}
|
||||||
|
|
||||||
const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()]))
|
const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()]))
|
||||||
if (level.brief) state.brief = { body: level.brief.body, concepts: level.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
if (level.brief) state.brief = { body: level.brief.body, concepts: (level.brief.concepts || []).map(concept => ({ id: randomUUID(), ...concept })), checklist: level.brief.checklist }
|
||||||
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view)
|
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view)
|
||||||
const folders = levelFolders.map(folder => ({
|
const folders = levelFolders.map(folder => ({
|
||||||
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export async function clearBoard(client: PoolClient, boardId: string) {
|
|||||||
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
||||||
|
await client.query('DELETE FROM osint.level_brief_checklist_items WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.level_goals WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.level_goals WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.evidence_match_rules WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
||||||
@@ -253,6 +254,8 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
|
|
||||||
const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId])
|
const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId])
|
||||||
if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body])
|
if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body])
|
||||||
|
const checklist = await client.query<{ sort_order: number; text: string }>('SELECT sort_order,text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
|
||||||
|
for (const row of checklist.rows) await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), targetBoardId, row.sort_order, row.text])
|
||||||
const concepts = await client.query<{
|
const concepts = await client.query<{
|
||||||
id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null
|
id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null
|
||||||
}>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
|
}>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
// a template board that no player has instantiated.
|
// a template board that no player has instantiated.
|
||||||
async function assembleBoard(level: LevelRow, authorMode = false): Promise<CaseState> {
|
async function assembleBoard(level: LevelRow, authorMode = false): Promise<CaseState> {
|
||||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
|
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult, checklistResult] = await Promise.all([
|
||||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
||||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||||
@@ -419,6 +419,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
|
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
|
||||||
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
|
pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
|
||||||
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
|
pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
|
||||||
|
pool.query<{ text: string }>('SELECT text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [level.board_id]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const blocks = new Map<string, string[]>()
|
const blocks = new Map<string, string[]>()
|
||||||
@@ -480,7 +481,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||||
views, revision: Number(level.revision),
|
views, revision: Number(level.revision),
|
||||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
brief: { body: briefResult.rows[0]?.body || '', concepts, checklist: checklistResult.rows.map(row => row.text) }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||||
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
|
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
|
||||||
}
|
}
|
||||||
@@ -520,6 +521,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
|
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
|
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
|
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
|
||||||
|
await client.query('DELETE FROM osint.level_brief_checklist_items WHERE board_id=$1', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
|
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
|
||||||
await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id])
|
await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id])
|
||||||
@@ -657,6 +659,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
}
|
}
|
||||||
const brief = state.brief || { body: '', concepts: [] }
|
const brief = state.brief || { body: '', concepts: [] }
|
||||||
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
|
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
|
||||||
|
for (const [sortOrder, item] of (brief.checklist || []).map(text => text.trim()).filter(Boolean).entries())
|
||||||
|
await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), level.board_id, sortOrder, item])
|
||||||
for (const [sortOrder, concept] of brief.concepts.entries()) {
|
for (const [sortOrder, concept] of brief.concepts.entries()) {
|
||||||
requireUuid(concept.id, 'Brief concept id')
|
requireUuid(concept.id, 'Brief concept id')
|
||||||
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
|
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
|
||||||
|
|||||||
+13
-3
@@ -615,6 +615,7 @@ export function App() {
|
|||||||
<div className="case-heading"><h1>{caseState.title}</h1></div>
|
<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} />
|
<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
|
{briefOpen && <BriefPanel
|
||||||
|
levelId={caseState.id}
|
||||||
brief={caseState.brief}
|
brief={caseState.brief}
|
||||||
goals={caseState.goals}
|
goals={caseState.goals}
|
||||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||||
@@ -1215,14 +1216,21 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
|||||||
</form></div>
|
</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 [minimized, setMinimized] = useState(false)
|
||||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
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 unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
const pending = goals.filter(goal => goal.status === 'pending').length
|
const pending = goals.filter(goal => goal.status === 'pending').length
|
||||||
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
|
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>
|
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>
|
<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>}
|
{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>
|
{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></>}
|
<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 }) {
|
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
|
||||||
const [body, setBody] = useState(brief.body)
|
const [body, setBody] = useState(brief.body)
|
||||||
|
const [checklist, setChecklist] = useState((brief.checklist || []).join('\n'))
|
||||||
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
||||||
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
|
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>
|
<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>
|
<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="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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -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 > 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.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-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-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; }
|
.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; }
|
.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
@@ -163,7 +163,7 @@ export interface BriefConcept {
|
|||||||
resolvedPartyExhibitId?: string
|
resolvedPartyExhibitId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelBrief { body: string; concepts: BriefConcept[] }
|
export interface LevelBrief { body: string; concepts: BriefConcept[]; checklist?: string[] }
|
||||||
|
|
||||||
export interface LevelGoal {
|
export interface LevelGoal {
|
||||||
/** Present in author mode so the goal can be edited; omitted in play mode. */
|
/** Present in author mode so the goal can be edited; omitted in play mode. */
|
||||||
|
|||||||
Reference in New Issue
Block a user