diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b82463..642ffea 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1050,14 +1050,64 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n .insights-mode button { padding: 2px 8px; font-size: 0.75rem; } .token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; } .token-total.over { color: var(--danger); } -.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; } -.token-bar-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright)); - box-shadow: 0 0 8px var(--accent-glow); - border-radius: 3px; - transition: width 0.4s ease; +/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in + Play.jsx so the bar, its legend and each section header stay in sync. */ +.token-split { + display: flex; + height: 14px; + background: var(--bg-input); + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; } +.token-split.over { box-shadow: 0 0 0 1px var(--danger); } +.token-seg { + min-width: 2px; /* a 0.2% section is still worth seeing */ + border-right: 1px solid var(--bg-panel); + transition: opacity 0.15s ease; +} +.token-seg:last-child { border-right: none; } +.token-seg-free { background: transparent; min-width: 0; border-right: none; } +.token-seg.faded, .token-legend-item.faded { opacity: 0.25; } + +.token-legend { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 1px 12px; + margin-bottom: 10px; +} +.token-legend-item { + display: flex; + align-items: center; + gap: 7px; + background: none; + border: none; + border-radius: 4px; + padding: 2px 5px; + font-size: 0.78rem; + color: var(--text-dim); + text-align: left; + transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; +} +.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); } +.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; } +.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.token-legend-tok { + width: 4.5em; + text-align: right; + opacity: 0.65; + font-variant-numeric: tabular-nums; +} +.token-legend-more { + justify-self: start; + background: none; + border: none; + padding: 2px 5px; + font-size: 0.75rem; + color: var(--text-dim); +} +.token-legend-more:hover { color: var(--accent); background: none; } .insights-history { color: var(--text-dim); margin-bottom: 8px; } .insights-cards div { margin-bottom: 3px; } .insights-cards .dropped { color: var(--danger); } @@ -1085,17 +1135,8 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n max-height: 260px; overflow-y: auto; } -.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; } -.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; } -.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; } -.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; } -.ctx-story_summary { border-left-color: #7dc9a2; } -.ctx-story_summary .ctx-header { color: #7dc9a2; } -.ctx-world_lore { border-left-color: #c9b47d; } -.ctx-world_lore .ctx-header { color: #c9b47d; } -.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; } -.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; } -.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; } +/* Per-section border/header colours are applied inline from SECTION_COLORS + (Play.jsx) — one palette drives the bar, the legend and these headers. */ /* ---------- Scripts ---------- */ diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index b280b24..6a2e852 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -51,8 +51,40 @@ const SECTION_LABELS = { authors_note: "Author's Note", recent_history: 'Recent history', front_memory: 'Front memory', + length_hint: 'Length guidance', + world_state_reminder: 'World State (emit reminder)', } +// One colour per context section, and the single source of truth for it: the +// token bar, the legend and each section's own header all read from here, so a +// slice of the bar and the text it stands for always carry the same colour. +// Related sections share a hue family but never an exact shade — in a stacked +// bar two identical colours read as one section. +const SECTION_COLORS = { + narrator: '#7d8fc9', + ai_instructions: '#9c8fd6', + plot_essentials: '#c97dc0', + script_context: '#d99ad0', + story_summary: '#7dc9a2', + used_memories: '#5fb8c9', + world_lore: '#c9b47d', + world_state: '#d79a63', + world_state_guide: '#b8834a', + world_state_rule: '#9d7a52', + world_state_reminder: '#8a6f52', + history: '#74748c', + recent_history: '#9d9db4', + authors_note: '#c97d7d', + front_memory: '#d99a9a', + length_hint: '#98a06b', +} +const SECTION_FALLBACK = '#6a6a78' +const sectionColor = (label) => SECTION_COLORS[label] || SECTION_FALLBACK + +// Share of the prompt, rounded for glanceability. Sections too small to round +// to a whole percent still say so rather than showing a misleading 0%. +const pctLabel = (pct) => (pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`) + const FIELD_LABELS = { memory: 'Plot Essentials (Memory)', authors_note: "Author's Note", @@ -1044,12 +1076,16 @@ function ScriptReport({ script }) { )} {script.context_changed && ( <> -
-
Context before script
+
+
+ Context before script +
{script.context_before}
-
-
Context after script (sent to AI)
+
+
+ Context after script (sent to AI) +
{script.context_after}
@@ -1058,6 +1094,89 @@ function ScriptReport({ script }) { ) } +const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget + +// Where the prompt's tokens actually went: one stacked bar scaled to the +// budget (so leftover width IS the remaining headroom) plus a legend ordered +// biggest-first, which is the order that answers "what is eating my context?". +function TokenBreakdown({ sections, tokens, used, onJump }) { + const [hovered, setHovered] = useState(null) + const [expanded, setExpanded] = useState(false) + if (used <= 0) return null + const budget = tokens.budget || 0 + // Over budget there is no headroom to draw, so the bar scales to what the + // prompt actually costs and the total line above it carries the warning. + const scale = Math.max(budget, used) + const free = Math.max(0, budget - used) + const filled = sections + .map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 })) + .filter((s) => s.tokens > 0) + const ranked = [...filled].sort((a, b) => b.tokens - a.tokens) + // The panel is narrow, so the legend is one column: list the sections that + // actually move the budget and fold the long tail behind a count. + const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE) + const rest = ranked.slice(shown.length) + const restPct = rest.reduce((n, s) => n + s.pct, 0) + const describe = (s) => + `${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}` + + return ( + <> +
budget ? 'over' : ''}`} + role="img" + aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`} + > + {filled.map((s) => ( +
setHovered(s.i)} + onMouseLeave={() => setHovered(null)} + /> + ))} + {free > 0 && ( +
+ )} +
+
+ {shown.map((s) => ( + + ))} + {rest.length > 0 && ( + + )} + {expanded && ranked.length > LEGEND_VISIBLE && ( + + )} +
+ + ) +} + function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { const [report, setReport] = useState(null) const [error, setError] = useState(null) @@ -1079,6 +1198,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { const { tokens, cards, history, sections } = report const overBudget = tokens.total > tokens.budget + // The per-section sum, not tokens.total: the total also counts the separators + // between sections, and percentages have to add up to 100 for the reader. + const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0) + const jumpToSection = (i) => { + document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } return (
@@ -1094,10 +1219,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
{tokens.total} / {tokens.budget} tokens
-
-
-
+
History: {history.included} of {history.total} actions in context {history.total > history.included && ' (older history trimmed)'} @@ -1132,10 +1259,18 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
{sections.map((s, i) => ( -
-
+
+
{SECTION_LABELS[s.label] || s.label} - {s.tokens} tok + + {s.tokens} tok + {sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`} +
{s.text}