Field notebook: capture NPC lines, tear pages onto the board
A dialogue line gains a "✎ Note this" capture that saves it to a per-playthrough notebook (migration 035 + notebook endpoints). The inventory notebook lists captured pages in a handwriting font (Google "Reenie Beanie") and "✂ Tear to board" drops a page onto the current board as a note exhibit (reusing addNote), then removes the page. Board notes render handwritten too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#071916" />
|
<meta name="theme-color" content="#071916" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Reenie+Beanie&display=swap" rel="stylesheet" />
|
||||||
<title>GUPI OSINT Board</title>
|
<title>GUPI OSINT Board</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The player's field notebook: lines captured from NPCs during play, per playthrough.
|
||||||
|
-- A page can later be torn onto the board as a note exhibit.
|
||||||
|
CREATE TABLE osint.notebook_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at);
|
||||||
@@ -516,6 +516,23 @@ app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) =
|
|||||||
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
|
||||||
|
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try { res.json(await narrative.notebookPages(String(req.params.id))) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null)
|
||||||
|
page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId))
|
||||||
|
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
// The phone tool: the directory available on the current node, and dialing a number.
|
// The phone tool: the directory available on the current node, and dialing a number.
|
||||||
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
|
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
|
||||||
try { res.json(await narrative.phoneDirectory(String(req.params.id))) }
|
try { res.json(await narrative.phoneDirectory(String(req.params.id))) }
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export interface NarrativeRepository {
|
|||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
||||||
|
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
|
||||||
|
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
|
||||||
|
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
|
||||||
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
|
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
|
||||||
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
|
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
|
||||||
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
@@ -282,6 +285,26 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Field notebook: lines the player captured from NPCs during this playthrough.
|
||||||
|
async notebookPages(playthroughId) {
|
||||||
|
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
|
||||||
|
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
|
||||||
|
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
|
||||||
|
},
|
||||||
|
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
|
||||||
|
const clean = text.trim()
|
||||||
|
if (!clean) return null
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
|
const row = (await pool.query<{ id: string }>(
|
||||||
|
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
|
||||||
|
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
|
||||||
|
return { id: row.id, text: clean }
|
||||||
|
},
|
||||||
|
async removeNotebookPage(playthroughId, pageId) {
|
||||||
|
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
|
||||||
|
return (result.rowCount || 0) > 0
|
||||||
|
},
|
||||||
|
|
||||||
// The phone directory available on the player's current node: the terminals of a
|
// The phone directory available on the player's current node: the terminals of a
|
||||||
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
||||||
async phoneDirectory(playthroughId) {
|
async phoneDirectory(playthroughId) {
|
||||||
|
|||||||
+5
-4
@@ -214,8 +214,8 @@ export function App() {
|
|||||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNote = () => {
|
const addNote = (preset?: string) => {
|
||||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||||||
if (!content || !caseState) return
|
if (!content || !caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||||
@@ -543,7 +543,8 @@ export function App() {
|
|||||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }} onClose={() => setInventoryOpen(false)} />
|
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||||
|
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||||
</Suspense>}
|
</Suspense>}
|
||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
@@ -608,7 +609,7 @@ export function App() {
|
|||||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||||
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
||||||
<span />
|
<span />
|
||||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
<button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
|
||||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||||
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
||||||
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||||
|
|||||||
+23
-6
@@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) {
|
|||||||
return <div ref={stageRef} className="inv-stage" />
|
return <div ref={stageRef} className="inv-stage" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
|
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
|
||||||
const [index, setIndex] = useState(0)
|
const [index, setIndex] = useState(0)
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
|
||||||
if (active) return <div className="inv-tool">
|
if (active) return <div className="inv-tool">
|
||||||
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
||||||
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
|
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
return <div className="inv-backdrop">
|
return <div className="inv-backdrop">
|
||||||
@@ -108,13 +108,30 @@ export function Inventory({ onClose, session }: { onClose?: () => void; session?
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
|
// Field notebook: the lines captured from NPCs during play. A page can be torn off
|
||||||
function NotebookTool() {
|
// onto the board as a note (when a board tear handler is available).
|
||||||
const [text, setText] = useState('')
|
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
|
||||||
|
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playthroughId) return
|
||||||
|
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
|
||||||
|
}, [playthroughId])
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
|
||||||
|
setPages(list => list.filter(page => page.id !== id))
|
||||||
|
}
|
||||||
|
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
|
||||||
return <div className="notebook">
|
return <div className="notebook">
|
||||||
<div className="notebook-page">
|
<div className="notebook-page">
|
||||||
<div className="notebook-head">FIELD NOTEBOOK</div>
|
<div className="notebook-head">FIELD NOTEBOOK</div>
|
||||||
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
|
{!pages.length && <p className="notebook-empty">Nothing noted yet. During a conversation, use “✎ Note this” to jot down what someone says.</p>}
|
||||||
|
{pages.map(page => <div key={page.id} className="notebook-note">
|
||||||
|
<p>{page.text}</p>
|
||||||
|
<div className="notebook-note-actions">
|
||||||
|
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board">✂ Tear to board</button>}
|
||||||
|
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -81,7 +81,7 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
|||||||
|
|
||||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||||
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
export function DialoguePlayer({ node, onExit, onAward, onCapture, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; onCapture?: (text: string, utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
const onAwardRef = useRef(onAward)
|
const onAwardRef = useRef(onAward)
|
||||||
@@ -149,7 +149,9 @@ export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { nod
|
|||||||
<div className="dialogue-scrim" aria-hidden />
|
<div className="dialogue-scrim" aria-hidden />
|
||||||
<div className="dialogue-box">
|
<div className="dialogue-box">
|
||||||
<div className="dialogue-panel">
|
<div className="dialogue-panel">
|
||||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}
|
||||||
|
{onCapture && !inline && current.utterer === 'npc' && done && <button className="dialogue-capture" title="Copy to notebook" onClick={event => { event.stopPropagation(); onCapture(current.text, current.id) }}>✎ Note this</button>}
|
||||||
|
</div>
|
||||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||||
{showChoices
|
{showChoices
|
||||||
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||||
|
|||||||
+2
-1
@@ -103,6 +103,7 @@ export function Play() {
|
|||||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||||
onExit={terminalKey => { void advance(terminalKey) }}
|
onExit={terminalKey => { void advance(terminalKey) }}
|
||||||
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }}
|
||||||
|
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
|
||||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -787,6 +787,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.inv-back:hover { border-color: #cdea6a; }
|
.inv-back:hover { border-color: #cdea6a; }
|
||||||
/* notebook tool (placeholder) */
|
/* notebook tool (placeholder) */
|
||||||
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
||||||
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
|
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||||
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
||||||
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }
|
.notebook-empty { font: 22px/1.3 "Reenie Beanie", cursive; color: #8a7a52; }
|
||||||
|
.notebook-note { border-bottom: 1px dashed #c8b98f; padding-bottom: 8px; }
|
||||||
|
.notebook-note p { margin: 0 0 6px; font: 27px/30px "Reenie Beanie", cursive; color: #26356b; }
|
||||||
|
.notebook-note-actions { display: flex; gap: 8px; }
|
||||||
|
.notebook-note-actions button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 3px 9px; cursor: pointer; }
|
||||||
|
.notebook-note-actions button.ghost { background: transparent; border-color: transparent; color: #a08a5a; }
|
||||||
|
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||||
|
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||||
|
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||||
|
/* A note torn to the board reads as handwriting too. */
|
||||||
|
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||||
|
|||||||
Reference in New Issue
Block a user