M8: the browser becomes the storyteller

The interface was AI-DnD's with this product's features bolted into it. The
navigation read Home · Adventures · Scenarios · Settings · AI Chat; starting a
story meant first picking a *world*, and making a world meant a JSON stat-schema
form, a story-card table and an art picker. The play screen had a Branches tab.
The input had three modes. Sixteen of the sixteen controls on a two-turn story
had no accessible name — they were single glyphs with a tooltip.

All of that was measured in a real browser before anything was changed, and the
measurements are in planning/reports/M8-IMPLEMENTATION-REPORT.md §C. Almost
nothing underneath was wrong: the play loop, the history controls, the takes,
the Save Points, the state correction and the knowledge library all worked. What
was wrong was what a reader was asked to understand in order to use them.

So the shape now is one entry point and one screen:

  Campaigns -> Campaign -> Story
                           State · Knowledge · Context · Save Points · Settings

Everything that is not the story lives in a panel that starts closed. The
top navigation bar is hidden on the story screen entirely, because on that one
screen the story is the interface.

Play is one natural-language field. An action and a piece of quoted dialogue are
both just what the reader wrote, and B01/B02 confirmed against a real narrator
that the model reads the quotes without being told which kind of turn it is.
What survives from the old Story mode is a Story direction toggle, which is not
a fourth mode: it changes who is being spoken to, not what kind of action is
taken, and the box is visibly marked while it is on.

Branch, fork, node, merge and head appear nowhere a reader can see them. The
branch panel and the tree overlay are gone from the browser. The mechanism is
untouched — takes, divergence, retained futures and Save Points all still work,
and their endpoints are still tested. This is a decision about what a reader is
asked to understand, not a reduction of what the product can do.

The two defects worth the space:

A player action is stored with AI Dungeon's "> You " prefix. That was right when
the Do mode asked for a bare verb phrase. With one field the spec tells the
reader to write "I enter the tavern", and the result was "> You I enter the
tavern." — in the transcript, in the replayed history, and therefore in the
narration, where a small model imitates it and writes "You I thank her". M8's
own design surfaced it, so M8 fixed it: the prefix is added only when the reader
has not already written a subject. The ">" marker, which is what actually
identifies a player turn in the prompt, is unchanged in every case.

And a stale `.input-bar { display: flex }` in play.css overrode the new
composer, because that sheet is imported after the new one. The direction row
and the input row laid out side by side and the box was unusably narrow. Found
by opening the product in a browser, not by reading the CSS — which is the
argument for having done that first.

Failures now have the taxonomy the spec asked for rather than one toast: model,
generation, state, knowledge, server, each with the thing to do about it. A
failed turn leaves the reader's words in the box and says so. The classification
reads backend strings, so it is a fallback ladder rather than a lookup — an
unrecognised message still classifies, still shows the server's own words and
still offers Retry.

`Settings.model` could be empty with nothing saying so until the first turn
failed with a provider error. The header now reports Ollama in five states, and
an unconfigured or missing model offers the models actually installed on the
endpoint, from the connection test that already knew them. Nothing is chosen
automatically: an endpoint's first model may be an embedding model, which cannot
narrate at all.

Narrator prose is rendered as safe Markdown — headings, emphasis, lists,
blockquotes, code. The safety is structural rather than filtered: every node is
a React element built from parsed text, and there is no dangerouslySetInnerHTML
in the file. A sanitizer is not needed to make markup safe if markup is never
produced from input. Link schemes are checked with the URL parser rather than a
pattern, because the bypasses are all in the parsing. A remote image is a
placeholder naming the blocked address; the knowledge and context panels
deliberately do not use this renderer at all, because they exist to show a
reader exactly what is in their file.

Backend, and only what the browser could not otherwise reach:

  AdventureCreate.opening   a start action could only come from a Scenario, so
                            every campaign made in the new setup flow opened on
                            a blank page. Same node, same code path.
  canon_rules               campaign_canon has been the highest authority in a
                            campaign since M5, read by the prompt builder and
                            the state validator, and had no API at all — a
                            fixture had to write it with SQL.
  a 401 and a 429 message   the last user-facing text describing a hosted
                            deployment. One told the reader to check an API key
                            that has not existed since M2.

No schema change and no migration: proved by building a database with a server
running the M7 commit's own code and opening it with this one.

The project had no frontend tests. It has 132 now, across ten files, running
in about six seconds — the enabled state of every history control, the take
selector, the confirmations, the panels, the five model states, the failure
taxonomy, the focus trap, accessibility, and that the reserved dictation control
never touches the microphone. Writing them found a real defect: the focus trap
filtered candidates with offsetParent, which is null inside the fixed-position
ancestor the dialog has and which jsdom never computes — it would have behaved
differently in the tests from the browser.

They do not replace the real-browser runs, and both kinds of evidence are in the
report. The browser suites drive the production build served by the real backend
with a real local narrator, including a genuine process restart.

A verification pass over all of it then found three more, each by driving the
product rather than reading it:

Stepping between alternate takes did nothing. The pager asked whether a take
lived on another line by comparing `target.branch_id !== action.branch_id`, and
`ActionOut` has never carried `branch_id` — so the comparison was permanently
`number !== undefined`, always true, and every step took the branch-switch path.
For two takes of an ordinary retry, which share a line until one is written
below, that meant switching to the line already being read: the same window came
back and nothing moved. D07 is a required v1 acceptance test. The fix needed no
new field — the variants list already carries every attempt's branch and marks
the live one.

The first regression test for that passed against the broken code, because its
fixture gave the action a `branch_id` the real payload never sends. That is the
exact failure M7's review was about, so the fixture was corrected, the tests were
re-run against the reverted code and failed for the right reason, and the
fixture now carries a docstring saying why the field must never come back.

And the knowledge panel pointed readers at an "embedding model" while the
setting is called "Model for meaning-based search" — a reader sent looking for a
field that does not exist by that name.

Campaign canon was measured rather than assumed. Editing it after play is a
configuration change: every turn already played keeps the canon it was actually
given, in its own context snapshot, and the accepted story, the state document
and the state audit log are byte-identical across an edit. It is not routed
through M5's state audit, because canon is not narrative state and doing so
would create the second representation the spec forbids. What the editor does
now is say so, once a campaign has moments.

`BROWSER-UX-SPEC.md` §38 asked for a "Show Hidden Story State" toggle. There is
no hidden story state — a secret lives in a narrator-only knowledge source and
never enters the state document. The section is rewritten to require what it
actually meant: ordinary surfaces must not carry narrator-only information,
advanced inspection must withhold it by default behind an explicit warned
choice, and no second store may be invented to give a toggle something to
reveal. The protection is stricter than before, not weaker.

Closeout. An independent review returned M8 IMPLEMENTATION: PASS subject to
evidence and documentation cleanup, and this commit carries that cleanup:

The report named two frontend bundles as the artifact behind its acceptance
evidence. The saved run logs settle it. index-Ii-lARp9.js, built at 18:53:02
from this tree, is the one final frozen artifact behind all 157 browser checks;
index-C6E5Uvtu.js is superseded — it predates the D09 fix and its acceptance
suite ended 54/55 on exactly that defect. No tracked file under backend/app or
frontend/src has a modification time after the freeze, so the whole final
campaign describes one build. §P sets the two side by side.

Finding 14 — the app budgets 16,384 prompt tokens while an Ollama that sees no
VRAM enforces 4,096 — is resolved operationally, with no application change.
The OpenAI-compatible endpoint this app speaks accepts num_ctx and ignores it,
and reloads the model at its own default, so a native call cannot prime it
either. A model derived with POST /api/create carries the parameter, is honoured
through the app's own OpenAI-compatible path, and appears in /v1/models — which
is the listing the Settings model picker already reads. Measured end to end.
The procedure is in DEVELOPMENT.md; nothing in the repository depends on any
particular derived model existing. Adding provider code to work around this was
declined deliberately: it would mean either a second native request path,
against ADR 011, or a parameter the endpoint provably ignores.

The §38 rewrite is ratified as a requirement clarification aligned with the
implemented architecture, and the spec gains the clause finding 3 was really
about: withheld material must be absent from the rendered DOM, not merely
collapsed in it.

The report's §U carries the M9 handoff — what a portable campaign has to include,
whether historical context snapshots belong in the bundle, what happens to
inherited story cards, and that a restored campaign may meet a different context
window than the one that wrote it. None of it is implemented here.

Final: backend 950 passed / 14 skipped; frontend 132 passed; lint, production
build and Docker build clean; 157 browser checks across six suites, zero
failures. M8 is implemented, verified, reviewed and accepted (2026-09-06).
M9 has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HdaXiFbscatQaLS7dJk6b
This commit is contained in:
JesseMarkowitz
2026-09-06 23:31:45 -04:00
co-authored by Claude Opus 5
parent 480414efe0
commit 1ce9972760
89 changed files with 11985 additions and 6532 deletions
-287
View File
@@ -1,287 +0,0 @@
/* ---------- Visit analytics (owner dashboard) ---------- */
/* Wider than the reading pages: this one is a grid of small figures, not
prose, and 960px puts two charts and eight tiles into a column. */
.an-page { max-width: 1180px; }
.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; }
/* Four across, not auto-fit: there are eight tiles, and letting them flow
leaves a single orphan on the second row at most widths. */
.an-tiles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 900px) {
.an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.an-tile {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.an-tile-value {
font-family: var(--font-display);
font-size: 1.7rem;
line-height: 1.1;
color: var(--accent-bright);
}
.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
.an-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.an-card {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px 14px;
margin-bottom: 16px;
}
.an-grid .an-card { margin-bottom: 0; }
.an-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.an-card-head h2 {
margin: 0;
font-family: var(--font-display);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--accent);
}
.an-note { font-size: 0.72rem; color: var(--text-dim); }
.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
/* ----- Day columns ----- */
.an-plot { position: relative; height: 170px; margin-top: 12px; }
.an-gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--border);
pointer-events: none;
}
.an-gridline span {
position: absolute;
right: 0;
top: -0.62em;
font-size: 0.68rem;
color: var(--text-dim);
background: var(--bg-panel);
padding: 0 4px;
/* The dashed rule belongs behind the bars; its value does not. Columns are
positioned, so without this the tallest bar swallows the label. */
z-index: 3;
}
.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
.an-column {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
border-radius: 4px 4px 0 0;
outline: none;
}
.an-column.hot { background: rgba(212, 169, 78, 0.07); }
.an-stack {
width: 100%;
/* Capped so a 7-day range draws bars and not slabs; the column around it
stays full width, so the hover target does not shrink with the mark. */
max-width: 44px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* A 2px gap of surface between stacked segments: the boundary reads as a
boundary without a border, which would eat a thin bar entirely. */
gap: 2px;
}
.an-bar { width: 100%; transition: filter 0.15s; }
.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
.an-column.hot .an-bar { filter: brightness(1.2); }
.an-tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 8px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
font-size: 0.74rem;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.an-tip strong { font-weight: 600; color: var(--accent-bright); }
.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
.an-axis { display: flex; gap: 2px; margin-top: 7px; }
.an-axis span {
flex: 1 1 0;
min-width: 0;
font-size: 0.68rem;
color: var(--text-dim);
text-align: center;
white-space: nowrap;
}
.an-axis span:first-child { text-align: left; }
.an-axis span:last-child { text-align: right; }
.an-overlay-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-style: italic;
font-size: 0.85rem;
pointer-events: none;
}
/* ----- Funnel ----- */
.an-funnel { display: flex; flex-direction: column; gap: 10px; }
.an-funnel-row {
display: grid;
grid-template-columns: 168px 1fr 96px;
align-items: center;
gap: 12px;
}
.an-funnel-label { font-size: 0.84rem; }
.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
.an-funnel-bar {
height: 100%;
min-width: 2px;
background: var(--chart-1);
border-radius: 7px;
transition: width 0.4s ease;
}
.an-funnel-value {
text-align: right;
font-size: 0.86rem;
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 8px;
}
.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
/* ----- Ranked lists ----- */
.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.an-list li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border-radius: 6px;
font-size: 0.84rem;
}
/* The bar sits behind the row rather than beside it, so a long label keeps the
full width and the magnitude is still visible at a glance. */
.an-list-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(181, 138, 48, 0.2);
border-radius: 6px;
}
.an-list-label {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-list-value { position: relative; color: var(--text-dim); flex: none; }
/* ----- Tabs, and the access log table ----- */
.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.an-tabs button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.82rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.an-tabs button:hover { color: var(--text); }
.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.an-log-head { flex-wrap: wrap; row-gap: 10px; }
.an-log-search { max-width: 260px; }
/* The table is the one thing here with a minimum width — six columns of real
data don't compress. It scrolls inside its own box so the page never does. */
.an-table-scroll { overflow-x: auto; }
.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
.an-table th {
text-align: left;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 0 10px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an-table tr:last-child td { border-bottom: none; }
.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
/* A failed attempt is the row you are scanning for; it gets the only colour in
the table, on a border rather than the text, which stays readable. */
.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
.an-table tr.failed td:nth-child(3) { color: var(--danger); }
.an-cell-dim { color: var(--text-dim); }
.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
.an-tag {
margin-left: 8px;
padding: 1px 6px;
border: 1px solid var(--border-bright);
border-radius: 10px;
font-size: 0.66rem;
color: var(--text-dim);
}
.an-more { display: flex; justify-content: center; padding-top: 14px; }
.an-footnote {
color: var(--text-dim);
font-size: 0.74rem;
line-height: 1.6;
margin: 22px 0 0;
max-width: 70ch;
}
-145
View File
@@ -1,145 +0,0 @@
/* ---------- Auth (log in / sign up) ---------- */
.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; }
.auth-crest {
font-size: 1.1rem;
color: var(--accent-dim);
letter-spacing: 0.3em;
margin-bottom: 6px;
}
.auth-modal h2 { font-size: 1.35rem; }
.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; }
/* Fields go back to left-aligned — only the header is centred. */
.auth-modal label.field { text-align: left; }
.auth-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
}
.auth-tab {
flex: 1;
padding: 7px 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); }
.auth-tab.active {
background: var(--bg-panel);
border-color: var(--accent-dim);
color: var(--accent-bright);
}
.auth-password { display: flex; gap: 8px; }
.auth-password input { flex: 1; min-width: 0; }
.auth-reveal {
flex: none;
padding: 0 12px;
font-size: 0.78rem;
color: var(--text-dim);
}
.auth-reveal:hover { color: var(--accent-bright); }
.auth-help {
display: block;
margin-top: 6px;
font-size: 0.76rem;
color: var(--text-dim);
}
.auth-error {
margin-top: 4px;
padding: 9px 12px;
border: 1px solid rgba(208, 101, 101, 0.45);
border-radius: 8px;
background: rgba(208, 101, 101, 0.09);
color: var(--danger);
font-size: 0.85rem;
text-align: left;
white-space: pre-wrap;
}
/* "Update from scenario" — a wider modal, since it lists a diff. */
.refresh-modal { width: min(620px, calc(100vw - 40px)); }
.refresh-changes {
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
font-size: 0.86rem;
}
.refresh-change + .refresh-change {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; }
.refresh-old { text-decoration: line-through; }
.refresh-new { color: var(--accent-bright); }
/* The button sits above the plot fields it would overwrite. */
.plot-source {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.plot-source .linklike { flex-shrink: 0; }
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
-25
View File
@@ -1,25 +0,0 @@
/* ---------- Play screen error/success banner ----------
Persistent (no auto-dismiss) and can carry a Retry action, so it stays
separate from the transient global toasts further down this file. */
.play-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--danger);
color: var(--text);
padding: 11px 20px;
border-radius: 10px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both;
}
.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
-97
View File
@@ -1,97 +0,0 @@
/* ---------- AI Chat (power users) ----------
A plain chat scratchpad, styled as a transcript rather than as the story
page: UI font, role labels, and a sticky composer at the bottom. */
.chat-page {
max-width: 820px;
display: flex;
flex-direction: column;
margin-bottom: var(--kb-inset, 0px); /* see .play-page */
}
.chat-meta {
font-size: 0.78rem;
margin: -10px 0 16px;
word-break: break-word;
}
.chat-options {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px 4px;
margin-bottom: 18px;
background: var(--bg-panel);
}
.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; }
.chat-msg {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px 12px;
background: var(--bg-panel);
}
.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); }
.chat-msg.assistant { border-left: 3px solid var(--accent-dim); }
.chat-msg-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; }
.chat-msg.user .chat-role { color: var(--player); }
.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; }
.chat-msg-actions {
margin-left: auto;
display: flex;
gap: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; }
.chat-msg-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.6;
font-size: 0.95rem;
}
.chat-msg .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
animation: blink 1s steps(1) infinite;
}
/* Same collapsed thinking block as the story page, re-scoped for the transcript. */
.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); }
.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; }
.chat-msg .reasoning summary:hover { opacity: 1; }
.chat-msg .reasoning .reasoning-text {
margin-top: 6px;
padding: 8px 12px;
border-left: 2px solid var(--border);
white-space: pre-wrap;
opacity: 0.85;
max-height: 260px;
overflow-y: auto;
}
.chat-composer {
position: sticky;
bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */
display: flex;
gap: 10px;
align-items: flex-end;
padding: 14px 0;
background: linear-gradient(to top, var(--bg) 55%, transparent);
}
.chat-composer textarea {
flex: 1;
min-height: 44px;
max-height: 220px;
resize: none;
overflow-y: auto;
}
.chat-composer button { flex: none; height: 40px; }
+168
View File
@@ -0,0 +1,168 @@
/* The context inspector, and the knowledge library's list.
*
* §55's rule shapes both: readable summaries first, raw text last and folded
* away. Everything here is UI-font and small — this is the layer under the
* story, and it should not compete with it.
*/
.context-panel { display: flex; flex-direction: column; gap: 14px; font-size: 0.82rem; }
.ctx-mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--text-dim);
font-size: 0.78rem;
}
.ctx-mode button { padding: 3px 9px; font-size: 0.72rem; }
.ctx-tokens {
padding: 11px 13px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
}
.ctx-tokens.over { border-color: var(--danger); }
.ctx-token-line {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 2px 0;
font-variant-numeric: tabular-nums;
}
.ctx-warn { margin: 8px 0 0; color: var(--danger); font-size: 0.76rem; }
.token-bar {
display: flex;
height: 6px;
margin-top: 9px;
overflow: hidden;
border-radius: 3px;
background: var(--bg);
}
.token-bar-slice { height: 100%; }
.slice-0 { background: var(--accent); }
.slice-1 { background: var(--player); }
.slice-2 { background: #b39ddb; }
.slice-3 { background: var(--accent-dim); }
.slice-4 { background: #6f9e8c; }
.slice-5 { background: #c48a6a; }
.slice-6 { background: #7c86b8; }
.slice-7 { background: var(--border-bright); }
.ctx-block {
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
}
.ctx-block > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
cursor: pointer;
list-style: none;
}
.ctx-block > summary::-webkit-details-marker { display: none; }
.ctx-block > summary::before {
content: '▸';
margin-right: 7px;
color: var(--text-dim);
font-size: 0.7rem;
}
.ctx-block[open] > summary::before { content: '▾'; }
.ctx-block-title { flex: 1; font-weight: 600; }
.ctx-block-count {
padding: 0 7px;
font-size: 0.7rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.ctx-block-body { padding: 0 12px 12px; }
.ctx-block-body > p:first-child { margin-top: 0; }
.ctx-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.ctx-row {
padding: 9px 11px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
}
.ctx-row-head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; margin-bottom: 4px; }
.ctx-row-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 0.72rem;
color: var(--text-dim);
}
.ctx-row-text, .ctx-row-text-plain {
margin: 7px 0 0;
padding: 8px 10px;
max-height: 16em;
overflow: auto;
font-family: var(--font-story);
font-size: 0.92rem;
line-height: 1.55;
white-space: pre-wrap;
background: var(--bg);
border-radius: 5px;
}
.ctx-row-withheld {
margin: 7px 0 0;
padding: 8px 10px;
font-style: italic;
color: var(--text-dim);
background: var(--bg);
border: 1px dashed var(--border-bright);
border-radius: 5px;
}
.ctx-source-link { font-weight: 600; }
.ctx-foot { margin: 9px 0 0; font-size: 0.74rem; }
.ctx-note { margin: 9px 0 0; font-size: 0.76rem; color: var(--accent-dim); line-height: 1.55; }
/* §19. Deliberately not styled as a quiet checkbox: it reveals story secrets,
and it should look like it means something. */
.reveal-toggle {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 11px;
padding: 8px 10px;
font-size: 0.76rem;
line-height: 1.5;
color: var(--text-dim);
background: var(--bg-input);
border: 1px dashed var(--accent-dim);
border-radius: 6px;
cursor: pointer;
}
.reveal-toggle input { margin-top: 2px; }
.ctx-settings { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; }
.ctx-settings dt { color: var(--text-dim); }
.ctx-settings dd { margin: 0; font-variant-numeric: tabular-nums; }
.ctx-raw-section { margin-top: 10px; }
.ctx-raw-head {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 5px 0;
font-size: 0.72rem;
color: var(--accent-dim);
border-bottom: 1px solid var(--border);
}
.ctx-raw-section pre {
margin: 6px 0 0;
padding: 9px 11px;
max-height: 22em;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
white-space: pre-wrap;
background: var(--bg-input);
border-radius: 5px;
}
+56
View File
@@ -0,0 +1,56 @@
/* The settings screen's request log. What remains of `auth.css`, whose
log-in, sign-up and "update from scenario" rules went with the surfaces
they styled (M8 §26). */
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
+71
View File
@@ -0,0 +1,71 @@
/* Modal dialogs, and the class tags shared by the knowledge and context panels. */
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(4, 4, 8, 0.72);
backdrop-filter: blur(3px);
animation: dialog-fade 0.15s ease both;
}
@keyframes dialog-fade { from { opacity: 0; } }
.dialog {
width: min(460px, 100%);
max-height: 86vh;
overflow-y: auto;
padding: 22px 24px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 11px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.dialog:focus { outline: none; }
.dialog-title { margin: 0 0 12px; font-size: 1.05rem; }
.dialog-body { font-size: 0.87rem; line-height: 1.65; color: var(--text-dim); }
.dialog-body p { margin: 0 0 10px; }
.dialog-body p:last-child { margin-bottom: 0; }
.dialog-field { display: flex; flex-direction: column; gap: 5px; margin-top: 16px; }
.dialog-field span { font-size: 0.76rem; color: var(--text-dim); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.external-href {
padding: 8px 10px;
overflow-wrap: anywhere;
background: var(--bg-input);
border-radius: 5px;
}
/* ---------- Classification tags ---------- *
* One definition, used by the knowledge library and the context inspector, so
* a class looks the same wherever a reader meets it (§48). */
.class-tag {
display: inline-block;
padding: 1px 8px;
font-family: var(--font-ui);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 10px;
border: 1px solid currentColor;
}
.class-canon { color: var(--accent-bright); }
.class-reference { color: var(--player); }
.class-inspiration { color: #b39ddb; }
.hidden-tag {
display: inline-block;
padding: 1px 8px;
font-family: var(--font-ui);
font-size: 0.66rem;
letter-spacing: 0.04em;
color: var(--bg);
background: var(--accent-dim);
border-radius: 10px;
}
-266
View File
@@ -1,266 +0,0 @@
/* ---------- Left status drawer (script state) ---------- */
.status-drawer {
flex-shrink: 0;
position: sticky;
top: 56px;
height: calc(100dvh - 56px);
display: flex;
align-items: flex-start;
border-right: 1px solid var(--border);
background: var(--bg-panel);
}
.status-drawer.open { width: 300px; }
.status-toggle {
flex-shrink: 0;
height: 100%;
width: 30px;
padding: 12px 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.status-toggle:hover { color: var(--text); background: var(--bg-input); }
.status-toggle-label {
writing-mode: vertical-rl;
text-orientation: mixed;
letter-spacing: 0.12em;
font-size: 0.72rem;
text-transform: uppercase;
}
.status-body {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
padding: 20px 16px;
animation: panel-in 0.2s ease both;
}
.status-vars { list-style: none; margin: 0; padding: 0; }
.status-vars li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.status-key {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
color: var(--accent-bright);
word-break: break-word;
}
.status-val {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
/* Recursive script-state value tree */
.jv-string { color: var(--text); }
.jv-number { color: var(--player); }
.jv-bool { color: var(--accent-bright); }
.jv-null { color: var(--text-dim); font-style: italic; }
.jv-empty { color: var(--text-dim); }
.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; }
.jv-toggle {
background: none; border: none; padding: 0; cursor: pointer;
font-family: inherit; font-size: 0.72rem; color: var(--text-dim);
}
.jv-toggle:hover { color: var(--accent-bright); }
.jv-children {
list-style: none; margin: 2px 0 2px 0; padding-left: 12px;
border-left: 1px solid var(--border);
}
.jv-children li { padding: 2px 0; word-break: break-word; }
.jv-key { color: var(--accent); margin-right: 6px; }
.jv-key::after { content: ':'; color: var(--text-dim); }
@keyframes panel-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
/* ---------- World State drawer (Phase 12 RPG) ---------- */
.ws-group { margin-bottom: 18px; }
.ws-group-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin: 0 0 8px;
}
.ws-stat { padding: 5px 0; }
.ws-stat-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 0.85rem;
}
.ws-stat-name { color: var(--text); text-transform: capitalize; }
.ws-stat-val {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
text-align: right;
}
.ws-band { color: var(--accent-bright); }
.ws-bar {
margin-top: 4px;
height: 5px;
border-radius: 3px;
background: var(--bg-input);
overflow: hidden;
}
.ws-bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.35s ease;
}
/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
five doesn't read as five more unrelated sections in the rail. */
.ws-npc {
padding: 7px 9px;
margin-bottom: 7px;
border: 1px solid var(--border);
border-left: 2px solid var(--accent-dim);
border-radius: 7px;
background: rgba(255, 255, 255, 0.022);
}
.ws-npc:last-child { margin-bottom: 0; }
.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
.ws-npc-avatar {
flex-shrink: 0;
width: 22px; height: 22px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.6rem;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.ws-npc-name {
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
.ws-milestones { list-style: none; margin: 0; padding: 0; }
.ws-milestones li {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-milestones li.done { color: var(--text-dim); }
.ws-check { flex-shrink: 0; color: var(--accent-bright); }
.ws-milestones li.done .ws-check { color: var(--player); }
.ws-flags { list-style: none; margin: 0; padding: 0; }
.ws-flags li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-flag-dot {
width: 9px; height: 9px; border-radius: 50%;
flex-shrink: 0;
background: var(--bg-input);
border: 1px solid var(--border);
}
.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
.ws-flag-name { flex: 1; }
.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; }
.ws-edit-actions { display: flex; gap: 6px; }
.ws-edit-input {
width: 100%;
margin-top: 3px;
padding: 4px 7px;
font-size: 0.82rem;
border-radius: 6px;
}
.schema-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.5;
tab-size: 2;
white-space: pre;
overflow-x: auto;
}
.schema-error {
margin-top: 6px;
color: var(--danger, #e5484d);
font-size: 0.82rem;
}
.schema-json-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.schema-json-actions .schema-error { margin-top: 0; }
.schema-preview {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.schema-preview-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin-bottom: 10px;
}
.schema-group { margin-bottom: 16px; }
.schema-group:last-child { margin-bottom: 0; }
.schema-group-title {
font-family: Georgia, serif;
font-size: 0.98rem;
color: var(--accent-bright);
margin-bottom: 6px;
}
.schema-npc-id {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
color: var(--text-dim);
margin-left: 6px;
}
.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); }
.schema-stat:last-child { border-bottom: none; }
.schema-stat-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.schema-stat-name { color: var(--text); text-transform: capitalize; }
.schema-stat-meta {
color: var(--text-dim);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
+265
View File
@@ -0,0 +1,265 @@
/* The campaign library, the setup form, and the model status badge.
*
* Everything above a campaign. Deliberately plain: this screen is passed
* through on the way to a story, and the thing a returning reader is looking
* for is the title of the story they were in.
*/
.page {
width: min(980px, 100%);
margin: 0 auto;
padding: 30px 24px 80px;
}
.page.setup, .page.settings { width: min(720px, 100%); }
/* ---------- Library ---------- */
.library-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.library-head h1 { margin: 0 0 4px; font-size: 1.5rem; }
.library-sub { margin: 0; color: var(--text-dim); font-size: 0.86rem; }
.library-actions { display: flex; gap: 10px; }
.library-empty {
max-width: 46ch;
margin: 40px auto;
text-align: center;
color: var(--text-dim);
}
.library-empty h2 { margin: 0 0 10px; color: var(--text); font-size: 1.15rem; }
.library-empty p { line-height: 1.65; margin-bottom: 20px; }
.campaign-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.campaign-card {
display: flex;
flex-direction: column;
gap: 7px;
padding: 16px 18px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 9px;
transition: border-color 0.15s;
}
.campaign-card:hover { border-color: var(--border-bright); }
.campaign-title { margin: 0; font-size: 1.05rem; font-weight: 600; }
.campaign-title a { color: var(--text); }
.campaign-title a:hover { color: var(--accent-bright); }
.campaign-meta {
margin: 0;
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.campaign-snippet {
flex: 1;
margin: 4px 0 8px;
font-family: var(--font-story);
font-size: 0.94rem;
line-height: 1.55;
color: var(--text-dim);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.campaign-snippet.muted { font-style: italic; opacity: 0.7; }
.campaign-tools { display: flex; gap: 6px; flex-wrap: wrap; }
.campaign-tools .button,
.campaign-tools button {
padding: 4px 11px;
font-size: 0.74rem;
}
/* ---------- Setup ---------- */
.setup h1 { margin: 0 0 6px; font-size: 1.5rem; }
.setup-lede { margin: 0 0 26px; color: var(--text-dim); font-size: 0.9rem; line-height: 1.6; }
.setup-section { margin-bottom: 28px; }
.setup-section h2 {
margin: 0 0 12px;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent-dim);
}
.setup-actions { display: flex; gap: 10px; margin-top: 8px; }
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.field > label {
font-size: 0.78rem;
color: var(--text-dim);
}
.field-row { display: flex; gap: 14px; flex-wrap: wrap; }
.field-row > .field { flex: 1; min-width: 190px; }
.field-hint { margin: 2px 0 0; font-size: 0.75rem; line-height: 1.55; color: var(--text-dim); }
/* ---------- Model status ---------- */
.model-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 11px;
font-size: 0.73rem;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 14px;
white-space: nowrap;
}
.model-badge:hover { border-color: var(--border-bright); color: var(--text); }
.model-badge-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
.model-badge.bad { color: var(--danger); border-color: var(--danger); }
.model-badge.bad .model-badge-dot { background: var(--danger); }
.model-badge.checking .model-badge-dot { background: var(--text-dim); }
.model-badge-model { color: var(--text-dim); opacity: 0.8; }
.notice {
margin: 0 0 20px;
padding: 14px 16px;
font-size: 0.85rem;
line-height: 1.6;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
border-radius: 7px;
}
.notice.error { border-left-color: var(--danger); }
.notice strong { display: block; margin-bottom: 6px; }
.notice p { margin: 0 0 8px; color: var(--text-dim); }
.notice-detail { font-size: 0.8rem; color: var(--text-dim); }
.notice-detail.error { color: var(--danger); }
.notice-steps { margin: 8px 0; padding-left: 1.3em; color: var(--text-dim); }
.notice-steps li { margin-bottom: 5px; }
.notice-foot { margin: 10px 0 0 !important; }
.notice code {
padding: 1px 5px;
font-size: 0.86em;
background: var(--bg-input);
border-radius: 3px;
}
.model-choices { list-style: none; display: flex; flex-wrap: wrap; gap: 7px; margin: 10px 0; padding: 0; }
.model-choices button {
padding: 5px 12px;
font-size: 0.8rem;
font-family: var(--font-ui);
background: var(--bg-input);
border: 1px solid var(--border-bright);
border-radius: 5px;
}
.model-choices button:hover { border-color: var(--accent); color: var(--accent-bright); }
.local-only {
margin-bottom: 24px;
padding: 14px 16px;
font-size: 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 7px;
}
.local-only strong { display: block; margin-bottom: 6px; color: var(--accent-bright); }
.local-only p { margin: 0; color: var(--text-dim); line-height: 1.6; }
.settings-section { margin-bottom: 34px; }
.settings-section h2 {
margin: 0 0 6px;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent-dim);
}
.panel-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.test-result { margin-top: 12px; font-size: 0.83rem; }
.test-result.ok { color: var(--accent); }
.test-result.bad { color: var(--danger); }
.advanced-block {
margin-top: 14px;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 7px;
}
.advanced-block > summary {
cursor: pointer;
font-size: 0.8rem;
color: var(--text-dim);
}
.advanced-block > summary:hover { color: var(--text); }
.advanced-block h4 { margin: 10px 0 4px; font-size: 0.74rem; color: var(--text-dim); }
.advanced-block pre {
max-height: 16em;
overflow: auto;
padding: 8px 10px;
font-size: 0.74rem;
background: var(--bg-input);
border-radius: 5px;
}
/* ---------- Utility ---------- */
.sr-only {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Visible the moment it is focused, which is the whole point of it. */
.skip-link {
position: absolute;
left: -9999px;
z-index: 100;
padding: 10px 16px;
background: var(--bg-panel);
border: 1px solid var(--accent);
border-radius: 0 0 6px 0;
}
.skip-link:focus { left: 0; top: 0; }
.dim { color: var(--text-dim); }
.linklike {
padding: 0;
color: var(--accent);
background: none;
border: none;
font: inherit;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.linklike:hover { color: var(--accent-bright); }
/* A setting whose change is worth thinking about before it is made. Amber
rather than red: nothing is destroyed, but the reader should know what the
change does and does not reach. */
.field-warning {
margin: 8px 0 0;
padding: 9px 11px;
font-size: 0.78rem;
line-height: 1.6;
color: var(--text-dim);
background: var(--bg-input);
border-left: 2px solid var(--accent-dim);
border-radius: 0 5px 5px 0;
}
.field-warning strong { color: var(--accent-bright); }
+12 -184
View File
@@ -1,36 +1,8 @@
/* ---------- 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. */
@@ -56,23 +28,12 @@
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);
@@ -104,27 +65,6 @@
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.
@@ -140,7 +80,7 @@
opacity: 0.45;
transition: opacity 0.15s;
}
.story .action:hover .take-pager,
.turn:hover .take-pager,
.take-pager:focus-within { opacity: 1; }
.take-pager button {
width: 20px;
@@ -174,92 +114,17 @@
color: var(--accent-dim);
}
/* ---------- Branches panel (Phase 14, SP7) ---------- */
/* ---------- Shared panel furniture ---------- */
.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 Points (M4). A Save Point is a name for a position in the story, and
the confirmations are shaped so that restoring does not read as dangerous —
it destroys nothing. 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 {
@@ -366,49 +231,12 @@
}
.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); }
/* The composer, the mode selector and the sticky control bar that used to
live here are gone: M8 replaced them with one input plus a direction
toggle, styled in `story.css`. The `.input-bar { display: flex }` left
behind here overrode the new layout, because this sheet is imported after
that one — which is exactly the hazard the import order comment warns of. */
.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
-249
View File
@@ -1,249 +0,0 @@
/* ---- Form-based schema editor ----
One shape for everything: a section is a panel, every entry inside it is a
`.se-row` tile, and every control wears a `.se-field` caption. Adding a new
kind of entry means reusing those three, not inventing a fourth box. */
.se-section {
margin-bottom: 16px;
padding: 13px 15px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
}
.se-section-nested {
margin: 10px 0 0;
padding: 11px 12px;
background: rgba(0, 0, 0, 0.22);
border-radius: 8px;
}
.se-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.se-section-title {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
.se-hint {
margin: -2px 0 10px;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-dim);
}
.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
.se-add, .se-add-sm {
font-size: 0.78rem;
padding: 3px 10px;
white-space: nowrap;
}
.se-add-sm { margin-top: 6px; }
.se-remove {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px 6px;
font-size: 0.9rem;
line-height: 1;
flex-shrink: 0;
}
.se-remove:hover { color: var(--danger, #e5484d); }
/* Every control in the editor, one look. The global field rule keys off
`input[type="…"]`, so the plain `<input>`s here (keys, descriptions) never
matched it and fell back to browser defaults — that mismatch is most of why
this panel used to read as a pile of unrelated boxes. Everything below is
qualified with `.se` so it outranks that global rule (0,1,1) too. */
/* `:where()` so the exclusion costs no specificity — a plain
`:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
below and swallow every one of them. */
.se input:not(:where([type="checkbox"])), .se textarea, .se select {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font-size: 0.84rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
/* One entry — a stat, a flag, a milestone. */
.se-row {
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.018);
transition: border-color 0.15s;
}
.se-row:last-child { margin-bottom: 0; }
.se-row:focus-within { border-color: var(--border-bright); }
/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
an input's default `min-width: auto` would push the ✕ off-screen instead. */
.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
.se .se-key {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
min-width: 0;
max-width: 220px;
}
/* Only in the row context — `.se-field` is a *column* flex, where a basis of
140px would set the input's height instead of its width. */
.se .se-row-top .se-key { flex: 1 1 140px; }
.se-field { display: flex; flex-direction: column; gap: 3px; }
.se-field > span {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-dim);
}
.se-fields {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: flex-end;
margin-bottom: 8px;
}
.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
.se .se-kind select { width: 132px; }
.se .se-initial-text input { width: 220px; max-width: 100%; }
.se-check {
display: flex; align-items: center; gap: 5px;
font-size: 0.8rem; color: var(--text-dim);
white-space: nowrap;
}
.se-check input { width: auto; }
/* The global textarea rule also forces min-height:70px, which a 2-row inline
field doesn't want. */
.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
as a bare class it lost, and the band bounds rendered full-width. */
.se .se-band-n { width: 64px; padding: 4px 6px; }
.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
.se-bands { margin-top: 8px; }
/* ---- NPC roster (its own page section) ---- */
.se-npc-grid {
display: grid;
/* min() so a 320px phone gets one column instead of a 340px overflow */
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 14px;
align-items: start;
}
.se-npc {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
overflow: hidden;
}
.se-npc:focus-within { border-color: var(--border-bright); }
.se-npc-head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
}
.se-avatar {
flex-shrink: 0;
width: 34px; height: 34px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.se-npc-ident { flex: 1; min-width: 0; }
.se .se-npc-name {
padding: 2px 6px;
font-family: var(--font-display);
font-size: 0.95rem;
background: transparent;
border-color: transparent;
}
.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
.se-npc-addr {
display: block;
margin: 1px 0 0 6px;
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-dim);
}
.se-npc-count {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
}
.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.se-npc-idfield .se-key { max-width: 100%; }
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
.ws-report li { padding: 2px 0; color: var(--text); }
/* Inline per-turn change chips under an AI message */
.turn-changes {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chg {
font-size: 0.74rem;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-dim);
font-variant-numeric: tabular-nums;
/* A chip must never be wider than the column it sits in: an NPC stat like
"bandit leader aggression +10" overruns a phone, and the chip row is
inside the story text, which has nothing to scroll. Text may break
mid-word as a last resort, which is what keeps a chip's minimum width
small enough for the flex row to shrink it. */
max-width: 100%;
overflow-wrap: anywhere;
}
.chg-val { overflow-wrap: anywhere; }
/* Only a signed number is held on one line, so the sign never ends up on a
line of its own. The other values a chip can carry are phrases — a refusal
reason, "no change — at its limit", or the whole new value of a free-text
stat — and holding those together set the chip's minimum width to the width
of the phrase, which is what pushed a chip out of the message on a phone. */
.chg-num { white-space: nowrap; }
.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); }
.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); }
.chg-flag { color: var(--accent-bright); }
.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
/* A refused change, and a clamp that left the value where it started. The
dashed border separates it from the changes that took effect. It is not
styled as an error, because the rules refusing a change is them working. */
.chg-refused {
border-style: dashed;
color: var(--text-dim);
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
}
/* A clamp that reduced a change but still moved the value. */
.chg-limited { opacity: 0.75; font-style: italic; }
.ws-report code { color: var(--accent-bright); }
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
.ws-flag { color: var(--text-dim); font-style: italic; }
.ws-rejected { color: var(--text-dim); }
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.side-panel-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
color: var(--accent);
}
+406
View File
@@ -0,0 +1,406 @@
/* The story screen: layout, transcript, composer.
*
* `BROWSER-UX-SPEC.md` §82-84 and §98. Three rules do most of the work here:
*
* §82 prose gets a readable measure and does not stretch across a wide
* monitor, whatever the window is doing;
* §83 no card chrome around every message — the story reads as prose, and a
* player turn is set apart by indentation and colour rather than a box;
* §84 no metadata on screen, so the controls on a turn stay invisible until
* the pointer or the keyboard reaches that turn.
*
* The transcript scrolls the page rather than an inner element. That keeps the
* browser's own find-in-page, scroll restoration and keyboard paging working,
* which an inner scroller quietly breaks.
*/
/* ---------- Layout ---------- */
.play {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.play-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 20px;
/* Opaque, not glass. The first browser pass had story text reading straight
through a translucent bar; a header the story shows through is not a
header. */
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.play-title { display: flex; align-items: baseline; gap: 14px; min-width: 0; flex: 1; }
.play-title h1 {
margin: 0;
font-size: 1.02rem;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.play-title .back {
font-size: 0.78rem;
color: var(--text-dim);
white-space: nowrap;
}
.play-title .back:hover { color: var(--accent); }
.play-header-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.panel-tabs { display: flex; gap: 2px; }
.panel-tabs button {
padding: 5px 11px;
font-size: 0.76rem;
color: var(--text-dim);
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
}
.panel-tabs button:hover { color: var(--text); border-color: var(--border); }
.panel-tabs button.active {
color: var(--accent-bright);
border-color: var(--border-bright);
background: var(--bg-input);
}
.play-main { display: flex; flex: 1; align-items: flex-start; min-height: 0; }
.play-story {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
/* Fills the viewport below the header, so the composer sits at the bottom of
a short story rather than immediately under its last line. */
min-height: calc(100vh - 52px);
}
/* ---------- Transcript ---------- */
.story {
/* §82. The measure is the point: prose is read, not scanned, and 66ch is
about the width a line can be before the eye loses the next one. */
width: min(66ch, 100%);
margin: 0 auto;
/* The bottom padding clears the sticky composer. Enough to scroll the last
line clear of it, not so much that a short story opens on a void. */
padding: 36px 24px 40px;
flex: 1;
font-family: var(--font-story);
font-size: 1.14rem;
line-height: 1.72;
}
.story-empty { color: var(--text-dim); font-style: italic; }
.turn {
position: relative;
margin: 0 0 1.35em;
animation: turn-in 0.35s ease both;
}
@keyframes turn-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; } }
.turn-text > *:first-child { margin-top: 0; }
.turn-text > *:last-child { margin-bottom: 0; }
.turn-text p { margin: 0 0 0.75em; }
/* §83. A player turn is the reader's own voice: indented, lighter, in the
cooler of the two accents. No box, no avatar, no timestamp. */
.turn-player .turn-text {
padding-left: 1.1em;
border-left: 2px solid var(--player);
color: var(--player);
font-style: italic;
}
.turn-narrator .turn-text { color: var(--text); }
/* The opening beat gets the drop cap, and only that one. */
.turn-opening .turn-text > p:first-of-type::first-letter {
float: left;
font-family: var(--font-display);
font-size: 3.1em;
line-height: 0.82;
padding: 0.06em 0.09em 0 0;
color: var(--accent);
}
.scene-break {
margin: 1.8em 0;
text-align: center;
color: var(--border-bright);
font-size: 0.8rem;
letter-spacing: 0.5em;
}
.story .cursor {
display: inline-block;
margin-left: 1px;
color: var(--accent);
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.story .reasoning {
margin-bottom: 0.5em;
font-family: var(--font-ui);
font-size: 0.78rem;
}
.story .reasoning summary {
cursor: pointer;
color: var(--text-dim);
opacity: 0.7;
}
.story .reasoning summary:hover { opacity: 1; }
.story .reasoning .reasoning-text {
margin-top: 5px;
padding-left: 10px;
border-left: 1px solid var(--border);
color: var(--text-dim);
white-space: pre-wrap;
}
/* §84. Controls stay out of the way until the turn is reached — but they are
real buttons in the tab order, so `:focus-within` has to reveal them or a
keyboard reader would be operating something they cannot see. */
.turn-tools {
display: flex;
gap: 4px;
margin-top: 6px;
font-family: var(--font-ui);
opacity: 0;
transition: opacity 0.15s;
}
.turn:hover .turn-tools,
.turn-tools:focus-within { opacity: 1; }
.turn-tools button {
padding: 2px 8px;
font-size: 0.71rem;
color: var(--text-dim);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
}
.turn-tools button:hover { color: var(--text); border-color: var(--border-bright); }
.turn-edit { margin: 0 0 1.35em; }
.turn-edit textarea {
width: 100%;
font-family: var(--font-story);
font-size: 1.1rem;
line-height: 1.6;
}
.edit-buttons { display: flex; gap: 8px; margin-top: 8px; }
.edit-hint {
margin: 8px 0 0;
font-family: var(--font-ui);
font-size: 0.76rem;
color: var(--text-dim);
}
.story-earlier { margin-bottom: 1.6em; text-align: center; font-family: var(--font-ui); }
.story-earlier button {
padding: 4px 14px;
font-size: 0.76rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 14px;
}
.story-earlier button:hover { color: var(--accent-bright); border-color: var(--border-bright); }
.thinking {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-ui);
font-size: 0.82rem;
color: var(--text-dim);
}
.thinking i {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s ease-in-out infinite;
}
.thinking i:nth-child(2) { animation-delay: 0.15s; }
.thinking i:nth-child(3) { animation-delay: 0.3s; }
@keyframes pulse { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
/* ---------- Markdown in story prose ---------- */
.md-heading { font-family: var(--font-display); margin: 1.1em 0 0.4em; }
.md-list { margin: 0 0 0.75em; padding-left: 1.4em; }
.md-quote {
margin: 0 0 0.75em;
padding-left: 0.9em;
border-left: 2px solid var(--border-bright);
color: var(--text-dim);
}
.md-code {
margin: 0 0 0.75em;
padding: 10px 12px;
overflow-x: auto;
font-size: 0.85rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 5px;
}
.turn-text code {
padding: 1px 4px;
font-size: 0.88em;
background: var(--bg-input);
border-radius: 3px;
}
.md-link { text-decoration: underline dotted; text-underline-offset: 3px; }
/* §75: the address is shown, and nothing is fetched. */
.md-image-blocked,
.md-link-blocked {
display: inline-block;
padding: 1px 7px;
font-family: var(--font-ui);
font-size: 0.76rem;
color: var(--text-dim);
background: var(--bg-input);
border: 1px dashed var(--border-bright);
border-radius: 4px;
}
/* ---------- Composer ---------- */
.composer {
position: sticky;
bottom: 0;
z-index: 15;
width: min(66ch, 100%);
margin: 0 auto;
padding: 10px 24px calc(16px + env(keyboard-inset-height, 0px));
background: linear-gradient(to top, var(--bg) 62%, transparent);
}
.story-controls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.story-controls button {
padding: 5px 13px;
font-size: 0.78rem;
color: var(--text-dim);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 5px;
}
.story-controls button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.story-controls button:disabled { opacity: 0.35; cursor: default; }
.input-bar {
border: 1px solid var(--border-bright);
border-radius: 9px;
background: var(--bg-panel);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input-bar:focus-within { border-color: var(--accent-dim); box-shadow: 0 0 0 3px var(--accent-glow); }
/* §13: while direction is on, the box must not look like the story box. */
.input-bar.directing { border-color: var(--accent); background: rgba(212, 169, 78, 0.06); }
.input-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px 0;
font-family: var(--font-ui);
font-size: 0.74rem;
}
.direction-toggle { display: flex; align-items: center; gap: 6px; color: var(--text-dim); cursor: pointer; }
.direction-toggle input { margin: 0; }
.input-bar.directing .direction-toggle { color: var(--accent-bright); }
.direction-hint { color: var(--text-dim); font-style: italic; }
.input-main { display: flex; align-items: flex-end; gap: 8px; padding: 4px 10px 10px; }
.input-main textarea {
flex: 1;
/* forms.css gives every textarea a 70px floor, which is right for a form
field and wrong for a composer that starts at one line and grows with what
is typed. */
min-height: 0;
max-height: 9em;
padding: 6px 4px;
font-family: var(--font-story);
font-size: 1.05rem;
line-height: 1.55;
color: var(--text);
background: transparent;
border: none;
resize: none;
}
.input-main textarea:focus { outline: none; }
/* Reserved for a later version. Disabled, and visibly so. */
.dictate-reserved {
padding: 5px 9px;
font-size: 0.9rem;
color: var(--text-dim);
background: transparent;
border: 1px dashed var(--border);
border-radius: 6px;
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Failure ---------- */
.failure {
width: min(66ch, 100%);
margin: 0 auto 12px;
padding: 12px 16px;
font-family: var(--font-ui);
font-size: 0.84rem;
background: var(--bg-panel);
border: 1px solid var(--danger);
border-left-width: 3px;
border-radius: 7px;
}
.failure-knowledge, .failure-state { border-color: var(--accent-dim); }
.failure-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.failure-head strong { flex: 1; }
.failure-icon { color: var(--danger); }
.failure-close {
padding: 0 5px;
color: var(--text-dim);
background: transparent;
border: none;
}
.failure-hint, .failure-kept { margin: 0 0 6px; color: var(--text-dim); line-height: 1.55; }
.failure-kept { font-style: italic; }
.failure details { margin-bottom: 6px; }
.failure summary { cursor: pointer; color: var(--text-dim); font-size: 0.78rem; }
.failure pre, .failure blockquote {
margin: 6px 0 0;
padding: 8px 10px;
max-height: 12em;
overflow: auto;
font-size: 0.76rem;
white-space: pre-wrap;
background: var(--bg-input);
border-radius: 5px;
}
.failure-actions { display: flex; gap: 8px; margin-top: 8px; }
/* The model-setup guidance, when it appears above the composer. Same measure as
the prose, so it reads as part of the column rather than a banner across it. */
.story-blocked {
width: min(66ch, 100%);
margin: 0 auto 12px;
padding: 0 24px;
}
-475
View File
@@ -1,475 +0,0 @@
/* ============================================================
Illuminated tome — ambient layer, ornament, art plates, cards
Everything below styles the home/library surfaces. It comes after
the base .card rules on purpose: several selectors here tie with
earlier ones on specificity and rely on source order to win.
============================================================ */
/* ---------- Ambient drifting motes ---------- */
/* Behind every page: fixed, unclickable, and out of the stacking flow. The
canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own
surfaces at 1+ keeps it under content without needing a negative index
(which would drop it behind the body background). */
.embers {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.85;
}
/* .topnav already carries z-index 10 — deliberately not touched here, so it
keeps sitting above page content as well as above the canvas. */
.page { position: relative; z-index: 1; }
/* A third gradient pool warming the middle of the ground. `background` is a
shorthand, so background-attachment has to be restated or it resets to
scroll and the gradients start sliding with the page. */
body {
background:
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%),
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%),
radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%),
var(--bg);
background-attachment: fixed;
}
/* ---------- Home banner ---------- */
.hall {
display: flex;
flex-direction: column;
gap: 5px;
padding: 6px 0 26px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
position: relative;
}
/* Gold hairline that fades out toward both ends, sat on the border above. */
.hall::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent);
opacity: 0.55;
}
.hall-eyebrow {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--accent-dim);
font-weight: 600;
}
.hall-title {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.7rem, 4.6vw, 2.3rem);
font-weight: 600;
letter-spacing: 0.05em;
color: var(--accent-bright);
text-shadow: 0 0 30px var(--accent-glow);
text-wrap: balance;
}
.hall-sub {
margin: 0;
color: var(--text-dim);
font-family: var(--font-story);
font-size: 1.02rem;
}
/* ---------- Ornamental section rules ---------- */
.home-section { margin-top: 34px; }
.rule-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.rule {
flex: 1;
display: flex;
align-items: center;
gap: 14px;
margin: 0;
font-family: var(--font-display);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.26em;
text-transform: uppercase;
color: var(--accent-bright);
white-space: nowrap;
}
/* Rules grow to fill whatever the label leaves — the label stays optically
centred in its own space without needing a fixed width. */
.rule::before, .rule::after {
content: '';
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent);
}
.rule::before { flex: 0 0 26px; }
.rule > span { flex: none; }
button.linklike.ornate {
font-family: var(--font-display);
font-size: 0.74rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-dim);
white-space: nowrap;
transition: color 0.15s;
}
button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; }
/* ---------- Art plates ---------- */
.art {
width: 38px;
height: 38px;
flex: none;
border-radius: 3px;
border: 1px solid var(--accent-dim);
box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
overflow: hidden;
display: grid;
place-items: center;
line-height: 1;
}
.art-lg { width: 46px; height: 46px; }
.art-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.art-icon { font-size: 1.15rem; }
.art-lg.art-icon { font-size: 1.4rem; }
.art-mono {
font-family: var(--font-display);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(232, 196, 118, 0.92);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65);
}
.art-lg.art-mono { font-size: 0.95rem; }
/* ---------- Tome cards ---------- */
.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.card.tome {
border-radius: 3px;
padding: 16px 17px 14px;
background:
linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%),
var(--bg-panel);
/* Inner dark gutter + a gold keyline: the double-rule border of a plate in
an old book, done with shadows so it costs no extra element. */
box-shadow:
inset 0 0 0 1px rgba(10, 10, 15, 0.9),
inset 0 0 0 2px rgba(150, 119, 58, 0.22);
}
/* Filigree corner brackets. These override the base .card::before gradient
bar (equal specificity, later in the file wins). */
.card.tome::before,
.card.tome::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border: 1px solid var(--accent-dim);
opacity: 0.5;
transition: opacity 0.2s;
background: none;
inset: auto;
}
.card.tome::before {
top: 6px;
left: 6px;
border-right: 0;
border-bottom: 0;
}
.card.tome::after {
bottom: 6px;
right: 6px;
border-left: 0;
border-top: 0;
}
.card.tome:hover::before,
.card.tome:hover::after { opacity: 1; }
.card.tome:hover {
border-color: var(--accent-dim);
box-shadow:
inset 0 0 0 1px rgba(10, 10, 15, 0.9),
inset 0 0 0 2px rgba(212, 169, 78, 0.4),
0 12px 30px rgba(0, 0, 0, 0.5),
0 0 26px rgba(212, 169, 78, 0.08);
}
.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
/* Keep headings clear of the wax seal, which is absolutely positioned over
the card's top-right corner. Only cards that actually carry one pay the
indent, so scenario cards keep their full width. */
.card.tome:has(> .seal) .card-head { padding-right: 26px; }
.card-headings { min-width: 0; }
.card.tome h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.045em;
color: var(--accent);
overflow-wrap: anywhere;
}
.card-from {
margin: 3px 0 0;
font-size: 0.75rem;
color: var(--text-dim);
overflow-wrap: anywhere;
}
/* Wax seal marking a story in progress. */
.seal {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 50%;
background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
font-size: 0.6rem;
color: #f0d5b0;
z-index: 1;
}
.snippet {
margin: 0;
font-family: var(--font-story);
font-size: 0.98rem;
line-height: 1.5;
color: #b3ac9c;
/* Clamp so a long beat can't stretch one card taller than its row. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.snippet.muted { color: var(--text-dim); font-style: italic; }
.snippet.dropcap::first-letter {
float: left;
font-family: var(--font-display);
font-size: 2.3rem;
line-height: 0.85;
font-weight: 700;
color: var(--accent);
padding: 3px 7px 0 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 13px;
padding-top: 11px;
border-top: 1px solid var(--border);
font-size: 0.74rem;
color: var(--text-dim);
flex-wrap: wrap;
}
.card-foot .turns { font-variant-numeric: tabular-nums; }
.card-foot .dot { opacity: 0.5; }
.card-foot .resume {
margin-left: auto;
color: var(--accent-dim);
font-family: var(--font-ui);
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 0.68rem;
font-weight: 600;
transition: color 0.15s;
}
.card.tome:hover .resume { color: var(--accent-bright); }
.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; }
.card-actions { margin-left: auto; display: inline-flex; gap: 5px; }
button.tiny { padding: 2px 9px; font-size: 0.72rem; }
button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; }
/* Staggered card entrance. The per-card delay is set inline. */
.enter { animation: card-in 0.4s ease both; }
@keyframes card-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* ---------- Loading skeletons ---------- */
.skeleton-card {
cursor: default;
pointer-events: none;
animation: card-in 0.3s ease both;
}
.skeleton-card::before, .skeleton-card::after { content: none; }
.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; }
.sk {
border-radius: 3px;
background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%);
background-size: 220% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; }
.sk-title { height: 13px; width: 58%; margin-bottom: 12px; }
.sk-line { height: 9px; margin-bottom: 8px; }
@keyframes shimmer {
from { background-position: 120% 0; }
to { background-position: -120% 0; }
}
/* ---------- Global toasts ---------- */
.toast-host {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
z-index: 120;
display: flex;
flex-direction: column-reverse;
gap: 8px;
align-items: center;
width: max-content;
max-width: min(92vw, 520px);
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 9px;
text-align: left;
font-family: var(--font-ui);
font-size: 0.86rem;
line-height: 1.45;
padding: 10px 16px;
border-radius: 8px;
background: var(--bg-panel-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
color: var(--text);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: rise 0.25s ease both;
}
.toast:hover { border-color: var(--accent-dim); }
.toast-error { border-left-color: var(--danger); }
.toast-mark { color: var(--accent); flex: none; line-height: 1.5; }
.toast-error .toast-mark { color: var(--danger); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* ---------- Story text polish ---------- */
/* Drop cap opening the story's first narrated beat. */
.story .action.opening::first-letter {
float: left;
font-family: var(--font-display);
font-size: 3.1rem;
line-height: 0.82;
font-weight: 700;
color: var(--accent);
padding: 5px 10px 0 2px;
text-shadow: 0 0 22px var(--accent-glow);
}
/* Ornamental separator between turns — a diamond flanked by fading rules.
Marks the beat change without the heaviness of a full <hr>. */
.scene-break {
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 16px;
color: var(--accent-dim);
font-size: 0.6rem;
user-select: none;
}
.scene-break::before, .scene-break::after {
content: '';
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent);
}
/* Thinking indicator — three motes pulsing in sequence. */
.thinking {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-ui);
font-size: 0.78rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-dim);
}
.thinking i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
animation: mote 1.25s ease-in-out infinite;
}
.thinking i:nth-child(2) { animation-delay: 0.18s; }
.thinking i:nth-child(3) { animation-delay: 0.36s; }
@keyframes mote {
0%, 100% { opacity: 0.25; transform: scale(0.8); }
45% { opacity: 1; transform: scale(1.15); }
}
/* ---------- Scenario art picker (editor) ---------- */
.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.art-preview {
width: 76px;
height: 76px;
flex: none;
border-radius: 3px;
border: 1px solid var(--accent-dim);
box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
overflow: hidden;
display: grid;
place-items: center;
}
.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.art-preview .glyph { font-size: 2rem; line-height: 1; }
.art-preview .mono {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: rgba(232, 196, 118, 0.92);
}
.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; }
.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.icon-row { display: flex; gap: 5px; flex-wrap: wrap; }
.icon-row button {
padding: 3px 8px;
font-size: 1rem;
line-height: 1.2;
min-width: 34px;
}
.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; }
-159
View File
@@ -1,159 +0,0 @@
/* ---------- The story tree, drawn (branch map overlay) ---------- */
.branch-map-open {
align-self: flex-start;
padding: 5px 12px;
font-size: 0.74rem;
letter-spacing: 0.08em;
color: var(--accent);
background: transparent;
border: 1px solid var(--border-bright);
}
.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
/* The overlay is deliberately wider and shorter than .modal: a tree is read
across, and the number of branches decides the height. */
.branch-map-overlay { z-index: 60; }
.branch-map {
display: flex;
flex-direction: column;
width: min(1080px, calc(100vw - 40px));
max-height: min(760px, calc(100vh - 60px));
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
}
.branch-map-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--border);
}
.branch-map-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1.15rem;
color: var(--accent-bright);
}
.branch-map-scale {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.branch-map-close {
margin-left: auto;
align-self: center;
padding: 2px 8px;
color: var(--text-dim);
background: transparent;
border: 1px solid transparent;
}
.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); }
.branch-map-canvas {
flex: 1;
min-height: 120px;
/* Height comes from the branch count, so a deep tree scrolls rather than the
diagram shrinking until nobody can read it. Width never scrolls — the map
is scaled to fit, which is the whole point of drawing it to one clock. */
overflow-y: auto;
overflow-x: hidden;
padding: 4px 12px 8px;
}
.branch-map-svg { display: block; }
.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; }
.bm-tick text {
fill: var(--text-dim);
font-family: var(--font-ui);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.bm-lane { cursor: pointer; }
.bm-hit { fill: transparent; }
.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); }
.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); }
.bm-lane:focus { outline: none; }
.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); }
.bm-line {
stroke: var(--border-bright);
stroke-width: 3;
stroke-linecap: round;
}
.bm-fork {
fill: none;
stroke: var(--border-bright);
stroke-width: 2;
}
.bm-fork-dot, .bm-tip { fill: var(--border-bright); }
.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); }
.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); }
.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); }
/* The line being read: brighter, and its tip carries the glow so where you
are standing is findable before a word is read. */
.bm-lane.here .bm-line { stroke: var(--accent); }
.bm-lane.here .bm-fork { stroke: var(--accent-dim); }
.bm-lane.here .bm-tip {
fill: var(--accent-bright);
filter: drop-shadow(0 0 6px var(--accent-glow));
}
.bm-lane.here .bm-name { fill: var(--accent-bright); }
.bm-name {
fill: var(--text);
font-family: var(--font-story);
font-size: 15px;
}
.bm-meta {
fill: var(--text-dim);
font-family: var(--font-ui);
font-size: 10.5px;
font-variant-numeric: tabular-nums;
}
.branch-map-hint {
margin: 0;
padding: 0 22px 10px;
color: var(--text-dim);
font-size: 0.82rem;
line-height: 1.55;
}
.branch-map-detail {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 22px 18px;
border-top: 1px solid var(--border);
}
.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); }
.bmd-head { display: flex; align-items: center; gap: 8px; }
.branch-map-detail .branch-name { font-size: 1.05rem; }
.branch-map-detail.here .branch-name { color: var(--accent-bright); }
.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button {
padding: 4px 11px;
font-size: 0.74rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.branch-map-detail .branch-tools button:hover:not(:disabled),
.branch-map-detail .branch-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
@media (prefers-reduced-motion: reduce) {
.enter, .skeleton-card, .sk, .toast, .thinking i,
.story .action, .page {
animation: none !important;
}
.card.tome:hover { transform: none; }
}