Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.
This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.
What was already correct, and was kept rather than rebuilt
Memory lineage. Memories already carried (branch_id, depth) and retrieval
already filtered through the capped-path clause; the ten-step negative control
was measured passing against b7005e6 before any change here. M6 adds the
regression tests that pin it, plus provenance and authority on the result.
Summary lineage — both halves
A summary is a row carrying the coordinate of the last node it covers, and
eligibility is the same head-capped lineage clause memories use. That alone
was not enough: generation was seeded from adventures.story_summary, a
campaign-global column with no lineage, so after a divergence the summariser
was handed the abandoned line's prose and asked to update it. The row it
produced was correctly anchored and therefore looked safe while its sentences
described a story the reader had left.
Generation is now seeded from summaries.current — the same question the
context builder asks — so the input and the output are scoped by one rule.
adventures.story_summary remains a reader-facing mirror for the Plot panel and
the export bundle, kept in step when a summary is written and when the head
moves, and nothing authoritative reads it.
Retrieval redundancy
With a real embedding model, four near-identical memories crowded out the one
distinctive clue, which survived only because the default memory_top_k is 5.
Retrieval now drops a candidate that repeats one already chosen, never across
authority classes, at a threshold measured against the configured embedding
model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
and are recorded as such.
Memory authority, budgeting, observability
Memory.authority is accepted_story or heuristic, classified by the application
and marked in the prompt; retrieval never writes state. The reply is reserved
out of the context budget, and an impossible configuration fails clearly
instead of overflowing. Each derived pass records ok/idle/failed per campaign,
served by GET /adventures/{id}/derived and shown in Insights, so the M2
failure — a dead memory bank with a green suite — is visible if it recurs.
Provider-wiring tests mock no factory.
Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.
Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
510 lines
14 KiB
CSS
510 lines
14 KiB
CSS
/* ---------- Play page ---------- */
|
||
|
||
.play-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: calc(100dvh - 56px);
|
||
/* Room to scroll the last beat clear of a composer lifted by the keyboard
|
||
(see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink
|
||
the layout viewport themselves. Margin, not padding — .page already sets
|
||
padding and a longhand here would drop its bottom gutter. */
|
||
margin-bottom: var(--kb-inset, 0px);
|
||
}
|
||
|
||
/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
|
||
scrolls — pin the header just below the 56px top nav. */
|
||
.play-page .page-header {
|
||
position: sticky;
|
||
top: 56px;
|
||
z-index: 5;
|
||
background: var(--bg);
|
||
margin-bottom: 12px;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.story {
|
||
flex: 1;
|
||
font-family: var(--font-story);
|
||
font-size: 1.14rem;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
padding-bottom: 16px;
|
||
}
|
||
/* The head of a windowed transcript: how much story is still above, and the
|
||
way back to it. Deliberately quiet — it is a seam in the page, not a
|
||
feature — and it sits inside .story so it inherits the story face. */
|
||
.story-earlier {
|
||
text-align: center;
|
||
margin: 4px 0 22px;
|
||
font-size: 0.9rem;
|
||
/* No action-in animation here: this appears above content the reader is
|
||
already looking at, and a fade would read as the story moving. */
|
||
}
|
||
.story-earlier .dim { color: var(--text-dim); font-style: italic; }
|
||
.story-earlier button {
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 1px solid rgba(159, 199, 209, 0.3);
|
||
color: var(--text-dim);
|
||
font: inherit;
|
||
font-style: italic;
|
||
cursor: pointer;
|
||
padding: 2px 4px;
|
||
}
|
||
.story-earlier button:hover {
|
||
color: var(--accent-bright);
|
||
border-bottom-color: var(--accent-bright);
|
||
}
|
||
.story .action {
|
||
position: relative;
|
||
margin-bottom: 15px;
|
||
padding-right: 56px;
|
||
animation: action-in 0.4s ease both;
|
||
}
|
||
@keyframes action-in {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.story .action.player {
|
||
color: var(--player);
|
||
border-left: 2px solid rgba(159, 199, 209, 0.35);
|
||
padding-left: 14px;
|
||
font-style: italic;
|
||
}
|
||
.story .action.streaming { color: var(--text); }
|
||
.story .cursor {
|
||
color: var(--accent-bright);
|
||
text-shadow: 0 0 8px var(--accent-glow);
|
||
animation: blink 1s steps(1) infinite;
|
||
}
|
||
@keyframes blink { 50% { opacity: 0; } }
|
||
|
||
/* Reasoning-model thinking, collapsed above the AI's response */
|
||
.story .reasoning {
|
||
margin: 0 0 8px;
|
||
font-size: 0.82rem;
|
||
line-height: 1.5;
|
||
color: var(--text-dim);
|
||
font-style: normal;
|
||
}
|
||
.story .reasoning summary {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
color: var(--text-dim);
|
||
opacity: 0.8;
|
||
}
|
||
.story .reasoning summary:hover { opacity: 1; }
|
||
.story .reasoning .reasoning-text {
|
||
margin-top: 6px;
|
||
padding: 8px 12px;
|
||
border-left: 2px solid var(--border);
|
||
opacity: 0.85;
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.story .action-tools {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
|
||
.story .action-tools button {
|
||
padding: 1px 8px;
|
||
font-size: 0.72rem;
|
||
font-family: var(--font-ui);
|
||
color: var(--text-dim);
|
||
background: var(--bg-panel-glass);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
.story .action-tools button:hover { color: var(--text); }
|
||
|
||
/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote
|
||
on the message, not part of the prose.
|
||
|
||
Small and unemphatic on purpose. Stepping through takes reads them and does
|
||
nothing else, so it should not carry the visual weight of a decision; the
|
||
decision is made by writing, which happens in the composer below. */
|
||
.take-pager {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
margin-top: 6px;
|
||
font-family: var(--font-ui);
|
||
opacity: 0.45;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.story .action:hover .take-pager,
|
||
.take-pager:focus-within { opacity: 1; }
|
||
.take-pager button {
|
||
width: 20px;
|
||
height: 20px;
|
||
padding: 0;
|
||
font-size: 0.9rem;
|
||
line-height: 1;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: 4px;
|
||
}
|
||
.take-pager button:hover:not(:disabled) {
|
||
color: var(--text);
|
||
border-color: var(--border-bright);
|
||
}
|
||
.take-pager button:disabled { opacity: 0.3; cursor: default; }
|
||
.take-count {
|
||
min-width: 30px;
|
||
text-align: center;
|
||
font-size: 0.72rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-dim);
|
||
}
|
||
/* Only while a take that isn't the live one is on screen: it says what the
|
||
next thing typed will do, because that is the click that forks. */
|
||
.take-note {
|
||
margin-left: 6px;
|
||
font-size: 0.72rem;
|
||
font-style: italic;
|
||
color: var(--accent-dim);
|
||
}
|
||
|
||
/* ---------- Branches panel (Phase 14, SP7) ---------- */
|
||
|
||
.panel-empty {
|
||
padding: 18px 0;
|
||
color: var(--text-dim);
|
||
font-size: 0.85rem;
|
||
}
|
||
.branch-panel { display: flex; flex-direction: column; gap: 14px; }
|
||
.branch-intro {
|
||
margin: 0;
|
||
color: var(--text-dim);
|
||
font-size: 0.82rem;
|
||
line-height: 1.55;
|
||
}
|
||
.branch-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.branch-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
padding: 9px 11px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
background: var(--bg-input);
|
||
}
|
||
.branch-row.here {
|
||
border-color: var(--accent-dim);
|
||
background: rgba(212, 169, 78, 0.07);
|
||
}
|
||
.branch-head { display: flex; align-items: center; gap: 7px; }
|
||
.branch-glyph { color: var(--border-bright); font-size: 0.85rem; }
|
||
.branch-row.here .branch-glyph { color: var(--accent); }
|
||
.branch-name {
|
||
font-family: var(--font-story);
|
||
font-size: 0.98rem;
|
||
color: var(--text);
|
||
}
|
||
.branch-row.here .branch-name { color: var(--accent-bright); }
|
||
.branch-here {
|
||
margin-left: auto;
|
||
font-size: 0.64rem;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--accent-dim);
|
||
}
|
||
.branch-rename {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 3px 7px;
|
||
font-family: var(--font-story);
|
||
font-size: 0.95rem;
|
||
}
|
||
.branch-meta {
|
||
font-size: 0.72rem;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||
.branch-tools button, .branch-confirm button {
|
||
padding: 3px 9px;
|
||
font-size: 0.72rem;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.branch-tools button:hover:not(:disabled),
|
||
.branch-confirm button:hover:not(:disabled) {
|
||
color: var(--accent-bright);
|
||
border-color: var(--border-bright);
|
||
}
|
||
.branch-tools button.danger:hover:not(:disabled),
|
||
.branch-confirm button.danger:hover:not(:disabled) {
|
||
color: var(--danger);
|
||
border-color: var(--danger);
|
||
}
|
||
.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; }
|
||
.branch-confirm span {
|
||
font-size: 0.74rem;
|
||
color: var(--danger);
|
||
flex-basis: 100%;
|
||
}
|
||
|
||
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
|
||
Point is a name for a position, and it should not look like a different
|
||
species of thing from the lines it names positions on. The confirmations
|
||
borrow the branch panel's shape but not its danger colouring — restoring
|
||
destroys nothing, and only deletion is red. */
|
||
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
|
||
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
|
||
.save-point-new label {
|
||
font-size: 0.64rem;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
}
|
||
.save-point-new input {
|
||
padding: 5px 9px;
|
||
font-family: var(--font-story);
|
||
font-size: 0.95rem;
|
||
}
|
||
.save-point-new input.save-point-note {
|
||
font-size: 0.82rem;
|
||
}
|
||
.save-point-hint, .save-point-intro {
|
||
margin: 0;
|
||
color: var(--text-dim);
|
||
font-size: 0.78rem;
|
||
line-height: 1.55;
|
||
}
|
||
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.save-point-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
padding: 9px 11px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
background: var(--bg-input);
|
||
}
|
||
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
|
||
than hidden: it still restores, and hiding it would be the automatic cleanup
|
||
this milestone deliberately does not do. */
|
||
.save-point-row.elsewhere { opacity: 0.72; }
|
||
.save-point-head { display: flex; align-items: center; gap: 7px; }
|
||
.save-point-name {
|
||
font-family: var(--font-story);
|
||
font-size: 0.98rem;
|
||
color: var(--text);
|
||
}
|
||
.save-point-rename {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 3px 7px;
|
||
font-family: var(--font-story);
|
||
font-size: 0.95rem;
|
||
}
|
||
.save-point-meta {
|
||
font-size: 0.72rem;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.save-point-note-text {
|
||
font-size: 0.78rem;
|
||
color: var(--text-dim);
|
||
line-height: 1.5;
|
||
}
|
||
.save-point-tools, .save-point-confirm {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.save-point-tools button, .save-point-confirm button {
|
||
padding: 3px 9px;
|
||
font-size: 0.72rem;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.save-point-tools button:hover:not(:disabled),
|
||
.save-point-confirm button:hover:not(:disabled) {
|
||
color: var(--accent-bright);
|
||
border-color: var(--border-bright);
|
||
}
|
||
.save-point-tools button.danger:hover:not(:disabled),
|
||
.save-point-confirm button.danger:hover:not(:disabled) {
|
||
color: var(--danger);
|
||
border-color: var(--danger);
|
||
}
|
||
.save-point-tools button:disabled,
|
||
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
|
||
.save-point-confirm span {
|
||
font-size: 0.74rem;
|
||
color: var(--text-dim);
|
||
line-height: 1.5;
|
||
flex-basis: 100%;
|
||
}
|
||
|
||
.action-edit { margin-bottom: 14px; }
|
||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
||
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||
min-height keeps a short player action from collapsing; max-height stops a
|
||
very long beat from pushing Save/Cancel off the screen (it scrolls instead).
|
||
resize stays on, so the height is a starting point, not a cage. */
|
||
.action-edit textarea {
|
||
min-height: 110px;
|
||
max-height: 65vh;
|
||
overflow-y: auto;
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
}
|
||
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
|
||
|
||
.play-controls {
|
||
position: sticky;
|
||
/* Sits on the keyboard's top edge when one is open, on the viewport bottom
|
||
otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */
|
||
bottom: var(--kb-inset, 0px);
|
||
background: linear-gradient(to top, var(--bg) 75%, transparent);
|
||
padding: 18px 0 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
|
||
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
|
||
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
|
||
|
||
.input-bar { display: flex; gap: 8px; align-items: flex-end; }
|
||
.input-bar input { flex: 1; }
|
||
/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */
|
||
.input-bar textarea {
|
||
flex: 1;
|
||
resize: none;
|
||
min-height: 0;
|
||
max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */
|
||
overflow-y: auto;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.mode-select { display: flex; }
|
||
.mode-select button {
|
||
border-radius: 0;
|
||
border-right-width: 0;
|
||
padding: 7px 14px;
|
||
color: var(--text-dim);
|
||
text-transform: capitalize;
|
||
}
|
||
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
|
||
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
|
||
.mode-select button.active {
|
||
color: var(--accent-bright);
|
||
background: var(--bg-panel);
|
||
border-color: var(--accent-dim);
|
||
border-right-width: 1px;
|
||
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
|
||
}
|
||
.mode-select button.active + button { border-left-width: 0; }
|
||
|
||
/* Story State (M5). The same furniture as the other panels: what the campaign
|
||
believes is not a different species of thing from the lines it believes them
|
||
on, and a reader moving between panels should not have to relearn the shapes. */
|
||
.state-panel { display: flex; flex-direction: column; gap: 14px; }
|
||
.state-intro, .state-hint {
|
||
margin: 0;
|
||
color: var(--text-dim);
|
||
font-size: 0.78rem;
|
||
line-height: 1.55;
|
||
}
|
||
.state-group { display: flex; flex-direction: column; gap: 6px; }
|
||
.state-group h3 {
|
||
margin: 0;
|
||
font-size: 0.64rem;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
font-weight: normal;
|
||
}
|
||
.state-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
padding: 7px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
background: var(--bg-input);
|
||
}
|
||
.state-label {
|
||
font-family: var(--font-story);
|
||
font-size: 0.94rem;
|
||
color: var(--text);
|
||
}
|
||
.state-detail {
|
||
font-size: 0.74rem;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.state-tools { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||
.state-tools button {
|
||
padding: 2px 8px;
|
||
font-size: 0.7rem;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.state-tools button:hover:not(:disabled) {
|
||
color: var(--accent-bright);
|
||
border-color: var(--border-bright);
|
||
}
|
||
.state-tools button:disabled { opacity: 0.4; cursor: default; }
|
||
.state-correction { display: flex; flex-direction: column; gap: 7px; }
|
||
.state-correction form { display: flex; flex-direction: column; gap: 7px; }
|
||
.state-correction label {
|
||
font-size: 0.64rem;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
}
|
||
.state-correction input {
|
||
padding: 5px 9px;
|
||
font-family: var(--font-story);
|
||
font-size: 0.92rem;
|
||
}
|
||
.state-correct-open, .state-history-open {
|
||
align-self: flex-start;
|
||
padding: 3px 9px;
|
||
font-size: 0.72rem;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.state-correct-open:hover, .state-history-open:hover {
|
||
color: var(--accent-bright);
|
||
border-color: var(--border-bright);
|
||
}
|
||
.state-history { display: flex; flex-direction: column; gap: 4px; }
|
||
.state-history-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
padding: 5px 9px;
|
||
border-left: 2px solid var(--border);
|
||
}
|
||
.state-history-what { font-size: 0.8rem; color: var(--text); }
|
||
.state-history-when {
|
||
font-size: 0.68rem;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* M6: a retrieved memory that is an interpretation rather than a record. The
|
||
colour is the same one the state panel uses for a reader's own correction,
|
||
because both mean "weigh this differently from accepted story". */
|
||
.mem-heuristic {
|
||
color: #d8a657;
|
||
}
|