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:
co-authored by
Claude Opus 5
parent
480414efe0
commit
1ce9972760
+94
-6
@@ -214,13 +214,44 @@ visible from within.
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 920 tests, 10 skipped
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # the backend suite
|
||||
cd frontend && npm test # the component suite (M8)
|
||||
cd frontend && npm run lint && npm run build
|
||||
```
|
||||
|
||||
Ten tests skip without something the machine may not have: seven need a second
|
||||
machine or an environment the suite cannot create, and three are M7's real-model
|
||||
tests below.
|
||||
Fourteen backend tests skip without something the machine may not have: seven
|
||||
need a second machine or an environment the suite cannot create, and the rest
|
||||
are the real-model tests below.
|
||||
|
||||
### The frontend component suite
|
||||
|
||||
M8 added one, because until M8 there was none — the browser was covered by real
|
||||
Firefox runs at each milestone's closeout and by nothing in between. It is
|
||||
Vitest and Testing Library over jsdom, and it runs in about two seconds:
|
||||
|
||||
```bash
|
||||
cd frontend && npm test # once
|
||||
cd frontend && npm run test:watch # while working
|
||||
```
|
||||
|
||||
It covers the deterministic browser behaviour M8 owns: which history controls
|
||||
are enabled and why, the take selector, the Save Point and delete confirmations,
|
||||
what the State panel shows and does not, knowledge classification and semantic
|
||||
status, the context inspector's sections, the model-empty and model-unavailable
|
||||
states, how failures are presented, the dialog focus trap, and that the reserved
|
||||
dictation control never touches the microphone. Several tests assert the absence
|
||||
of branch vocabulary in the surfaces a reader uses.
|
||||
|
||||
`markdown.test.jsx` is the security one. Narrator prose and imported text both
|
||||
reach the renderer, so it is where H06 and H07 are decided: markup in the source
|
||||
never becomes markup in the page, a `javascript:` URL never becomes an href, and
|
||||
a remote image is a placeholder rather than a request.
|
||||
|
||||
**It does not replace the real-browser runs.** jsdom has no layout, no
|
||||
navigation and no network, so scroll behaviour, streaming, a genuine process
|
||||
restart and the CSP are all outside its reach. Each milestone's closeout drives
|
||||
a real Firefox over WebDriver, and that evidence is recorded in the milestone
|
||||
report.
|
||||
|
||||
Two files are the M1 regression guards.
|
||||
|
||||
@@ -297,6 +328,58 @@ subsystem comes back as a route, if an API key becomes settable again, if the
|
||||
model timeout stops being configurable or becomes unbounded, or if a supported
|
||||
start path stops binding loopback.
|
||||
|
||||
## The context window your Ollama actually enforces
|
||||
|
||||
**Check this before a long campaign.** The application budgets a prompt up to
|
||||
`Settings.context_token_budget` (16,384 by default). Ollama enforces its own
|
||||
input window, and when it sees no VRAM it defaults to **4,096**:
|
||||
|
||||
```
|
||||
level=INFO msg="vram-based default context" total_vram="0 B" default_num_ctx=4096
|
||||
```
|
||||
|
||||
Confirm what yours is:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:11434/api/ps | python3 -m json.tool | grep context_length
|
||||
```
|
||||
|
||||
If that number is smaller than your budget, Ollama silently truncates the input
|
||||
— and `llama.cpp` drops the **oldest** tokens, which in this application is the
|
||||
system block: the narrator rules and the campaign canon. The symptom is a
|
||||
narrator that forgets canon deep into a long session, with nothing on screen
|
||||
explaining why.
|
||||
|
||||
**Setting it per request does not work from this application.** Ollama's
|
||||
OpenAI-compatible endpoint accepts `num_ctx` — nested in `options` or at the top
|
||||
level — returns HTTP 200 and ignores it. Worse, it *reloads the model at its own
|
||||
default*, so priming the server with a native `/api/chat` call first does not
|
||||
help either: the app's next request resets the window.
|
||||
|
||||
**Bake it into a model instead.** The window travels with the model, and this
|
||||
needs no shell access on the Ollama host — it is a normal API call:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:11434/api/create -d '{
|
||||
"model": "qwen2.5:3b-instruct-16k",
|
||||
"from": "qwen2.5:3b-instruct",
|
||||
"parameters": {"num_ctx": 16384}
|
||||
}'
|
||||
```
|
||||
|
||||
The derived model shares the base model's blobs, so it costs a manifest. It then
|
||||
appears in `/v1/models`, which is the listing the Settings model picker reads —
|
||||
select it there and the storyteller gets the full window through its ordinary
|
||||
OpenAI-compatible path. Remove it with `POST /api/delete` when you are done.
|
||||
|
||||
Where you *do* control the server environment, `OLLAMA_CONTEXT_LENGTH=16384`
|
||||
does the same job. Either way a larger window costs roughly proportionally more
|
||||
KV cache.
|
||||
|
||||
If you would rather not raise it at all, set **How much story to send** in
|
||||
Settings to the number `/api/ps` reports, and the prompt will be assembled to
|
||||
fit.
|
||||
|
||||
## What was made offline-safe, and how to check
|
||||
|
||||
Two runtime downloads were removed in Milestone M1. Both were invisible on a
|
||||
@@ -350,5 +433,10 @@ left of upstream that a newcomer might report as a defect:
|
||||
- **`.github/workflows/ci.yml`** is upstream's GitHub Actions pipeline. This
|
||||
repository lives on a self-hosted Gitea; the workflow is kept for provenance
|
||||
and is not what runs the tests here.
|
||||
- **No frontend tests.** `npm run lint && npm run build` is the whole frontend
|
||||
check. A test runner is M8's job.
|
||||
- **A thin `components.jsx`.** What is left of upstream's shared component
|
||||
module is a toast host, a file picker, a JSON download and an auto-growing
|
||||
textarea. M8 removed the rest with the screens that used them — the scenario
|
||||
art generator, the placeholder modal, the story-card row.
|
||||
|
||||
(Removed from this list by M8: **no frontend tests**. There is a component suite
|
||||
now — see Tests above.)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
[](LICENSE)
|
||||
|
||||
An interactive storytelling app that runs entirely on your own machine, with your own model.
|
||||
Create scenarios and play open-ended adventures where a local LLM narrates the world, keeps
|
||||
track of what is true, and remembers what happened.
|
||||
Start a campaign from a short form and play an open-ended story where a local LLM narrates the
|
||||
world, keeps track of what is true, and remembers what happened. Since M8 the browser has one
|
||||
entry point — a campaign library — and one natural-language input; the scenario gallery and its
|
||||
editor are gone from the interface, though the AI Dungeon-compatible scenario *format* is still
|
||||
supported for import and export.
|
||||
|
||||
This is the **Adventure Storyteller** fork of [AI-DnD](https://github.com/parththakkar106/AI-DnD).
|
||||
It is deliberately narrower than its upstream: single-user, local-only, and pointed at a model
|
||||
@@ -31,19 +34,26 @@ that isn't the live one starts a new branch.
|
||||
|
||||
## Features
|
||||
|
||||
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction
|
||||
becomes a new continuation carrying the state it implies, and the original narration keeps its
|
||||
own future as retained history. Reasoning models are supported: "thinking" streams into a
|
||||
collapsible 💭 panel with its own token budget.
|
||||
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
|
||||
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
|
||||
and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||||
branch. Branches borrow their ancestors' turns instead of copying them, so a fork costs about
|
||||
100 bytes, and a 20-fork story loads within 1% of the same story flat. Switching restores that
|
||||
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
|
||||
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
- **The full play loop, in one box.** You write what you do or say in a single
|
||||
natural-language field — an action and a piece of quoted dialogue are both just what you
|
||||
wrote — with **Continue** for a beat you do not act in and a **Story direction** toggle for
|
||||
speaking to the narrator rather than in the story. Responses stream (SSE), and Undo, Redo,
|
||||
Retry and Edit sit beside the box. Correcting narrator prose does not overwrite it: the
|
||||
correction becomes a new continuation carrying the state it implies, and the original
|
||||
narration keeps its own future as retained history. Reasoning models are supported: the
|
||||
narrator's thinking streams into a collapsible panel with its own token budget.
|
||||
- **Retained history, without a tree to manage.** Underneath, the story is a tree: any turn
|
||||
can hold more than one **take**, and `‹ 2/4 ›` steps between them. Stepping is free — the
|
||||
story below simply empties, and the server is told nothing. Writing below a take that is not
|
||||
the live one is what starts a different continuation. Branches borrow their ancestors' turns
|
||||
instead of copying them, so one costs about 100 bytes, and a 20-fork story loads within 1% of
|
||||
the same story flat (`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
|
||||
**None of that vocabulary reaches the reader.** M8 removed the branch panel and the tree
|
||||
overlay from the browser: what you get is Undo, Redo, Retry, takes, Save Points and Restore,
|
||||
and nothing on screen says branch, fork, node or head. The mechanism is unchanged and still
|
||||
fully tested — this is a decision about what you are asked to understand, not about what the
|
||||
product can do.
|
||||
- **Authoritative narrative state, and the application owns it.** The story tracks who exists,
|
||||
where they are, what they hold, what is true, how they are tied to each other, and what is
|
||||
still open — as generic entities, facts, relationships and threads, with no genre baked in.
|
||||
@@ -60,12 +70,15 @@ that isn't the live one starts a new branch.
|
||||
(`backend/app/context/builder.py`). Story cards are the inherited authored-lore primitive and
|
||||
are kept; they are **not** the knowledge library below, which is a first-class subsystem with
|
||||
its own classification, provenance, chunking and index.
|
||||
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
|
||||
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
|
||||
included.
|
||||
- **Total prompt transparency.** Every turn stores the exact prompt sent to the model.
|
||||
**Inspect context** on any narrator turn opens a readable account of what it was given —
|
||||
what it remembered, what it read, what it believes, and what each part cost — with the
|
||||
assembled prompt itself kept as an advanced section rather than opening on a wall of text.
|
||||
A passage that came from an imported file links back to the file it came from.
|
||||
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
|
||||
memories every few actions, a running story summary, and embedding-based retrieval that
|
||||
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
|
||||
pulls old-but-relevant facts back into context, with similarity scores visible in the
|
||||
context inspector
|
||||
(`backend/app/memorybank.py`).
|
||||
- **An imported knowledge library, classified by how much authority it has.** Import your own
|
||||
local `.txt` and `.md` files — a setting bible, character notes, research, a passage whose
|
||||
@@ -80,7 +93,7 @@ that isn't the live one starts a new branch.
|
||||
path, not a fallback — the library works with no embedding model at all. Canon you mark
|
||||
**always include** is supplied on every turn whether or not the scene resembles it, and Canon
|
||||
you mark **narrator only** is given to the narrator with instructions not to let the
|
||||
protagonist know it. Every passage that reaches a prompt is listed in Insights with its file,
|
||||
protagonist know it. Every passage that reaches a prompt is listed in the context inspector with its file,
|
||||
class, heading, passage number, scores and token cost, and that record is kept in the turn, so
|
||||
deleting a source never erases the evidence of what an old turn was shown
|
||||
(`backend/app/knowledge/`).
|
||||
|
||||
@@ -155,6 +155,22 @@ class Adventure(Base):
|
||||
# and a science-fiction one forbidding faster-than-light travel use the same
|
||||
# field and the same validator; neither word appears in the application.
|
||||
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@property
|
||||
def canon_rules(self) -> list[str]:
|
||||
"""The `rules` list alone, which is the half a person writes.
|
||||
|
||||
`campaign_canon` also carries `forbidden_status_changes`, a structured
|
||||
shape the browser has no editor for and does not need one for — a rule
|
||||
like "nothing dead becomes alive" is expressible as a sentence. So the
|
||||
API exposes the sentences and leaves the structured half to whatever
|
||||
wrote it, rather than round-tripping a shape the UI would flatten.
|
||||
"""
|
||||
canon = self.campaign_canon
|
||||
if not isinstance(canon, dict):
|
||||
return []
|
||||
rules = canon.get("rules")
|
||||
return [r for r in rules if isinstance(r, str)] if isinstance(rules, list) else []
|
||||
# The ${Placeholder} answers collected when this adventure was started, kept
|
||||
# so "Update from scenario" can re-fill freshly copied scenario text with the
|
||||
# same values. NULL for adventures created before this column existed.
|
||||
|
||||
@@ -381,21 +381,29 @@ class OpenAICompatibleProvider(Provider):
|
||||
return vectors
|
||||
|
||||
def _friendly_http_error(self, status: int, detail: str) -> str:
|
||||
"""The message a reader sees when the endpoint answers with an error.
|
||||
|
||||
M8 rewrote two of these. They were the last user-facing text describing
|
||||
a hosted deployment this build does not have: a 401 advised checking an
|
||||
API key, and a 429 explained a shared free tier's daily cap. There is no
|
||||
API key field — M2 removed it with the cloud providers — and no shared
|
||||
tier, so both sent a reader looking for a setting that does not exist.
|
||||
Ollama's own 401 and 429 mean something else entirely.
|
||||
"""
|
||||
if status == 401:
|
||||
return "Authentication failed — check your API key in Settings."
|
||||
return (
|
||||
"The endpoint refused the request as unauthorized (HTTP 401). "
|
||||
"An ordinary local Ollama does not require authentication — "
|
||||
f"check that {self.base_url} is the endpoint you meant. {detail}"
|
||||
)
|
||||
if status == 404:
|
||||
return (
|
||||
f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that "
|
||||
f"model '{self.model}' exists. {detail}"
|
||||
)
|
||||
if status == 429:
|
||||
# OpenRouter's shared free tier has a per-day cap. Distinguish it
|
||||
# from a short-term burst limit, so the message tells the reader what
|
||||
# to do.
|
||||
if "free-models-per-day" in detail:
|
||||
return (
|
||||
"The free demo has hit its daily request limit (resets at "
|
||||
"00:00 UTC). Please try again later."
|
||||
)
|
||||
return "The AI is getting too many requests right now — wait a moment and try again."
|
||||
return (
|
||||
"The endpoint is refusing further requests for now (HTTP 429). "
|
||||
"Wait a moment and try again."
|
||||
)
|
||||
return f"AI endpoint returned HTTP {status}: {detail}"
|
||||
|
||||
@@ -195,20 +195,37 @@ def create_adventure(
|
||||
# everywhere, which buys nothing.
|
||||
tree.head_branch(db, adventure)
|
||||
|
||||
# M8: canon written at setup. Stored in the same document the prompt and the
|
||||
# validator already read, so nothing downstream learns a second shape.
|
||||
rules = [r.strip() for r in payload.canon_rules if r.strip()]
|
||||
if rules:
|
||||
adventure.campaign_canon = {"rules": rules}
|
||||
|
||||
if scenario:
|
||||
for ref, spec in scenario_card_specs(scenario, values).items():
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
||||
if scenario.prompt.strip():
|
||||
opening = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="start",
|
||||
text=fill_placeholders(scenario.prompt, values),
|
||||
)
|
||||
# Record the starting state on the opening node, so undoing or
|
||||
# retrying the first turn has a state to roll back to.
|
||||
attempts.snapshot_outcome(adventure, opening)
|
||||
tree.place_action(db, adventure, opening)
|
||||
db.add(opening)
|
||||
|
||||
# The opening scene. A scenario's prompt and M8's `opening` field are the
|
||||
# same thing arriving by different routes, so they build the same node —
|
||||
# the scenario wins when both are present, because it is the more specific
|
||||
# request. Everything downstream (Undo to the opening, retrying the first
|
||||
# turn, the drop cap) keys on the `start` type and is unchanged.
|
||||
opening_text = (
|
||||
fill_placeholders(scenario.prompt, values)
|
||||
if scenario and scenario.prompt.strip()
|
||||
else payload.opening.strip()
|
||||
)
|
||||
if opening_text:
|
||||
opening = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="start",
|
||||
text=opening_text,
|
||||
)
|
||||
# Record the starting state on the opening node, so undoing or
|
||||
# retrying the first turn has a state to roll back to.
|
||||
attempts.snapshot_outcome(adventure, opening)
|
||||
tree.place_action(db, adventure, opening)
|
||||
db.add(opening)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
@@ -296,6 +313,20 @@ def update_adventure(
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
fields = payload.model_dump(exclude_unset=True)
|
||||
# M8. `canon_rules` is a read-only view onto the stored `campaign_canon`
|
||||
# document, so it is written by hand rather than by the setattr loop — and
|
||||
# only the `rules` key is replaced. Whatever else the document holds
|
||||
# (`forbidden_status_changes`, which has no browser editor) is left exactly
|
||||
# as it was, so editing canon through the browser cannot silently discard
|
||||
# the structured half a fixture or an import wrote.
|
||||
if "canon_rules" in fields:
|
||||
rules = [r.strip() for r in (fields.pop("canon_rules") or []) if r.strip()]
|
||||
canon = dict(adventure.campaign_canon or {})
|
||||
if rules:
|
||||
canon["rules"] = rules
|
||||
else:
|
||||
canon.pop("rules", None)
|
||||
adventure.campaign_canon = canon or None
|
||||
for field, value in fields.items():
|
||||
setattr(adventure, field, value)
|
||||
# M6: a summary the reader typed is still a summary, so it is anchored to
|
||||
|
||||
@@ -82,8 +82,35 @@ async def with_turn_lock(adventure_id: int, gen):
|
||||
_active_turns.discard(adventure_id)
|
||||
|
||||
|
||||
#: Openings that mean the reader has already written the subject of the sentence.
|
||||
#:
|
||||
#: Matched as whole words, longest first, so "I'm" is recognised before "I".
|
||||
_FIRST_PERSON = ("i ", "i'm ", "i've ", "i'll ", "i'd ", "my ", "we ", "we're ")
|
||||
|
||||
|
||||
def format_player_input(action_type: str, text: str) -> str:
|
||||
"""Formats player input the way AI Dungeon does."""
|
||||
"""Formats player input the way AI Dungeon does — with one M8 correction.
|
||||
|
||||
The convention is a `>` marker and second person: typing `look around` in
|
||||
the old Do mode stored `> You look around.`, which reads correctly and shows
|
||||
the model whose turn it is.
|
||||
|
||||
**M8 broke that assumption and this repairs it.** `BROWSER-UX-SPEC.md` §12
|
||||
replaced the Do/Say/Story selector with one natural-language field, and §11
|
||||
tells the reader to write sentences like *"I enter the tavern."* Prefixing
|
||||
that produced `> 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". It was visible in the very first
|
||||
browser pass of the new composer.
|
||||
|
||||
So the prefix is added only when the reader has *not* already written a
|
||||
subject. First person is left alone; everything else keeps the old
|
||||
behaviour, and the `>` marker is unchanged in every case, because that is
|
||||
what actually distinguishes a player turn in the prompt.
|
||||
|
||||
Storage is unchanged for text that was already formatted — see
|
||||
`test_take_parentage.py`, which guards against `> You > You ...`.
|
||||
"""
|
||||
text = text.strip()
|
||||
if action_type == "say":
|
||||
text = text.strip('"')
|
||||
@@ -95,6 +122,9 @@ def format_player_input(action_type: str, text: str) -> str:
|
||||
text = text[4:]
|
||||
if text and text[-1] not in ".!?…":
|
||||
text += "."
|
||||
lowered = text.lower()
|
||||
if any(lowered.startswith(opening) for opening in _FIRST_PERSON):
|
||||
return f"> {text}"
|
||||
return f"> You {text}"
|
||||
return text # The "story" type is appended as raw text.
|
||||
|
||||
|
||||
@@ -143,6 +143,24 @@ class ScenarioListItem(ORMModel):
|
||||
class AdventureCreate(BaseModel):
|
||||
scenario_id: int | None = None
|
||||
title: Name | None = None
|
||||
# M8: the opening scene, for a campaign started without a scenario.
|
||||
#
|
||||
# A scenario's `prompt` already becomes the campaign's `start` action, and
|
||||
# this is the same thing said directly. It exists because M8's setup flow
|
||||
# creates a campaign from a form rather than from a template
|
||||
# (`BROWSER-UX-SPEC.md` §41), and without it every new campaign opens on a
|
||||
# blank page — the reader has to invent the situation *and* the first move
|
||||
# in one box. Ignored when `scenario_id` is given, which already supplies one.
|
||||
opening: Prose = ""
|
||||
# M8: the campaign's own rules, as a list of sentences.
|
||||
#
|
||||
# The column has existed since migration 82 and both the prompt
|
||||
# (`context/builder._canon_section`) and the state validator
|
||||
# (`narrative/apply`) already read it — it simply had no way in from the
|
||||
# browser, so a fixture had to write it with SQL. This is the highest
|
||||
# authority in the campaign, which is exactly why a person setting one up
|
||||
# needs to be able to state it.
|
||||
canon_rules: list[Name] = []
|
||||
# The `${Placeholder}` values collected from the player at the start, which
|
||||
# is the AI Dungeon behavior.
|
||||
placeholders: dict[str, str] = {}
|
||||
@@ -165,6 +183,10 @@ class AdventureUpdate(BaseModel):
|
||||
persona_name: PersonaName | None = None
|
||||
persona_pronouns: PersonaPronouns | None = None
|
||||
persona_desc: Prose | None = None
|
||||
# M8. See `AdventureCreate.canon_rules`. Editable after setup because canon
|
||||
# is the thing a reader most often gets wrong first and needs to correct —
|
||||
# "resurrection is impossible" is easier to write once the story has tried it.
|
||||
canon_rules: list[Name] | None = None
|
||||
|
||||
|
||||
class AdventureRefresh(BaseModel):
|
||||
@@ -456,6 +478,9 @@ class AdventureOut(ORMModel):
|
||||
persona_name: str
|
||||
persona_pronouns: str
|
||||
persona_desc: str
|
||||
# M8. Read from the `canon_rules` property on the model, which pulls the
|
||||
# sentence list out of the stored `campaign_canon` document.
|
||||
canon_rules: list[str] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
story_cards: list[StoryCardOut] = []
|
||||
|
||||
@@ -1476,18 +1476,27 @@ def test_h06_h07_imported_active_content_is_served_as_inert_text(client):
|
||||
assert report_response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
# And the browser side never renders it as HTML. Asserted against the source
|
||||
# of the two components that display imported text, because that is where
|
||||
# the property would be lost — a `dangerouslySetInnerHTML` added to either
|
||||
# is what turns every assertion above into decoration. A real browser
|
||||
# exercises the same two components; this fails at build time instead of
|
||||
# waiting for someone to run one.
|
||||
# of every component that displays imported or narrator text, because that
|
||||
# is where the property would be lost — one `dangerouslySetInnerHTML` turns
|
||||
# every assertion above into decoration.
|
||||
#
|
||||
# M8 renamed the Insights panel to `ContextPanel.jsx` and added
|
||||
# `markdown.jsx`, which renders narrator prose. The renderer is the newest
|
||||
# and largest way this property could be lost, so it is guarded here too;
|
||||
# `frontend/src/markdown.test.jsx` covers its behaviour, and this covers the
|
||||
# one line that would make that behaviour irrelevant.
|
||||
import pathlib
|
||||
|
||||
frontend = pathlib.Path(__file__).resolve().parents[2] / "frontend" / "src"
|
||||
for name in ("pages/Play/panels/KnowledgePanel.jsx",
|
||||
"pages/Play/panels/InsightsPanel.jsx"):
|
||||
"pages/Play/panels/ContextPanel.jsx",
|
||||
"markdown.jsx"):
|
||||
text = (frontend / name).read_text()
|
||||
assert "dangerouslySetInnerHTML" not in text, name
|
||||
# The prop as it would actually be written, not the bare word: these
|
||||
# files discuss the hazard in their own comments, and a test that
|
||||
# cannot tell an explanation from a use would forbid documenting it.
|
||||
assert "dangerouslySetInnerHTML=" not in text, name
|
||||
assert "dangerouslySetInnerHTML:" not in text, name
|
||||
assert "innerHTML" not in text.replace("document.body.innerHTML='owned'", ""), name
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""M8: the two fields the streamlined setup flow added, and what they must not do.
|
||||
|
||||
`BROWSER-UX-SPEC.md` §41 replaced "pick a scenario, then fill in its
|
||||
placeholders" with a form. Two things had to reach the API for that to work, and
|
||||
both are narrow by design (`BUILD-MILESTONES.md` M8, §39 of the brief):
|
||||
|
||||
opening the campaign's first scene, so a new campaign does not open on
|
||||
a blank page. It builds the same `start` node a scenario's
|
||||
prompt does, by the same code path.
|
||||
canon_rules a read/write view onto the `rules` list inside the existing
|
||||
`campaign_canon` document, which has had no API at all since
|
||||
the column was added in migration 82.
|
||||
|
||||
Neither adds a column. `test_knowledge_migration.py` and the M8 report's
|
||||
migration proof cover the schema claim; these cover the behaviour.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
OPENING = "You sit at a shared table in the Crooked Lantern Tavern."
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""The suite's convention: create the schema, own a user, drop it after.
|
||||
|
||||
The first version of this fixture just wrapped `TestClient(app)`. It passed
|
||||
in isolation and failed ten ways in the full suite, because the tests share
|
||||
one database and every other module creates and drops the schema around
|
||||
itself — so this file inherited whatever the previous module had left, and
|
||||
had no user of its own for `auth.get_current_user` to find.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="m8setup@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _campaign(client, **body):
|
||||
r = client.post("/api/adventures", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- opening ---
|
||||
|
||||
def test_the_opening_becomes_the_campaigns_first_scene(client):
|
||||
adv = _campaign(client, title="With opening", opening=OPENING)
|
||||
assert [(a["type"], a["text"]) for a in adv["actions"]] == [("start", OPENING)]
|
||||
|
||||
# `action_count` is computed on read, so the create response reports 0 —
|
||||
# for a scenario-made campaign too, and it has always done so. The browser
|
||||
# navigates to the campaign and re-reads, which is the surface asserted
|
||||
# here and the one a reader actually sees.
|
||||
fetched = client.get(f"/api/adventures/{adv['id']}").json()
|
||||
assert fetched["action_count"] == 1
|
||||
assert [(a["type"], a["text"]) for a in fetched["actions"]] == [("start", OPENING)]
|
||||
|
||||
|
||||
def test_the_opening_is_not_duplicated(client):
|
||||
adv = _campaign(client, title="Once", opening=OPENING)
|
||||
again = client.get(f"/api/adventures/{adv['id']}").json()
|
||||
assert [a["text"] for a in again["actions"]].count(OPENING) == 1
|
||||
assert again["action_count"] == 1
|
||||
|
||||
|
||||
def test_the_opening_is_placed_on_the_tree_like_any_other_node(client):
|
||||
"""It must not bypass head/history semantics.
|
||||
|
||||
A `start` node that was not placed on the tree, or carried no state
|
||||
snapshot, would break Undo and retry at the first turn — which is exactly
|
||||
where a new reader meets them.
|
||||
"""
|
||||
adv = _campaign(client, title="Placed", opening=OPENING)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = (db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv["id"]).one())
|
||||
assert row.depth == 0
|
||||
assert row.branch_id is not None
|
||||
assert row.parent_id is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# And the head is at it: there is nothing before the opening to undo to.
|
||||
assert adv["can_undo"] is False
|
||||
assert adv["can_redo"] is False
|
||||
assert client.post(f"/api/adventures/{adv['id']}/undo").status_code >= 400
|
||||
|
||||
|
||||
def test_a_campaign_without_an_opening_still_starts_empty(client):
|
||||
adv = _campaign(client, title="Blank")
|
||||
assert adv["actions"] == []
|
||||
|
||||
|
||||
def test_a_scenario_prompt_takes_precedence_and_is_never_doubled(client):
|
||||
"""Both routes build the same node, so only one of them may fire."""
|
||||
sc = client.post("/api/scenarios",
|
||||
json={"title": "S", "prompt": "A scenario opening."}).json()
|
||||
adv = _campaign(client, scenario_id=sc["id"], opening=OPENING)
|
||||
assert [a["text"] for a in adv["actions"]] == ["A scenario opening."]
|
||||
|
||||
legacy = _campaign(client, scenario_id=sc["id"])
|
||||
assert [a["text"] for a in legacy["actions"]] == ["A scenario opening."]
|
||||
|
||||
|
||||
def test_the_opening_survives_export_and_import(client):
|
||||
adv = _campaign(client, title="Round trip", opening=OPENING)
|
||||
bundle = client.get(f"/api/adventures/{adv['id']}/export").json()
|
||||
restored = client.post("/api/adventures/import", json=bundle).json()
|
||||
assert [a["text"] for a in restored["actions"]] == [OPENING]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ canon_rules ---
|
||||
|
||||
def test_canon_rules_round_trip_and_blank_lines_are_dropped(client):
|
||||
adv = _campaign(client, title="Canon",
|
||||
canon_rules=["Resurrection is impossible.", " ", "Magic exists."])
|
||||
assert adv["canon_rules"] == ["Resurrection is impossible.", "Magic exists."]
|
||||
|
||||
patched = client.patch(f"/api/adventures/{adv['id']}",
|
||||
json={"canon_rules": ["Only one rule now."]}).json()
|
||||
assert patched["canon_rules"] == ["Only one rule now."]
|
||||
|
||||
cleared = client.patch(f"/api/adventures/{adv['id']}",
|
||||
json={"canon_rules": []}).json()
|
||||
assert cleared["canon_rules"] == []
|
||||
|
||||
|
||||
def test_editing_canon_preserves_the_structured_half_it_has_no_editor_for(client):
|
||||
"""`campaign_canon` also holds `forbidden_status_changes`.
|
||||
|
||||
The browser edits sentences and has no editor for the structured shape, so
|
||||
writing the sentences must not discard it — otherwise importing a bundle
|
||||
that carries one and then touching canon in the UI would silently drop a
|
||||
rule the validator enforces.
|
||||
"""
|
||||
adv = _campaign(client, title="Structured")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(models.Adventure, adv["id"])
|
||||
row.campaign_canon = {
|
||||
"rules": ["R1"],
|
||||
"forbidden_status_changes": [{"from": "dead", "to": "alive"}],
|
||||
}
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert client.get(f"/api/adventures/{adv['id']}").json()["canon_rules"] == ["R1"]
|
||||
client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["R2", "R3"]})
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.get(models.Adventure, adv["id"]).campaign_canon
|
||||
assert stored["rules"] == ["R2", "R3"]
|
||||
assert stored["forbidden_status_changes"] == [{"from": "dead", "to": "alive"}]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_canon_is_untouched_by_an_unrelated_patch(client):
|
||||
adv = _campaign(client, title="Untouched", canon_rules=["A rule."])
|
||||
renamed = client.patch(f"/api/adventures/{adv['id']}",
|
||||
json={"title": "Renamed"}).json()
|
||||
assert renamed["canon_rules"] == ["A rule."]
|
||||
assert renamed["title"] == "Renamed"
|
||||
|
||||
|
||||
def test_canon_reaches_the_prompt_as_the_campaigns_own_rules(client):
|
||||
"""The point of exposing it: what is written here is what the narrator is told."""
|
||||
adv = _campaign(client, title="Prompted",
|
||||
canon_rules=["Resurrection is impossible."])
|
||||
report = client.get(f"/api/adventures/{adv['id']}/context").json()
|
||||
canon = next((s["text"] for s in report["sections"]
|
||||
if s["label"] == "campaign_canon"), "")
|
||||
assert "Resurrection is impossible." in canon
|
||||
@@ -1471,46 +1471,24 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
|
||||
f"/api/adventures/{client.adv_id}/branches/{middle_branch}"
|
||||
).status_code == 409
|
||||
|
||||
|
||||
def test_both_branch_delete_surfaces_explain_the_save_point_rule():
|
||||
"""The rule must be visible in every view the deletion is reachable from.
|
||||
|
||||
A source-level assertion, because the project has no frontend test runner
|
||||
(M8). `test_offline_assets.py` reads the frontend the same way, for the same
|
||||
reason: the check is worth having now, and it is honest about what it is —
|
||||
it proves the wiring is in the build, not that a user saw it. The browser
|
||||
smoke test performed at closeout is what proves the rendering.
|
||||
|
||||
Two files, because the branch list and the tree overlay each render their
|
||||
own delete control, and a rule that held in one of them would not be a rule.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
views = {
|
||||
"the branch panel":
|
||||
repo / "frontend/src/pages/Play/panels/BranchPanel.jsx",
|
||||
"the tree overlay":
|
||||
repo / "frontend/src/BranchMap.jsx",
|
||||
}
|
||||
for where, path in views.items():
|
||||
source = path.read_text(encoding="utf-8")
|
||||
assert "savePointsUnder" in source, (
|
||||
f"{where} does not count the Save Points that protect a branch"
|
||||
)
|
||||
# The Delete control is disabled while Save Points protect the subtree,
|
||||
# and says why rather than failing silently on the server.
|
||||
assert "protecting > 0" in source, (
|
||||
f"{where} does not disable Delete while Save Points protect the branch"
|
||||
)
|
||||
assert "deleting a Save Point deletes no story" in source, (
|
||||
f"{where} does not tell the user how to proceed"
|
||||
)
|
||||
# The user-facing copy must not explain itself in schema terms.
|
||||
for jargon in ("cascade", "foreign key", "foreign-key", "ON DELETE"):
|
||||
assert jargon.lower() not in source.lower(), (
|
||||
f"{where} uses implementation jargon in user-facing copy: {jargon}"
|
||||
)
|
||||
# The browser copy for deleting and restoring a Save Point was asserted here,
|
||||
# by reading `SavePointPanel.jsx` as text. That check is gone, and this note is
|
||||
# what replaced it.
|
||||
#
|
||||
# It existed because the project had no frontend test runner and the wording is
|
||||
# load-bearing: a reader who believes Restore destroys their later story will
|
||||
# not press it. M8 supplied the runner, and
|
||||
# `frontend/src/pages/Play/panels/panels.test.jsx` now renders both
|
||||
# confirmations and reads what they actually say — which is the thing this was
|
||||
# approximating, done properly.
|
||||
#
|
||||
# It was also becoming unsound. JSX wraps prose across lines, so a substring
|
||||
# match on a sentence broke on reflow rather than on a change of meaning, and
|
||||
# the same check forbade the words "branch" and "fork" in a file whose own
|
||||
# comments explain why those words are avoided.
|
||||
#
|
||||
# The server-side rule it protected — deleting a branch a Save Point is kept on
|
||||
# is refused — is unchanged and tested above.
|
||||
|
||||
|
||||
def test_creating_a_save_point_takes_the_campaigns_turn_lock(client):
|
||||
|
||||
@@ -474,3 +474,83 @@ def test_naming_a_take_that_is_already_the_story_just_plays_on(client):
|
||||
_play(client, "carry on", after_id=live.id)
|
||||
|
||||
assert _branch_count(client.adv_id) == before
|
||||
|
||||
|
||||
def test_first_person_input_is_not_prefixed_with_you():
|
||||
"""M8. `BROWSER-UX-SPEC.md` §11-12: one field, and the reader writes "I …".
|
||||
|
||||
The AI Dungeon convention prefixes a player action with `> You `, which was
|
||||
right when the Do mode asked for a bare verb phrase. With one
|
||||
natural-language field it produced `> You I enter the tavern.` — in the
|
||||
transcript, in the replayed history, and so in the narration, where a small
|
||||
model imitated it and wrote "You I thank her". Found in the first browser
|
||||
pass of the M8 composer.
|
||||
|
||||
Tested against the shared normalizer rather than a rendered component,
|
||||
because every surface — storage, transcript, replayed history, export —
|
||||
reads the result of this one function.
|
||||
|
||||
The `>` marker is what distinguishes a player turn in the prompt, so it is
|
||||
unchanged in every case; only the redundant subject is dropped.
|
||||
"""
|
||||
from app.routers.adventures.turns import format_player_input as fmt
|
||||
|
||||
# --- The §11 examples, verbatim from the specification ---
|
||||
assert fmt("do", "I enter the tavern.") == "> I enter the tavern."
|
||||
assert fmt("do", "I ask Mara about Edrin.") == "> I ask Mara about Edrin."
|
||||
assert fmt("do", "I wait quietly and watch the room.") == (
|
||||
"> I wait quietly and watch the room.")
|
||||
|
||||
# --- No duplicate subject, in any first-person phrasing ---
|
||||
for text in ("I walk into the tavern", "I'm going to knock", "I've seen this before",
|
||||
"I'll wait", "I'd rather not", "My hand finds the key",
|
||||
"We head north", "We're leaving"):
|
||||
out = fmt("do", text)
|
||||
assert "You I" not in out, f"duplicate subject in {out!r}"
|
||||
assert "You My" not in out, f"duplicate subject in {out!r}"
|
||||
assert "You We" not in out, f"duplicate subject in {out!r}"
|
||||
assert out.startswith("> "), f"lost the player-turn marker in {out!r}"
|
||||
|
||||
# --- The legacy bare action still normalizes, which is deliberate ---
|
||||
assert fmt("do", "open the door") == "> You open the door."
|
||||
assert fmt("do", "look around") == "> You look around."
|
||||
# And an explicit "You ..." is de-duplicated rather than doubled.
|
||||
assert fmt("do", "You leave the tavern") == "> You leave the tavern."
|
||||
|
||||
# --- Dialogue and out-of-character direction are untouched ---
|
||||
assert fmt("say", "Have you seen Edrin?") == '> You say "Have you seen Edrin?"'
|
||||
assert fmt("say", "I think he went north") == '> You say "I think he went north."'
|
||||
assert fmt("story", "Keep this scene tense, but do not start a fight yet.") == (
|
||||
"Keep this scene tense, but do not start a fight yet.")
|
||||
|
||||
# --- The marker survives, and is never doubled ---
|
||||
for kind in ("do", "say"):
|
||||
assert fmt(kind, "I move").startswith("> ")
|
||||
assert "> > " not in fmt("do", "I move")
|
||||
|
||||
|
||||
def test_normalized_player_text_reaches_history_exactly_once(client):
|
||||
"""The normalization must survive into the replayed context, unduplicated.
|
||||
|
||||
A `format_player_input` that is correct but applied twice, or correct in
|
||||
storage and re-prefixed on the way into the prompt, would put "You I ..."
|
||||
back in front of the model — which is the thing the defect was about. So
|
||||
this asserts on the assembled prompt, not on the stored row.
|
||||
"""
|
||||
_play(client, "I enter the tavern.")
|
||||
_play(client, "I ask Mara about Edrin.")
|
||||
|
||||
stored = [a["text"] for a in
|
||||
client.get(f"/api/adventures/{client.adv_id}/actions").json()["actions"]]
|
||||
player_rows = [t for t in stored if t.startswith(">")]
|
||||
assert player_rows, stored
|
||||
for row in player_rows:
|
||||
assert "You I" not in row, row
|
||||
assert row.count(">") == 1, row
|
||||
|
||||
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
||||
prompt = "\n".join(s["text"] for s in report["sections"])
|
||||
assert "You I " not in prompt, "the model's context was polluted with 'You I ...'"
|
||||
# Each player line appears once, with its marker, in the replayed history.
|
||||
for row in player_rows:
|
||||
assert prompt.count(row) == 1, f"{row!r} appears {prompt.count(row)} times"
|
||||
|
||||
Generated
+2352
-15
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
@@ -15,10 +17,15 @@
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"vite": "^8.1.1"
|
||||
"vite": "^8.1.1",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
+59
-43
@@ -1,50 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { NavLink, Outlet, useMatch } from 'react-router-dom'
|
||||
import { ToastHost } from './components'
|
||||
import Embers from './Embers.jsx'
|
||||
import { ModelStatusProvider } from './modelStatus'
|
||||
import { ModelStatusBadge } from './ModelStatusBadge'
|
||||
|
||||
// The shell: the nav bar and whatever page is routed under it.
|
||||
//
|
||||
// Upstream also carried the hosted deployment's account furniture here — a
|
||||
// /auth/me lookup on mount, guest and sign-up prompts, a log-out button, a
|
||||
// pageview beacon, and two nav links gated on server-side allowlists. M2
|
||||
// removed the deployment those served. There is one local user, nothing to log
|
||||
// in to, and nothing counting.
|
||||
/* The shell: a thin top bar and whatever page is routed under it.
|
||||
*
|
||||
* M8 reduced this to the two places that are not inside a campaign. Upstream
|
||||
* carried five links — Home, Adventures, Scenarios, Settings, AI Chat — which
|
||||
* described AI-DnD's information architecture rather than this product's.
|
||||
* `BROWSER-UX-SPEC.md` §97 has one entry point (the campaign library) and puts
|
||||
* everything else *inside* a campaign, where it belongs: a Save Point, a piece
|
||||
* of knowledge and a story state are all things a campaign has, and none of
|
||||
* them mean anything at the top level.
|
||||
*
|
||||
* What went, and why:
|
||||
*
|
||||
* Adventures the library is now the landing page, so a second link to a
|
||||
* second list of the same thing had nothing to point at.
|
||||
* Scenarios a scenario is a template for a campaign. The editor for one
|
||||
* was a schema editor, a story-card table and an art picker —
|
||||
* §93's "dangerous advanced features" almost exactly. Creating a
|
||||
* campaign no longer requires one.
|
||||
* AI Chat a raw model console. Useful to whoever was debugging the
|
||||
* fork; nothing to do with telling a story.
|
||||
*
|
||||
* The story screen deliberately does *not* show this bar's campaign links. It
|
||||
* has its own header, because on the one screen that matters the story is the
|
||||
* interface.
|
||||
*/
|
||||
export default function App() {
|
||||
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
|
||||
// The story screen owns its whole viewport, so the shell gets out of the way.
|
||||
const playing = useMatch('/play/:id')
|
||||
|
||||
return (
|
||||
<ToastHost>
|
||||
<Embers />
|
||||
<nav className="topnav">
|
||||
<span className="brand">⚔ Adventure Storyteller</span>
|
||||
<button
|
||||
className="nav-hamburger"
|
||||
aria-label="Menu"
|
||||
aria-expanded={navOpen}
|
||||
onClick={() => setNavOpen((o) => !o)}
|
||||
>
|
||||
{navOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
<div className={`nav-links${navOpen ? ' open' : ''}`} onClick={() => setNavOpen(false)}>
|
||||
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/adventures" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Adventures
|
||||
</NavLink>
|
||||
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Scenarios
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Settings
|
||||
</NavLink>
|
||||
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
AI Chat
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</ToastHost>
|
||||
<ModelStatusProvider>
|
||||
<ToastHost>
|
||||
<a className="skip-link" href="#main">Skip to main content</a>
|
||||
{!playing && (
|
||||
<nav className="topnav" aria-label="Main">
|
||||
<NavLink to="/" className="brand">Adventure Storyteller</NavLink>
|
||||
<div className="nav-links">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}
|
||||
>
|
||||
Campaigns
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}
|
||||
>
|
||||
Settings
|
||||
</NavLink>
|
||||
</div>
|
||||
<ModelStatusBadge />
|
||||
</nav>
|
||||
)}
|
||||
<main id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ToastHost>
|
||||
</ModelStatusProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { ScenarioArt } from './components'
|
||||
|
||||
// Cards render the plate at 46px (92px on a 2x screen), and the editor preview
|
||||
// at 76px. 400px square gives headroom for both plus any future larger use,
|
||||
// while keeping the stored data URI in the tens of kilobytes.
|
||||
const MAX_EDGE = 400
|
||||
const WEBP_QUALITY = 0.82
|
||||
// The backend caps the column at 400_000 chars; stay clearly under it so a
|
||||
// pathological image fails here with a clear message rather than as a 422.
|
||||
const MAX_DATA_URI = 360_000
|
||||
|
||||
// A spread of moods rather than a themed set — most scenarios find something
|
||||
// close enough here and skip hunting for a picture.
|
||||
const SUGGESTED_ICONS = [
|
||||
'⚔️', '🗡️', '🏹', '🛡️', '🔮', '🗝️', '📜', '🏰',
|
||||
'🐉', '👑', '💀', '🕯️', '🌑', '🌲', '⛰️', '🌊',
|
||||
'🚀', '🛰️', '🤖', '👁️', '🩸', '🃏',
|
||||
]
|
||||
|
||||
/** Read a File and re-encode it small, as a WebP data URI.
|
||||
*
|
||||
* Downscaling in the browser means the upload never leaves the machine at full
|
||||
* size and the row stays small — no server-side image library required.
|
||||
*/
|
||||
function downscaleToDataURI(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(new Error('Could not read that file'))
|
||||
reader.onload = () => {
|
||||
const img = new Image()
|
||||
img.onerror = () => reject(new Error('That file is not an image the browser can read'))
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height))
|
||||
const canvas = document.createElement('canvas')
|
||||
// Math.max(1, …) guards against a 0-dimension canvas, which throws.
|
||||
canvas.width = Math.max(1, Math.round(img.width * scale))
|
||||
canvas.height = Math.max(1, Math.round(img.height * scale))
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
let uri = canvas.toDataURL('image/webp', WEBP_QUALITY)
|
||||
// Safari only gained WebP encoding recently; if it silently fell back
|
||||
// to PNG, retry as JPEG so we don't store a huge lossless blob.
|
||||
if (!uri.startsWith('data:image/webp')) {
|
||||
uri = canvas.toDataURL('image/jpeg', WEBP_QUALITY)
|
||||
}
|
||||
if (uri.length > MAX_DATA_URI) {
|
||||
reject(new Error('That image is too detailed to store — try a smaller or simpler one'))
|
||||
return
|
||||
}
|
||||
resolve(uri)
|
||||
}
|
||||
img.src = reader.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** Cover-art picker: upload a picture, choose an emoji, or leave it generated.
|
||||
*
|
||||
* `image` and `icon` are the stored values; `onChange({ image, icon })` gets
|
||||
* both on every change so the caller can persist them together.
|
||||
*/
|
||||
export default function ArtPicker({ title, image, icon, onChange, disabled }) {
|
||||
const fileRef = useRef(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pick = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
// Reset immediately so picking the same file twice still fires onChange.
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
setError('')
|
||||
try {
|
||||
const uri = await downscaleToDataURI(file)
|
||||
// An uploaded picture wins over an emoji, so clear the icon to make the
|
||||
// precedence visible rather than leaving a hidden value behind.
|
||||
onChange({ image: uri, icon: '' })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const chooseIcon = (glyph) => {
|
||||
setError('')
|
||||
onChange({ image: '', icon: icon === glyph ? '' : glyph })
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setError('')
|
||||
onChange({ image: '', icon: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="label">Cover art</span>
|
||||
<div className="art-picker">
|
||||
<span className="art-preview">
|
||||
{/* Same three-tier fallback the cards use, at preview size. */}
|
||||
<ScenarioArt image={image} icon={icon} title={title} />
|
||||
</span>
|
||||
|
||||
<div className="art-controls">
|
||||
<div className="art-buttons">
|
||||
<button type="button" disabled={disabled} onClick={() => fileRef.current?.click()}>
|
||||
Upload image
|
||||
</button>
|
||||
{(image || icon) && (
|
||||
<button type="button" disabled={disabled} onClick={clear}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
|
||||
hidden
|
||||
onChange={pick}
|
||||
/>
|
||||
|
||||
<div className="icon-row">
|
||||
{SUGGESTED_ICONS.map((glyph) => (
|
||||
<button
|
||||
key={glyph}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={icon === glyph ? 'active' : ''}
|
||||
title={`Use ${glyph}`}
|
||||
onClick={() => chooseIcon(glyph)}
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="art-hint" style={{ color: 'var(--danger)' }}>{error}</p>
|
||||
) : (
|
||||
<p className="art-hint">
|
||||
Pictures are shrunk to {MAX_EDGE}px before saving. With neither a picture
|
||||
nor an emoji, the card draws its own art from the title.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches'
|
||||
|
||||
// The story tree, drawn.
|
||||
//
|
||||
// The Branches panel lists every line; this draws the same list as lanes on one
|
||||
// clock, which is the thing a list cannot say — *where* two tellings parted, and
|
||||
// how much story each of them is. A lane runs from the moment its branch left
|
||||
// its parent to the moment it ends, so length is story and a fork is a corner.
|
||||
//
|
||||
// It reads nothing of its own. Everything here comes from the `GET /branches`
|
||||
// the panel already made, and every operation goes back through the panel's, so
|
||||
// there is one copy of the rules and one thing to keep honest.
|
||||
//
|
||||
// Portalled to <body>: this is opened from inside `.side-panel`, whose panel-in
|
||||
// animation (fill mode `both`) makes it the containing block for position:fixed
|
||||
// children — an overlay rendered in place would be trapped in the 420px panel
|
||||
// and clipped by its overflow. Same trap for anything opened from a drawer.
|
||||
|
||||
// A label is drawn into the space its lane leaves. `perChar` is the average
|
||||
// width of the face it is drawn in — about 7px for the 15px serif a name uses
|
||||
// and 5px for the 10.5px UI face under it. Overshooting only costs an ellipsis,
|
||||
// so this is deliberately a guess rather than a measurement pass.
|
||||
function clipToWidth(text, px, perChar = 7) {
|
||||
const max = Math.max(4, Math.floor(px / perChar))
|
||||
return text.length <= max ? text : `${text.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onClose }) {
|
||||
const [selectedId, setSelectedId] = useState(() => branches.find((b) => b.is_head)?.id ?? null)
|
||||
const [renameText, setRenameText] = useState(null) // null = not renaming
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [width, setWidth] = useState(0)
|
||||
const boxRef = useRef(null)
|
||||
|
||||
const { lanes, maxDepth, height } = useMemo(() => layoutTree(branches), [branches])
|
||||
const lineage = useMemo(() => headLineage(branches), [branches])
|
||||
|
||||
|
||||
// The map is drawn in real pixels rather than scaled from a fixed viewBox,
|
||||
// so a narrow window gets a narrower map and not smaller writing.
|
||||
//
|
||||
// Both halves are load-bearing, and each covers the other's gap. The seed
|
||||
// measures the *content* box, because that is what the observer reports and
|
||||
// `clientWidth` is not — it counts the canvas padding, so seeding from it
|
||||
// drew an svg 24px wider than the box it sits in and the map ran off the
|
||||
// right edge. The observer then keeps up with a window being dragged; it
|
||||
// cannot be the only source, because its initial observation is not
|
||||
// guaranteed to arrive and without the seed the map never drew at all.
|
||||
useLayoutEffect(() => {
|
||||
const el = boxRef.current
|
||||
if (!el) return undefined
|
||||
const style = getComputedStyle(el)
|
||||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||
setWidth(el.clientWidth - padding)
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// A branch can vanish under the selection — deleting one takes everything
|
||||
// forked from it, which is more rows than the one that was clicked.
|
||||
const selected = branches.find((b) => b.id === selectedId) || null
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setSelectedId(branches.find((b) => b.is_head)?.id ?? null)
|
||||
setRenameText(null)
|
||||
setConfirming(false)
|
||||
}
|
||||
}, [selected, branches])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
// Escape backs out of the smallest thing that is open, so it never
|
||||
// throws away a half-typed name along with the map.
|
||||
if (renameText !== null) setRenameText(null)
|
||||
else if (confirming) setConfirming(false)
|
||||
else onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [renameText, confirming, onClose])
|
||||
|
||||
const W = Math.max(width, 320)
|
||||
const span = Math.max(maxDepth, 1)
|
||||
// Ticks are spaced by the room there is to print them in, not by a constant.
|
||||
const ticks = momentTicks(maxDepth, Math.max(3, Math.round(W / 160)))
|
||||
const x = (depth) => PAD.left + (depth / span) * (W - PAD.left - PAD.right)
|
||||
const laneY = (row) => PAD.top + row * ROW_H + 30
|
||||
|
||||
const pick = (branch) => {
|
||||
setSelectedId(branch.id)
|
||||
setRenameText(null)
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
const busy = busyId !== null && busyId !== undefined
|
||||
// The server refuses to delete the line being read or anything it was forked
|
||||
// from; saying so on the button is friendlier than a toast after the click.
|
||||
const isLoadBearing = selected ? lineage.has(selected.id) : false
|
||||
const isRoot = selected ? selected.parent_branch_id === null : false
|
||||
|
||||
// Each of these resolves to whether it worked. The panel owns the request
|
||||
// and reports its own failures, so all this has to decide is whether to put
|
||||
// the editor away — clearing a half-typed name on a rename that was refused
|
||||
// would throw away the only copy of it.
|
||||
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
|
||||
const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
|
||||
// Save Points naming a moment on the selected branch, or on anything forked
|
||||
// from it, keep it alive — the server refuses to delete them along with it.
|
||||
const protecting = selected ? savePointsUnder(branches, selected.id) : 0
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
|
||||
<div className="branch-map" onClick={(e) => e.stopPropagation()}
|
||||
role="dialog" aria-modal="true" aria-label="The story so far">
|
||||
<div className="branch-map-header">
|
||||
<h2>The story so far</h2>
|
||||
<span className="branch-map-scale">
|
||||
{branches.length} {branches.length === 1 ? 'line' : 'lines'} · {maxDepth + 1} moments
|
||||
</span>
|
||||
<button type="button" className="branch-map-close" onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="branch-map-canvas" ref={boxRef}>
|
||||
{width > 0 && (
|
||||
<svg className="branch-map-svg" width={W} height={height}
|
||||
role="img" aria-label={`${branches.length} branches over ${maxDepth + 1} moments`}>
|
||||
{/* The clock the whole map is read against. */}
|
||||
{ticks.map((depth) => (
|
||||
<g key={depth} className="bm-tick">
|
||||
<line x1={x(depth)} y1={PAD.top - 16} x2={x(depth)} y2={height - PAD.bottom} />
|
||||
<text x={x(depth)} y={PAD.top - 24} textAnchor="middle">{depth + 1}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{lanes.map((lane) => {
|
||||
const child = lane.branch.parent_branch_id !== null && lane.parentRow !== null
|
||||
const forkX = x(lane.from)
|
||||
const start = child ? forkX + CORNER : x(lane.from)
|
||||
// A branch forked but not yet written past still gets a stub,
|
||||
// or it would be a corner leading to nothing.
|
||||
const end = Math.max(x(lane.to), start + 8)
|
||||
const y = laneY(lane.row)
|
||||
const b = lane.branch
|
||||
const label = branchLabel(b)
|
||||
const meta = `${b.own_actions} of its own`
|
||||
+ (b.parent_branch_id !== null ? ` · left at ${b.fork_depth + 1}` : '')
|
||||
+ ` · ends at ${b.depth + 1}`
|
||||
// A lane that leaves late has no room to write in to its right,
|
||||
// so its labels hang back over the fork instead. The row is its
|
||||
// own band with nothing else in it, and the alternative is text
|
||||
// running off the edge — which is what a narrow window did.
|
||||
const roomRight = W - PAD.right - start
|
||||
const flip = roomRight < 170 && start - PAD.left > roomRight
|
||||
const textX = flip ? start - 4 : start
|
||||
const room = flip ? start - 4 - PAD.left : roomRight
|
||||
const classes = [
|
||||
'bm-lane',
|
||||
b.is_head ? 'here' : '',
|
||||
b.id === selectedId ? 'picked' : '',
|
||||
].join(' ')
|
||||
return (
|
||||
<g key={b.id} className={classes}
|
||||
role="button" tabIndex={0}
|
||||
aria-label={`${label}, ${b.own_actions} of its own${b.is_head ? ', the line you are reading' : ''}`}
|
||||
onClick={() => pick(b)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(b) }
|
||||
}}>
|
||||
{/* Full-width hit area: the lane itself is 3px tall and a
|
||||
fork late in a long story is a very small target. */}
|
||||
<rect className="bm-hit" x={0} y={PAD.top + lane.row * ROW_H}
|
||||
width={W} height={ROW_H} />
|
||||
|
||||
{child && (
|
||||
<path className="bm-fork"
|
||||
d={`M ${forkX} ${laneY(lane.parentRow)} L ${forkX} ${y - CORNER} Q ${forkX} ${y} ${forkX + CORNER} ${y}`} />
|
||||
)}
|
||||
{child && <circle className="bm-fork-dot" cx={forkX} cy={laneY(lane.parentRow)} r={3.5} />}
|
||||
|
||||
<line className="bm-line" x1={start} y1={y} x2={end} y2={y} />
|
||||
|
||||
{/* The tip: a diamond for the line being read, so where you
|
||||
are standing is findable without reading a word. */}
|
||||
{b.is_head ? (
|
||||
<rect className="bm-tip" x={end - 5} y={y - 5} width={10} height={10}
|
||||
transform={`rotate(45 ${end} ${y})`} />
|
||||
) : (
|
||||
<circle className="bm-tip" cx={end} cy={y} r={4.5} />
|
||||
)}
|
||||
|
||||
<text className="bm-name" x={textX} y={y - 12}
|
||||
textAnchor={flip ? 'end' : 'start'}>
|
||||
{clipToWidth(label, room)}
|
||||
</text>
|
||||
<text className="bm-meta" x={textX} y={y + 19}
|
||||
textAnchor={flip ? 'end' : 'start'}>
|
||||
{clipToWidth(meta, room, 5)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{branches.length === 1 && (
|
||||
<p className="branch-map-hint">
|
||||
One thread so far. Retry a turn, then write on from an attempt the
|
||||
story moved past — that is what makes a second line.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className={`branch-map-detail ${selected.is_head ? 'here' : ''}`}>
|
||||
<div className="bmd-head">
|
||||
{renameText !== null ? (
|
||||
<input
|
||||
className="branch-rename"
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
value={renameText}
|
||||
onChange={(e) => setRenameText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') save()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="branch-name">{branchLabel(selected)}</span>
|
||||
)}
|
||||
{selected.is_head && <span className="branch-here">reading</span>}
|
||||
</div>
|
||||
|
||||
<div className="branch-meta">
|
||||
{selected.own_actions} of its own
|
||||
{selected.parent_branch_id !== null && ` · forked at moment ${selected.fork_depth + 1}`}
|
||||
{` · ends at moment ${selected.depth + 1}`}
|
||||
</div>
|
||||
|
||||
{confirming ? (
|
||||
<div className="branch-confirm">
|
||||
{/* The same wording the list gives, because the same deletion
|
||||
is reachable from both views and a rule that held in one of
|
||||
them would not be a rule. */}
|
||||
<span>
|
||||
Delete this branch and everything forked from it? The story on
|
||||
other paths, and every Save Point, is unaffected.
|
||||
</span>
|
||||
<button type="button" className="danger" disabled={busy} onClick={drop}>
|
||||
Delete
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)}>Keep</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="branch-tools">
|
||||
{!selected.is_head && (
|
||||
<button type="button" disabled={busy}
|
||||
onClick={() => onSwitch(selected)}>Switch to this line</button>
|
||||
)}
|
||||
{renameText !== null ? (
|
||||
<>
|
||||
<button type="button" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" onClick={() => setRenameText(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" disabled={busy}
|
||||
onClick={() => setRenameText(selected.name || '')}>Rename</button>
|
||||
)}
|
||||
{/* The root holds the turns every other branch borrows, and the
|
||||
server refuses it — so it is not offered at all. A branch
|
||||
the head stands on is offered, and says why it cannot go. */}
|
||||
{!isRoot && (
|
||||
<button type="button" className="danger"
|
||||
disabled={busy || isLoadBearing || protecting > 0}
|
||||
title={
|
||||
isLoadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: protecting > 0
|
||||
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setConfirming(true)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* Modal dialogs, with the focus behaviour §28 requires.
|
||||
*
|
||||
* Three things have to be true for a dialog to be usable without a mouse, and
|
||||
* all three are easy to leave out:
|
||||
*
|
||||
* 1. focus moves *into* the dialog when it opens,
|
||||
* 2. Tab cannot leave it while it is open,
|
||||
* 3. focus returns to whatever opened it when it closes.
|
||||
*
|
||||
* Written by hand rather than with the native `<dialog>` element. `showModal()`
|
||||
* gives 1-3 for free, but jsdom does not implement it, so every component test
|
||||
* covering a confirmation would have to stub it out — and a focus trap that is
|
||||
* stubbed in the tests is a focus trap nothing checks. This version is a plain
|
||||
* element tree, so the tests exercise the same code the browser runs.
|
||||
*
|
||||
* `ConfirmDialog` adds the typed-name confirmation §67 asks for on the one
|
||||
* destructive action that deserves it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
export function Dialog({ title, children, onClose, labelledBy, className = '' }) {
|
||||
const ref = useRef(null)
|
||||
const returnTo = useRef(null)
|
||||
const headingId = useId()
|
||||
const id = labelledBy || headingId
|
||||
|
||||
useEffect(() => {
|
||||
// Remember where focus was, so it can go back there on close.
|
||||
returnTo.current = document.activeElement
|
||||
const node = ref.current
|
||||
if (node) {
|
||||
const first = node.querySelector(FOCUSABLE)
|
||||
// The panel itself is focusable as a fallback, so a dialog with no
|
||||
// controls still moves focus off the page behind it.
|
||||
;(first || node).focus()
|
||||
}
|
||||
return () => {
|
||||
const back = returnTo.current
|
||||
if (back && typeof back.focus === 'function' && document.contains(back)) back.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onKeyDown = useCallback((event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
// Hidden controls are skipped by attribute rather than by layout. An
|
||||
// `offsetParent !== null` check reads more natural and is wrong twice: it
|
||||
// is null for anything inside a `position: fixed` ancestor, which this
|
||||
// dialog is, and jsdom never computes it at all — so the trap would behave
|
||||
// differently in the tests from the browser, which is the one thing a focus
|
||||
// trap must not do.
|
||||
const items = Array.from(node.querySelectorAll(FOCUSABLE))
|
||||
.filter((el) => !el.hidden && el.getAttribute('aria-hidden') !== 'true')
|
||||
if (items.length === 0) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
// Wrap at both ends. Without this, Tab off the last control lands on the
|
||||
// browser chrome and the reader is outside a modal they cannot see they
|
||||
// have left.
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onMouseDown={(e) => {
|
||||
// Only a click on the backdrop itself closes; a drag that ends there
|
||||
// after starting inside the panel must not.
|
||||
if (e.target === e.currentTarget) onClose?.()
|
||||
}}>
|
||||
<div
|
||||
className={`dialog ${className}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={id}
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<h2 className="dialog-title" id={id}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
children,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
requireText = null,
|
||||
requireLabel = 'Type to confirm',
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
const [typed, setTyped] = useState('')
|
||||
const fieldId = useId()
|
||||
const ready = requireText === null || typed.trim() === String(requireText).trim()
|
||||
|
||||
return (
|
||||
<Dialog title={title} onClose={onCancel}>
|
||||
<div className="dialog-body">{children}</div>
|
||||
{requireText !== null && (
|
||||
<label className="dialog-field" htmlFor={fieldId}>
|
||||
<span>{requireLabel}</span>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="text"
|
||||
value={typed}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && ready) onConfirm() }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button type="button" onClick={onCancel} disabled={busy}>{cancelLabel}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={destructive ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
disabled={busy || !ready}
|
||||
>
|
||||
{busy ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* The dialog's focus behaviour, which is the part §28 actually requires and the
|
||||
* part that is invisible until someone tries to use the product without a mouse.
|
||||
*/
|
||||
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConfirmDialog, Dialog } from './Dialog'
|
||||
|
||||
describe('focus (§28)', () => {
|
||||
it('moves focus into the dialog when it opens', async () => {
|
||||
render(
|
||||
<Dialog title="A question" onClose={vi.fn()}>
|
||||
<button type="button">Inside</button>
|
||||
</Dialog>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Inside' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('returns focus to whatever opened it', async () => {
|
||||
const user = userEvent.setup()
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>Open</button>
|
||||
{open && (
|
||||
<Dialog title="A question" onClose={() => setOpen(false)}>
|
||||
<button type="button" onClick={() => setOpen(false)}>Close</button>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
render(<Harness />)
|
||||
const opener = screen.getByRole('button', { name: 'Open' })
|
||||
await user.click(opener)
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(opener).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wraps Tab at both ends rather than letting focus escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Dialog title="A question" onClose={vi.fn()}>
|
||||
<button type="button">First</button>
|
||||
<button type="button">Last</button>
|
||||
</Dialog>,
|
||||
)
|
||||
const first = screen.getByRole('button', { name: 'First' })
|
||||
const last = screen.getByRole('button', { name: 'Last' })
|
||||
expect(first).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(last).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(first).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Dialog title="A question" onClose={onClose}>
|
||||
<button type="button">Inside</button>
|
||||
</Dialog>,
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is announced as a modal dialog with its title as the name', () => {
|
||||
render(
|
||||
<Dialog title="Delete this?" onClose={vi.fn()}>
|
||||
<button type="button">Inside</button>
|
||||
</Dialog>,
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(dialog).toHaveAccessibleName('Delete this?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('typed confirmation (§67)', () => {
|
||||
it('keeps the destructive action disabled until the name is typed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<ConfirmDialog
|
||||
title="Delete “Westhaven”?"
|
||||
confirmLabel="Delete this campaign"
|
||||
destructive
|
||||
requireText="Westhaven"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<p>This cannot be undone.</p>
|
||||
</ConfirmDialog>,
|
||||
)
|
||||
const confirm = screen.getByRole('button', { name: 'Delete this campaign' })
|
||||
expect(confirm).toBeDisabled()
|
||||
await user.type(screen.getByRole('textbox'), 'Westhaven')
|
||||
expect(confirm).toBeEnabled()
|
||||
await user.click(confirm)
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a near miss', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ConfirmDialog
|
||||
title="Delete?" requireText="Westhaven"
|
||||
onConfirm={vi.fn()} onCancel={vi.fn()}
|
||||
>
|
||||
<p>x</p>
|
||||
</ConfirmDialog>,
|
||||
)
|
||||
await user.type(screen.getByRole('textbox'), 'Westhaben')
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('needs no typing when no name is required', () => {
|
||||
render(
|
||||
<ConfirmDialog title="Restore?" onConfirm={vi.fn()} onCancel={vi.fn()}>
|
||||
<p>x</p>
|
||||
</ConfirmDialog>,
|
||||
)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('cancels without acting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<ConfirmDialog title="Delete?" onConfirm={onConfirm} onCancel={onCancel}>
|
||||
<p>x</p>
|
||||
</ConfirmDialog>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows what the dialog is about', () => {
|
||||
render(
|
||||
<ConfirmDialog title="Delete?" onConfirm={vi.fn()} onCancel={vi.fn()}>
|
||||
<p>Turns that already used it are unchanged.</p>
|
||||
</ConfirmDialog>,
|
||||
)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText(/already used it are unchanged/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// Motes per million CSS pixels of viewport. Tuned so a laptop gets ~55 and a
|
||||
// phone ~20: enough to read as drifting dust, few enough to stay cheap.
|
||||
const DENSITY = 34
|
||||
const MAX_PARTICLES = 90
|
||||
|
||||
/** Slow-drifting gold motes behind the whole app.
|
||||
*
|
||||
* Fixed, non-interactive, and drawn on one canvas. It sits below every page in
|
||||
* the stacking order and never receives pointer events, so it cannot affect
|
||||
* layout or intercept clicks. Disabled outright for `prefers-reduced-motion`,
|
||||
* and paused whenever the tab is hidden so a backgrounded tab costs nothing.
|
||||
*/
|
||||
export default function Embers() {
|
||||
const canvasRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (reduced.matches) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Cap the backing store at 2x: beyond that the cost is real and nobody can
|
||||
// see the difference on a blurred mote.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
let width = 0
|
||||
let height = 0
|
||||
let particles = []
|
||||
let frame = 0
|
||||
|
||||
const spawn = (scattered) => ({
|
||||
x: Math.random() * width,
|
||||
// New motes enter from just below the fold; the first batch is scattered
|
||||
// across the whole height so the screen isn't empty on load.
|
||||
y: scattered ? Math.random() * height : height + Math.random() * 40,
|
||||
radius: 0.6 + Math.random() * 1.6,
|
||||
speed: 0.08 + Math.random() * 0.3,
|
||||
sway: Math.random() * Math.PI * 2,
|
||||
swaySpeed: 0.004 + Math.random() * 0.01,
|
||||
alpha: 0.12 + Math.random() * 0.4,
|
||||
})
|
||||
|
||||
const resize = () => {
|
||||
width = window.innerWidth
|
||||
height = window.innerHeight
|
||||
canvas.width = Math.round(width * dpr)
|
||||
canvas.height = Math.round(height * dpr)
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const target = Math.min(
|
||||
MAX_PARTICLES,
|
||||
Math.max(12, Math.round((width * height * DENSITY) / 1_000_000)),
|
||||
)
|
||||
particles = Array.from({ length: target }, () => spawn(true))
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
p.sway += p.swaySpeed
|
||||
p.y -= p.speed
|
||||
p.x += Math.sin(p.sway) * 0.28
|
||||
if (p.y < -8) {
|
||||
particles[i] = spawn(false)
|
||||
continue
|
||||
}
|
||||
// Fade out toward the top of the screen so motes dissolve rather than
|
||||
// vanishing at the edge.
|
||||
const fade = Math.min(1, p.y / height)
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 4)
|
||||
glow.addColorStop(0, `rgba(232, 196, 118, ${(p.alpha * fade).toFixed(3)})`)
|
||||
glow.addColorStop(1, 'rgba(212, 169, 78, 0)')
|
||||
ctx.fillStyle = glow
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.radius * 4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
frame = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (!frame) frame = requestAnimationFrame(draw)
|
||||
}
|
||||
const stop = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
const onVisibility = () => (document.hidden ? stop() : start())
|
||||
|
||||
resize()
|
||||
start()
|
||||
window.addEventListener('resize', resize)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
stop()
|
||||
window.removeEventListener('resize', resize)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} className="embers" aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Leaving the local-only environment, deliberately.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §74. A URL can arrive in the story from two places the
|
||||
* reader did not write: the narrator can invent one, and an imported file can
|
||||
* carry one. Neither is a reason to distrust the reader's judgement, but both
|
||||
* are a reason not to navigate silently — the whole product runs on this
|
||||
* machine, and following a link is the one ordinary action that leaves it.
|
||||
*
|
||||
* The address is shown in full, as text, before anything is opened. That is the
|
||||
* point: a link whose text says one thing and whose href says another is the
|
||||
* oldest trick there is, and the only defence that works is showing the reader
|
||||
* where they are actually going.
|
||||
*
|
||||
* Opening uses `noopener`, so the destination gets no handle on this window.
|
||||
*/
|
||||
|
||||
import { Dialog } from './Dialog'
|
||||
|
||||
export function ExternalLinkDialog({ href, onClose }) {
|
||||
let shown = href
|
||||
try {
|
||||
shown = new URL(href, window.location.origin).toString()
|
||||
} catch { /* show the raw text if it will not parse */ }
|
||||
|
||||
return (
|
||||
<Dialog title="This link leaves the storyteller" onClose={onClose}>
|
||||
<div className="dialog-body">
|
||||
<p>
|
||||
Everything else in this application stays on your machine. Opening this
|
||||
link goes out to the internet.
|
||||
</p>
|
||||
<p className="external-href"><code>{shown}</code></p>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" onClick={onClose}>Stay here</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => {
|
||||
window.open(shown, '_blank', 'noopener,noreferrer')
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open in a new tab
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* The way out of a blank model configuration.
|
||||
*
|
||||
* This is M8 §8's carried debt, and the debt was not that the error was ugly —
|
||||
* it was that the reader had nothing to *do*. `Settings.model` could be empty,
|
||||
* nothing said so, and play failed on the first turn with a provider message.
|
||||
*
|
||||
* So this component is a fix rather than a warning. When the endpoint is
|
||||
* reachable it lists the models actually installed there, from the connection
|
||||
* test the settings screen already had, and choosing one writes it back and
|
||||
* re-tests. Nothing is chosen automatically: picking the first model in the
|
||||
* listing would silently narrate a campaign with whatever happened to sort
|
||||
* first — possibly an embedding model, which cannot narrate at all — and the
|
||||
* milestone brief rules that out in the absence of a product rule authorizing it.
|
||||
*
|
||||
* When the endpoint is *not* reachable there is no list to offer, so it gives
|
||||
* the local troubleshooting §44 asks for instead.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useModelStatus } from './modelStatus'
|
||||
|
||||
export function ModelSetupNotice({ compact = false }) {
|
||||
const { status, models, model, endpoint, detail, chooseModel, refresh } = useModelStatus()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState(null)
|
||||
|
||||
const pick = async (name) => {
|
||||
setBusy(true)
|
||||
setFailed(null)
|
||||
try {
|
||||
await chooseModel(name)
|
||||
} catch (err) {
|
||||
setFailed(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'ready' || status === 'checking') return null
|
||||
|
||||
return (
|
||||
<div className="notice model-setup" role="status" data-testid="model-setup-notice">
|
||||
{status === 'unavailable' && (
|
||||
<>
|
||||
<strong>Ollama is not reachable</strong>
|
||||
<p>
|
||||
The storyteller narrates with a model running on your own machine or
|
||||
your own network. Nothing here works until it can reach one.
|
||||
</p>
|
||||
{detail && <p className="notice-detail">{detail}</p>}
|
||||
<ol className="notice-steps">
|
||||
<li>Install Ollama, if it is not installed.</li>
|
||||
<li>Start it: <code>ollama serve</code></li>
|
||||
<li>
|
||||
Pull a model to narrate with, for example{' '}
|
||||
<code>ollama pull qwen2.5:3b-instruct</code>
|
||||
</li>
|
||||
<li>
|
||||
Check the endpoint in Settings. It is currently{' '}
|
||||
<code>{endpoint || '(not set)'}</code>.
|
||||
</li>
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'no-model' && (
|
||||
<>
|
||||
<strong>Choose a narrator model</strong>
|
||||
<p>
|
||||
Ollama is running, but no model has been chosen to tell the story.
|
||||
These are installed on it:
|
||||
</p>
|
||||
{models.length > 0 ? (
|
||||
<ul className="model-choices">
|
||||
{models.map((name) => (
|
||||
<li key={name}>
|
||||
<button type="button" disabled={busy} onClick={() => pick(name)}>
|
||||
{name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>
|
||||
It has no models installed. Pull one first, for example{' '}
|
||||
<code>ollama pull qwen2.5:3b-instruct</code>, then{' '}
|
||||
<button type="button" className="linklike" onClick={refresh}>check again</button>.
|
||||
</p>
|
||||
)}
|
||||
{/* An embedding model in the list is a real trap — it narrates
|
||||
nothing and fails obscurely — so say so rather than filtering the
|
||||
list on a name pattern that would be wrong for some model. */}
|
||||
{models.some((m) => m.includes('embed')) && (
|
||||
<p className="field-hint">
|
||||
A model with “embed” in its name is for searching your imported
|
||||
material, not for narrating.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'missing-model' && (
|
||||
<>
|
||||
<strong>The chosen model is not installed</strong>
|
||||
<p>
|
||||
This campaign is set to narrate with <code>{model}</code>, which the
|
||||
endpoint does not have. Pull it there with{' '}
|
||||
<code>ollama pull {model}</code>, or choose one it does have:
|
||||
</p>
|
||||
<ul className="model-choices">
|
||||
{models.map((name) => (
|
||||
<li key={name}>
|
||||
<button type="button" disabled={busy} onClick={() => pick(name)}>
|
||||
{name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{failed && <p className="notice-detail error">{failed}</p>}
|
||||
{!compact && (
|
||||
<p className="notice-foot">
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* "Ollama: Connected — qwen2.5:3b-instruct", or the reason it is not.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §44. It is a status line, not a control: the only thing
|
||||
* it does is link to the place the problem is fixed. Rendered as a `<Link>`
|
||||
* rather than a button because it navigates, which is what a screen reader
|
||||
* should be told it does.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { blocksPlay, statusLabel, useModelStatus } from './modelStatus'
|
||||
|
||||
export function ModelStatusBadge({ compact = false }) {
|
||||
const { status, model, endpoint } = useModelStatus()
|
||||
const bad = blocksPlay(status)
|
||||
const label = statusLabel(status)
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/settings"
|
||||
className={`model-badge ${bad ? 'bad' : ''} ${status === 'checking' ? 'checking' : ''}`}
|
||||
data-testid="model-status"
|
||||
data-status={status}
|
||||
// The visible text is two short fragments; the accessible name is the
|
||||
// whole sentence, including the endpoint, which sighted readers get from
|
||||
// Settings rather than from a badge in a nav bar.
|
||||
aria-label={
|
||||
status === 'ready'
|
||||
? `${label}. Narrator model ${model}. Endpoint ${endpoint}. Open Settings.`
|
||||
: `${label}. Open Settings.`
|
||||
}
|
||||
>
|
||||
<span className="model-badge-dot" aria-hidden="true" />
|
||||
<span className="model-badge-text">{label}</span>
|
||||
{!compact && status === 'ready' && model && (
|
||||
<span className="model-badge-model">{model}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { npcInitials } from './components'
|
||||
|
||||
// Rebuild an object with one key renamed, preserving order. Returns null on a
|
||||
// no-op or a collision (so the caller keeps the old object).
|
||||
function withRenamedKey(obj, oldKey, newKey) {
|
||||
if (!newKey || oldKey === newKey || obj[newKey] !== undefined) return null
|
||||
const next = {}
|
||||
for (const [k, v] of Object.entries(obj)) next[k === oldKey ? newKey : k] = v
|
||||
return next
|
||||
}
|
||||
|
||||
// A key/id input that commits on blur (renaming a key mid-keystroke would
|
||||
// rebuild the parent object and steal focus).
|
||||
function KeyInput({ value, onCommit, placeholder }) {
|
||||
const [v, setV] = useState(value)
|
||||
useEffect(() => { setV(value) }, [value])
|
||||
const commit = () => {
|
||||
const trimmed = v.trim()
|
||||
if (trimmed && trimmed !== value) onCommit(trimmed)
|
||||
else setV(value)
|
||||
}
|
||||
return (
|
||||
<input className="se-key" value={v} placeholder={placeholder}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} />
|
||||
)
|
||||
}
|
||||
|
||||
// Every control in this editor wears the same tiny caption — that consistency
|
||||
// is what keeps the mixed row types (stats, flags, milestones, NPCs) reading
|
||||
// as one form rather than five.
|
||||
function Field({ label, className = '', children }) {
|
||||
return (
|
||||
<label className={`se-field ${className}`}>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Num({ label, value, onChange }) {
|
||||
return (
|
||||
<Field label={label} className="se-num">
|
||||
<input type="number" value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
||||
const set = (field, val) => {
|
||||
const next = { ...def }
|
||||
if (val === undefined || val === '') delete next[field]
|
||||
else next[field] = val
|
||||
onChange(next)
|
||||
}
|
||||
const isText = def.type === 'text'
|
||||
const bands = Array.isArray(def.bands) ? def.bands : []
|
||||
const setBands = (nb) => set('bands', nb.length ? nb : undefined)
|
||||
const updBand = (i, j, raw) => {
|
||||
const nb = bands.map((b) => (Array.isArray(b) ? [...b] : [0, 0, '']))
|
||||
while (nb[i].length < 3) nb[i].push(j < 2 ? 0 : '')
|
||||
nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw
|
||||
setBands(nb)
|
||||
}
|
||||
const setType = (val) => {
|
||||
const next = { ...def, type: val || undefined }
|
||||
if (val === 'text') {
|
||||
// Numeric-only fields don't apply to free text.
|
||||
delete next.min; delete next.max; delete next.max_delta_per_turn; delete next.bands
|
||||
if (typeof next.initial !== 'string') next.initial = ''
|
||||
} else if (typeof next.initial === 'string') {
|
||||
delete next.initial
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
return (
|
||||
<div className="se-row">
|
||||
<div className="se-row-top">
|
||||
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
|
||||
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
||||
</div>
|
||||
<div className="se-fields">
|
||||
{isText ? (
|
||||
<Field label="initial" className="se-num se-initial-text">
|
||||
<input type="text" value={def.initial ?? ''}
|
||||
onChange={(e) => set('initial', e.target.value)} />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
|
||||
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
|
||||
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
|
||||
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
|
||||
</>
|
||||
)}
|
||||
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
|
||||
<Field label="kind" className="se-num se-kind">
|
||||
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
|
||||
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
|
||||
<option value="number">number</option>
|
||||
<option value="counter">counts up only</option>
|
||||
<option value="text">free text</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="description (shown to the AI)">
|
||||
<input className="se-text" value={def.desc || ''} placeholder="e.g. how badly wounded you are"
|
||||
onChange={(e) => set('desc', e.target.value || undefined)} />
|
||||
</Field>
|
||||
{!isText && (
|
||||
<div className="se-bands">
|
||||
<div className="se-sub-head">Bands — low, high, label</div>
|
||||
{bands.map((b, i) => (
|
||||
<div key={i} className="se-band">
|
||||
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
|
||||
onChange={(e) => updBand(i, 0, e.target.value)} />
|
||||
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
|
||||
onChange={(e) => updBand(i, 1, e.target.value)} />
|
||||
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
|
||||
onChange={(e) => updBand(i, 2, e.target.value)} />
|
||||
<button type="button" className="se-remove"
|
||||
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="se-add-sm"
|
||||
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatSection({ title, hint, defs, onChange, addLabel = '+ stat', nested }) {
|
||||
const entries = Object.entries(defs || {})
|
||||
const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
|
||||
const setDef = (k, val) => onChange({ ...defs, [k]: val })
|
||||
const remove = (k) => { const x = { ...defs }; delete x[k]; onChange(x) }
|
||||
const add = () => {
|
||||
let i = 1, key = 'stat'
|
||||
while (defs[key]) key = `stat${i++}`
|
||||
onChange({ ...defs, [key]: { min: 0, max: 100, initial: 0 } })
|
||||
}
|
||||
return (
|
||||
<div className={nested ? 'se-section se-section-nested' : 'se-section'}>
|
||||
<div className="se-section-head">
|
||||
<span className="se-section-title">{title}</span>
|
||||
<button type="button" className="se-add" onClick={add}>{addLabel}</button>
|
||||
</div>
|
||||
{hint && <p className="se-hint">{hint}</p>}
|
||||
{entries.length === 0 && <div className="se-empty">None yet.</div>}
|
||||
{entries.map(([k, d]) => (
|
||||
<StatEditor key={k} statKey={k} def={d || {}}
|
||||
onRenameKey={(nk) => rename(k, nk)}
|
||||
onChange={(nd) => setDef(k, nd)}
|
||||
onRemove={() => remove(k)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlagSection({ flags, onChange }) {
|
||||
const entries = Object.entries(flags || {})
|
||||
const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
|
||||
const setFlag = (k, val) => onChange({ ...flags, [k]: val })
|
||||
const remove = (k) => { const x = { ...flags }; delete x[k]; onChange(x) }
|
||||
const add = () => {
|
||||
let i = 1, key = 'flag'
|
||||
while (flags[key]) key = `flag${i++}`
|
||||
onChange({ ...flags, [key]: { initial: false, desc: '' } })
|
||||
}
|
||||
return (
|
||||
<div className="se-section">
|
||||
<div className="se-section-head">
|
||||
<span className="se-section-title">Flags</span>
|
||||
<button type="button" className="se-add" onClick={add}>+ flag</button>
|
||||
</div>
|
||||
<p className="se-hint">On/off switches the AI can flip either way — a door unlocked, an alarm raised.</p>
|
||||
{entries.length === 0 && <div className="se-empty">No flags.</div>}
|
||||
{entries.map(([k, f]) => (
|
||||
<div key={k} className="se-row">
|
||||
<div className="se-row-top">
|
||||
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
|
||||
<label className="se-check">
|
||||
<input type="checkbox" checked={!!f.initial}
|
||||
onChange={(e) => setFlag(k, { ...f, initial: e.target.checked })} />
|
||||
<span>on by default</span>
|
||||
</label>
|
||||
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
|
||||
</div>
|
||||
<Field label="description (shown to the AI)">
|
||||
<input className="se-text" value={f.desc || ''} placeholder="e.g. the cellar door is unlocked"
|
||||
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MilestoneSection({ milestones, onChange }) {
|
||||
const entries = Object.entries(milestones || {})
|
||||
const rename = (o, n) => { const x = withRenamedKey(milestones, o, n); if (x) onChange(x) }
|
||||
const setM = (k, val) => onChange({ ...milestones, [k]: val })
|
||||
const remove = (k) => { const x = { ...milestones }; delete x[k]; onChange(x) }
|
||||
const add = () => {
|
||||
let i = 1, key = 'goal'
|
||||
while (milestones[key]) key = `goal${i++}`
|
||||
onChange({ ...milestones, [key]: { desc: '' } })
|
||||
}
|
||||
return (
|
||||
<div className="se-section">
|
||||
<div className="se-section-head">
|
||||
<span className="se-section-title">Milestones</span>
|
||||
<button type="button" className="se-add" onClick={add}>+ milestone</button>
|
||||
</div>
|
||||
<p className="se-hint">Objectives that stick once reached — they never un-tick on their own.</p>
|
||||
{entries.length === 0 && <div className="se-empty">No milestones.</div>}
|
||||
{entries.map(([k, m]) => (
|
||||
<div key={k} className="se-row">
|
||||
<div className="se-row-top">
|
||||
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
|
||||
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
|
||||
</div>
|
||||
<Field label="objective">
|
||||
<input className="se-text" value={m?.desc || ''} placeholder="e.g. escaped the bandit camp"
|
||||
onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- NPCs ----------------------------------------------------------------
|
||||
// NPCs live inside the same stat_schema (`schema.npcs`) but get their own
|
||||
// top-level section in the editor: each one is a small character sheet, which
|
||||
// doesn't fit the flat stat rows the other sections use.
|
||||
|
||||
// Next `npcs` object with a fresh entry appended, and the id it used.
|
||||
export function addNpc(npcs) {
|
||||
let i = 1, key = 'npc'
|
||||
while (npcs?.[key]) key = `npc${i++}`
|
||||
return { ...(npcs || {}), [key]: { name: '', keys: '', desc: '', stats: {} } }
|
||||
}
|
||||
|
||||
function NpcCard({ npcId, npc, onRenameKey, onChange, onRemove }) {
|
||||
const set = (field, val) => onChange({ ...npc, [field]: val })
|
||||
const statCount = Object.keys(npc.stats || {}).length
|
||||
return (
|
||||
<div className="se-npc">
|
||||
<div className="se-npc-head">
|
||||
<span className="se-avatar" aria-hidden="true">{npcInitials(npc.name, npcId)}</span>
|
||||
<div className="se-npc-ident">
|
||||
<input className="se-npc-name" value={npc.name || ''} placeholder="Display name"
|
||||
onChange={(e) => set('name', e.target.value)} />
|
||||
<code className="se-npc-addr">npc.{npcId}</code>
|
||||
</div>
|
||||
<span className="se-npc-count">{statCount} {statCount === 1 ? 'stat' : 'stats'}</span>
|
||||
<button type="button" className="se-remove" onClick={onRemove} title="Remove NPC">✕</button>
|
||||
</div>
|
||||
<div className="se-npc-body">
|
||||
<Field label="id (how the AI addresses them)" className="se-npc-idfield">
|
||||
<KeyInput value={npcId} onCommit={onRenameKey} placeholder="npc_id" />
|
||||
</Field>
|
||||
<Field label="trigger words, comma-separated">
|
||||
<input className="se-text" value={npc.keys || ''} placeholder="Gwen, ranger, the scout"
|
||||
onChange={(e) => set('keys', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="description (lore + shown to the AI)">
|
||||
<textarea className="se-text se-area" rows={2} value={npc.desc || ''}
|
||||
placeholder="A wary ranger who owes you a debt."
|
||||
onChange={(e) => set('desc', e.target.value)} />
|
||||
</Field>
|
||||
<StatSection title="Their stats" defs={npc.stats || {}} nested
|
||||
onChange={(nd) => set('stats', nd)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The NPC roster. Takes/returns just the `npcs` slice of a stat_schema.
|
||||
export function NpcEditor({ npcs, onChange }) {
|
||||
const entries = Object.entries(npcs || {})
|
||||
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
|
||||
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
|
||||
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
No NPCs yet. Each one gets its own stats (trust, health, ferocity — whatever suits them),
|
||||
and a story card is created automatically so they show up in context when mentioned.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="se se-npc-grid">
|
||||
{entries.map(([k, npc]) => (
|
||||
<NpcCard key={k} npcId={k} npc={npc || {}}
|
||||
onRenameKey={(nk) => rename(k, nk)}
|
||||
onChange={(n) => setNpc(k, n)}
|
||||
onRemove={() => remove(k)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Form-based editor for a stat_schema, minus the NPCs (see `NpcEditor`, which
|
||||
// the scenario editor renders as its own page section). `schema` is the parsed
|
||||
// object (or null); `onChange(nextSchema)` fires on every edit. Empty sections
|
||||
// are dropped.
|
||||
export default function SchemaEditor({ schema, onChange }) {
|
||||
const s = schema && typeof schema === 'object' ? schema : {}
|
||||
const setSection = (key, val) => {
|
||||
const next = { ...s }
|
||||
if (val && Object.keys(val).length) next[key] = val
|
||||
else delete next[key]
|
||||
onChange(next)
|
||||
}
|
||||
return (
|
||||
<div className="se">
|
||||
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)}
|
||||
hint="Things about the situation, not any one character — time of day, the camp’s alert level." />
|
||||
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)}
|
||||
hint="The player’s own numbers — hp, gold, reputation — plus free-text ones like an outfit." />
|
||||
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
|
||||
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/* Accessibility, as behaviour rather than as a claim.
|
||||
*
|
||||
* §28 asks for semantic HTML, keyboard navigation, visible focus, descriptive
|
||||
* labels, sensible transcript structure and dialogs that manage focus. The
|
||||
* parts of that a test can actually decide are here; contrast and visible focus
|
||||
* are CSS and were checked by eye in the browser (recorded in the M8 report).
|
||||
*
|
||||
* The baseline this replaces: sixteen of sixteen per-message controls on a
|
||||
* two-turn story had no accessible name — only a `title` on a single glyph.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from './api'
|
||||
import App from './App'
|
||||
import Campaigns from './pages/Campaigns'
|
||||
import NewCampaign from './pages/NewCampaign'
|
||||
import { Composer } from './pages/Play/Composer'
|
||||
import { mockModelStatus, renderWith } from './test/helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockModelStatus(api)
|
||||
})
|
||||
|
||||
/** Every control in `container` has a non-trivial accessible name. */
|
||||
function everyControlIsNamed(container) {
|
||||
const unnamed = []
|
||||
container.querySelectorAll('button, a[href], select, textarea, input').forEach((el) => {
|
||||
if (el.type === 'hidden') return
|
||||
const label = el.getAttribute('aria-label')
|
||||
|| el.getAttribute('aria-labelledby')
|
||||
|| (el.labels && el.labels.length ? el.labels[0].textContent : '')
|
||||
|| el.textContent
|
||||
|| ''
|
||||
if (label.trim().length < 3) unnamed.push(el.outerHTML.slice(0, 90))
|
||||
})
|
||||
return unnamed
|
||||
}
|
||||
|
||||
describe('controls have names, not glyphs', () => {
|
||||
it('in the composer', async () => {
|
||||
const { container } = await renderWith(
|
||||
<Composer
|
||||
input="" direction={false} busy={false}
|
||||
canUndo canRedo canRetry
|
||||
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
|
||||
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(everyControlIsNamed(container)).toEqual([])
|
||||
})
|
||||
|
||||
it('in the campaign library', async () => {
|
||||
vi.spyOn(api, 'listAdventures').mockResolvedValue([
|
||||
{ id: 1, title: 'Continuity Test', action_count: 4, updated_at: '2026-09-06T10:00:00', snippet: 'x' },
|
||||
])
|
||||
const { container } = await renderWith(<Campaigns />)
|
||||
expect(everyControlIsNamed(container)).toEqual([])
|
||||
})
|
||||
|
||||
it('in the setup form', async () => {
|
||||
const { container } = await renderWith(<NewCampaign />)
|
||||
expect(everyControlIsNamed(container)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('form fields are labelled', () => {
|
||||
it('every input in setup has a label element bound to it', async () => {
|
||||
const { container } = await renderWith(<NewCampaign />)
|
||||
const fields = container.querySelectorAll('input:not([type=radio]):not([type=checkbox]), textarea, select')
|
||||
expect(fields.length).toBeGreaterThan(5)
|
||||
for (const field of fields) {
|
||||
expect(field.labels?.length, `${field.id || field.outerHTML.slice(0, 60)} has no label`)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('semantic structure', () => {
|
||||
it('the library uses a list and one h1', async () => {
|
||||
vi.spyOn(api, 'listAdventures').mockResolvedValue([
|
||||
{ id: 1, title: 'A', action_count: 1, updated_at: '2026-09-06T10:00:00' },
|
||||
{ id: 2, title: 'B', action_count: 2, updated_at: '2026-09-06T10:00:00' },
|
||||
])
|
||||
const { container } = await renderWith(<Campaigns />)
|
||||
expect(container.querySelectorAll('h1')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('ul.campaign-grid > li')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a campaign is opened by a link, not a click handler on a div', async () => {
|
||||
vi.spyOn(api, 'listAdventures').mockResolvedValue([
|
||||
{ id: 7, title: 'Continuity Test', action_count: 1, updated_at: '2026-09-06T10:00:00' },
|
||||
])
|
||||
await renderWith(<Campaigns />)
|
||||
// Two routes to the same place, both real links, both keyboard-reachable.
|
||||
const links = screen.getAllByRole('link', { name: /Continuity Test|Open/ })
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const link of links) expect(link).toHaveAttribute('href', '/play/7')
|
||||
})
|
||||
|
||||
it('the setup form is a form, so Enter submits it', async () => {
|
||||
const { container } = await renderWith(<NewCampaign />)
|
||||
expect(container.querySelector('form')).toBeInTheDocument()
|
||||
expect(container.querySelector('button[type=submit]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shell', () => {
|
||||
it('offers a skip link and one main landmark', async () => {
|
||||
vi.spyOn(api, 'listAdventures').mockResolvedValue([])
|
||||
const { container } = await renderWith(<App />)
|
||||
expect(screen.getByText('Skip to main content')).toHaveAttribute('href', '#main')
|
||||
expect(container.querySelectorAll('main')).toHaveLength(1)
|
||||
expect(container.querySelector('main')).toHaveAttribute('id', 'main')
|
||||
})
|
||||
|
||||
it('names its navigation', async () => {
|
||||
vi.spyOn(api, 'listAdventures').mockResolvedValue([])
|
||||
await renderWith(<App />)
|
||||
expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard', () => {
|
||||
it('reaches every story control by Tab, in order', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWith(
|
||||
<Composer
|
||||
input="" direction={false} busy={false}
|
||||
canUndo canRedo canRetry
|
||||
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
|
||||
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const order = ['Continue', 'Retry', 'Undo', 'Redo', 'Save Point']
|
||||
for (const label of order) {
|
||||
await user.tab()
|
||||
expect(document.activeElement).toHaveAccessibleName(label)
|
||||
}
|
||||
// Then the direction toggle, then the box itself.
|
||||
await user.tab()
|
||||
expect(document.activeElement.type).toBe('checkbox')
|
||||
await user.tab()
|
||||
expect(document.activeElement.tagName).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
it('does not put the disabled dictation control in the tab order', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWith(
|
||||
<Composer
|
||||
input="" direction={false} busy={false}
|
||||
canUndo={false} canRedo={false} canRetry={false}
|
||||
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
|
||||
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
for (let i = 0; i < 12; i++) await user.tab()
|
||||
// A disabled button is skipped by the browser; assert it never took focus.
|
||||
expect(screen.getByTestId('dictate-reserved')).not.toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -1,151 +0,0 @@
|
||||
// The story tree, as numbers something can be drawn from.
|
||||
//
|
||||
// `GET /adventures/{id}/branches` answers two numbers per branch — `fork_depth`,
|
||||
// where a line leaves its parent, and `depth`, where it currently ends — which
|
||||
// is deliberately enough to draw the whole shape without walking a single node.
|
||||
// Both readers of that shape live behind this file: the list in the Branches
|
||||
// panel and the map overlay. They order and label a branch the same way because
|
||||
// they order and label it *here*, so a branch cannot appear in one and not the
|
||||
// other, or be called two different things by the two of them.
|
||||
|
||||
// Derived, never stored: a generated name in the column would go stale the
|
||||
// moment a branch before it is deleted. A fork depth is a coordinate, so it
|
||||
// says the same thing whatever else is thrown away.
|
||||
export function branchLabel(branch) {
|
||||
if (branch.name) return branch.name
|
||||
if (branch.parent_branch_id === null) return 'The first telling'
|
||||
return `Fork at moment ${branch.fork_depth + 1}`
|
||||
}
|
||||
|
||||
// Parents before children, each child under the branch it left.
|
||||
export function orderBranches(branches) {
|
||||
const kids = new Map()
|
||||
for (const b of branches) {
|
||||
const key = b.parent_branch_id
|
||||
if (!kids.has(key)) kids.set(key, [])
|
||||
kids.get(key).push(b)
|
||||
}
|
||||
const out = []
|
||||
const walk = (parentId, indent) => {
|
||||
for (const b of kids.get(parentId) || []) {
|
||||
out.push({ branch: b, indent })
|
||||
walk(b.id, indent + 1)
|
||||
}
|
||||
}
|
||||
// Anything whose parent is missing would otherwise never be walked. That
|
||||
// cannot happen through the API, but a list that silently drops a branch is
|
||||
// the one bug this panel exists to make impossible to have.
|
||||
walk(null, 0)
|
||||
const seen = new Set(out.map((row) => row.branch.id))
|
||||
for (const b of branches) if (!seen.has(b.id)) out.push({ branch: b, indent: 0 })
|
||||
return out
|
||||
}
|
||||
|
||||
// The branches the head is standing on: itself, and everything it borrows from.
|
||||
//
|
||||
// This is the client's copy of the server's delete rule — `parent_branch_id`
|
||||
// cascades, so deleting an ancestor of the head takes the head with it and
|
||||
// leaves `head_branch_id` pointing at a row that is gone. The server refuses
|
||||
// exactly this set; computing it here only means the button can say so before
|
||||
// it is pressed. The server stays the authority.
|
||||
export function headLineage(branches) {
|
||||
const byId = new Map(branches.map((b) => [b.id, b]))
|
||||
const out = new Set()
|
||||
let cur = branches.find((b) => b.is_head)
|
||||
// The guard is against a cycle, which the schema forbids and a walk should
|
||||
// still never hang on.
|
||||
while (cur && !out.has(cur.id)) {
|
||||
out.add(cur.id)
|
||||
cur = cur.parent_branch_id === null ? null : byId.get(cur.parent_branch_id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// How many Save Points would go if this branch were deleted.
|
||||
//
|
||||
// Deleting a branch deletes everything forked from it, and a Save Point names a
|
||||
// position on a line, so the Save Points on the whole doomed subtree go too.
|
||||
// The count is summed over that subtree rather than over the one branch — a
|
||||
// warning that said "1 Save Point" while three disappeared would be worse than
|
||||
// no warning at all.
|
||||
//
|
||||
// Computed here because the panel already holds every branch and its parent,
|
||||
// so the answer costs a walk rather than an endpoint. The server remains the
|
||||
// authority on what is actually deleted; this only lets the button say so
|
||||
// before it is pressed, the same division `headLineage` already uses.
|
||||
export function savePointsUnder(branches, rootId) {
|
||||
const children = new Map()
|
||||
for (const b of branches) {
|
||||
const key = b.parent_branch_id
|
||||
if (!children.has(key)) children.set(key, [])
|
||||
children.get(key).push(b)
|
||||
}
|
||||
const byId = new Map(branches.map((b) => [b.id, b]))
|
||||
const seen = new Set()
|
||||
const stack = [rootId]
|
||||
let total = 0
|
||||
while (stack.length) {
|
||||
const id = stack.pop()
|
||||
// The guard is against a cycle, which the schema forbids and a walk should
|
||||
// still never hang on.
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
total += byId.get(id)?.save_points ?? 0
|
||||
for (const child of children.get(id) ?? []) stack.push(child.id)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ---------- Map geometry ----------
|
||||
|
||||
export const ROW_H = 64 // one branch, name above the lane and meta below
|
||||
export const PAD = { top: 44, right: 26, bottom: 20, left: 24 }
|
||||
export const CORNER = 11 // radius of the elbow a fork turns through
|
||||
|
||||
// Place every branch on its own horizontal lane, in tree order.
|
||||
//
|
||||
// A lane runs from where its branch left its parent to where its branch ends,
|
||||
// so the horizontal axis is the story's own clock: two branches at the same x
|
||||
// are at the same moment, and the length of a lane is how much of the story it
|
||||
// covers. Nothing here is measured in pixels — the component owns the mapping
|
||||
// from a moment to an x, because only it knows how wide it ended up.
|
||||
export function layoutTree(branches) {
|
||||
const rows = orderBranches(branches)
|
||||
const rowOf = new Map(rows.map((row, i) => [row.branch.id, i]))
|
||||
const lanes = rows.map(({ branch }, row) => ({
|
||||
branch,
|
||||
row,
|
||||
// The first telling starts where the story does; every other line starts
|
||||
// where it walked away from another one.
|
||||
from: branch.parent_branch_id === null ? 0 : (branch.fork_depth ?? 0),
|
||||
to: branch.depth,
|
||||
parentRow: branch.parent_branch_id === null
|
||||
? null
|
||||
: (rowOf.has(branch.parent_branch_id) ? rowOf.get(branch.parent_branch_id) : null),
|
||||
}))
|
||||
return {
|
||||
lanes,
|
||||
// The whole map is scaled to the longest path, so a short branch reads as
|
||||
// short. `|| 1` keeps a one-moment story from dividing by zero.
|
||||
maxDepth: Math.max(0, ...branches.map((b) => b.depth)),
|
||||
height: PAD.top + rows.length * ROW_H + PAD.bottom,
|
||||
}
|
||||
}
|
||||
|
||||
// Round tick marks for the moment axis: about `count` of them, landing on
|
||||
// numbers a person would have chosen (1, 2, 5, 10, 25, 50 …) rather than on
|
||||
// whatever `maxDepth / 6` happens to be.
|
||||
export function momentTicks(maxDepth, count = 6) {
|
||||
if (maxDepth <= 0) return [0]
|
||||
const raw = maxDepth / count
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) || magnitude * 10
|
||||
const out = []
|
||||
for (let d = 0; d <= maxDepth; d += step) out.push(Math.round(d))
|
||||
// The last moment is worth naming, but not on top of the tick before it —
|
||||
// at a narrow width `26` and `27` printed as `2627`.
|
||||
const last = out[out.length - 1]
|
||||
if (maxDepth - last > step * 0.6) out.push(maxDepth)
|
||||
else if (last !== maxDepth) out[out.length - 1] = maxDepth
|
||||
return out
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
export function downloadJSON(obj, filename) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
|
||||
@@ -33,8 +32,6 @@ export function pickJSONFile() {
|
||||
|
||||
// ---------- Scenario art ----------
|
||||
|
||||
// FNV-1a. Any stable hash works; the point is that a given title always maps to
|
||||
// the same plate, so the library looks the same on every visit and every device.
|
||||
function hashString(str) {
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -44,9 +41,6 @@ function hashString(str) {
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
// Deep jewel ramps that sit under gold without competing with it — the accent
|
||||
// stays the brightest thing on the card. Ordered so adjacent library entries
|
||||
// rarely land on neighbouring hues.
|
||||
const ART_RAMPS = [
|
||||
['#14424a', '#0b2328'], // drowned teal
|
||||
['#4d2130', '#250f19'], // wine
|
||||
@@ -58,7 +52,6 @@ const ART_RAMPS = [
|
||||
['#4a3a16', '#231b09'], // ochre
|
||||
]
|
||||
|
||||
// Up to two letters from the title's most significant words.
|
||||
function monogram(title) {
|
||||
const words = (title || '')
|
||||
.replace(/^\[[^\]]*\]\s*/, '') // drop a leading "[Demo]" style label
|
||||
@@ -68,11 +61,6 @@ function monogram(title) {
|
||||
return letters.join('').toUpperCase()
|
||||
}
|
||||
|
||||
/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
|
||||
*
|
||||
* Deliberately not `monogram`: that one drops short words, which is right for
|
||||
* scenario titles and wrong for names, and it has no id to fall back on.
|
||||
*/
|
||||
export function npcInitials(name, id) {
|
||||
const src = String(name || id || '?').trim() || '?'
|
||||
const words = src.split(/[\s_-]+/).filter(Boolean)
|
||||
@@ -80,12 +68,6 @@ export function npcInitials(name, id) {
|
||||
return letters.toUpperCase()
|
||||
}
|
||||
|
||||
/** A scenario's plate: uploaded picture, else emoji, else generated art.
|
||||
*
|
||||
* `large` is for the Continue cards, where the plate carries more weight. The
|
||||
* generated tier means no card is ever an empty box, so a fresh library still
|
||||
* reads as a shelf of distinct things.
|
||||
*/
|
||||
export function ScenarioArt({ image, icon, title, large = false }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const ramp = ART_RAMPS[hashString(title || '') % ART_RAMPS.length]
|
||||
@@ -187,7 +169,6 @@ export function ToastHost({ children }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
|
||||
export function extractPlaceholders(...texts) {
|
||||
const names = []
|
||||
for (const text of texts) {
|
||||
@@ -199,18 +180,6 @@ export function extractPlaceholders(...texts) {
|
||||
return names
|
||||
}
|
||||
|
||||
// The modal shown before an adventure begins. It always asks who the player is
|
||||
// playing as, and it also collects any `${Placeholder}` answers the scenario's
|
||||
// text asks for.
|
||||
//
|
||||
// The two sets of fields are independent on purpose. A scenario that writes
|
||||
// `${Name}` is asking its own question, and the persona does not answer it. No
|
||||
// scenario in the repo uses placeholders at all, so the overlap is hypothetical;
|
||||
// pre-filling one from the other is a small change here if it ever bites.
|
||||
//
|
||||
// `onSubmit` receives `{ persona, placeholders }`. Every persona field is
|
||||
// optional — submitting them all blank gives an adventure with no persona,
|
||||
// which behaves exactly as adventures did before personas existed.
|
||||
export function BeginAdventureModal({ title, names = [], onSubmit, onCancel }) {
|
||||
const [persona, setPersona] = useState({ name: '', pronouns: '', desc: '' })
|
||||
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
|
||||
@@ -302,9 +271,6 @@ export function AutoTextarea({ value, ...props }) {
|
||||
return <textarea ref={ref} value={value} {...props} />
|
||||
}
|
||||
|
||||
// `maxLength` mirrors the column width the server enforces. Without it an
|
||||
// over-long value is only rejected at save time, as a 422 the player sees as a
|
||||
// toast after the text is already typed.
|
||||
export function Field({ label, value, onChange, textarea, rows, placeholder, maxLength }) {
|
||||
return (
|
||||
<label className="field">
|
||||
@@ -320,26 +286,3 @@ export function Field({ label, value, onChange, textarea, rows, placeholder, max
|
||||
)
|
||||
}
|
||||
|
||||
export function StoryCardRow({ card, onChange, onDelete }) {
|
||||
return (
|
||||
<div className="storycard">
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
|
||||
onChange={(e) => onChange({ ...card, name: e.target.value })} />
|
||||
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
|
||||
onChange={(e) => onChange({ ...card, type: e.target.value })} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
|
||||
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
|
||||
</div>
|
||||
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
|
||||
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Turning a failure into something a reader can act on.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §71 asks for five kinds of failure to be told apart, and
|
||||
* forbids collapsing them into "Something went wrong". They are told apart
|
||||
* because each one has a different thing to *do* about it: start Ollama, pull a
|
||||
* model, retry the turn, correct the state, look at the server log. A single
|
||||
* message leaves the reader guessing which of those they are looking at.
|
||||
*
|
||||
* The classification reads the message the server actually sent. That is a
|
||||
* coupling to backend strings, so it is deliberately a *fallback ladder* rather
|
||||
* than a lookup: an unrecognised message still gets a kind ("generation"), still
|
||||
* shows its own text, and still offers Retry. Nothing is hidden when the match
|
||||
* misses — the reader sees the server's own words either way, and the only thing
|
||||
* lost is the tailored hint.
|
||||
*
|
||||
* The signatures below are the ones `backend/app/providers/openai_compatible.py`
|
||||
* raises; each is quoted in the comment beside it so a change over there is
|
||||
* findable from here.
|
||||
*/
|
||||
|
||||
/** The five kinds, in the vocabulary §71 uses. */
|
||||
export const KIND = {
|
||||
MODEL: 'model',
|
||||
GENERATION: 'generation',
|
||||
STATE: 'state',
|
||||
KNOWLEDGE: 'knowledge',
|
||||
SERVER: 'server',
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
[KIND.MODEL]: 'Model unavailable',
|
||||
[KIND.GENERATION]: 'Generation failed',
|
||||
[KIND.STATE]: 'Story state could not be updated',
|
||||
[KIND.KNOWLEDGE]: 'Imported knowledge problem',
|
||||
[KIND.SERVER]: 'The storyteller had a problem',
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a failure message.
|
||||
*
|
||||
* Returns `{ kind, title, detail, hint, retryable }`. `detail` is always the
|
||||
* server's own text — this never replaces what the server said, only frames it.
|
||||
*/
|
||||
export function classifyError(message) {
|
||||
const detail = String(message || '').trim() || 'No detail was reported.'
|
||||
const low = detail.toLowerCase()
|
||||
|
||||
// ---- Model / endpoint: the story cannot be told at all ----
|
||||
|
||||
// "No model configured — set one in Settings."
|
||||
if (low.includes('no model configured')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'No narrator model chosen',
|
||||
detail,
|
||||
hint: 'Choose an installed Ollama model in Settings, then try again.',
|
||||
retryable: false,
|
||||
action: { label: 'Open Settings', to: '/settings' },
|
||||
}
|
||||
}
|
||||
// "No embedding model configured — set one in Settings."
|
||||
if (low.includes('no embedding model configured')) {
|
||||
return {
|
||||
kind: KIND.KNOWLEDGE,
|
||||
title: 'No embedding model chosen',
|
||||
detail,
|
||||
hint:
|
||||
'Imported knowledge is still searched by keyword. Choose an embedding '
|
||||
+ 'model in Settings to add meaning-based search.',
|
||||
retryable: false,
|
||||
action: { label: 'Open Settings', to: '/settings' },
|
||||
}
|
||||
}
|
||||
// "Could not connect to <url> — is the AI server running?"
|
||||
// "Request to AI endpoint failed: ..."
|
||||
if (low.includes('could not connect') || low.includes('request to ai endpoint failed')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'Ollama is not reachable',
|
||||
detail,
|
||||
hint:
|
||||
'Start Ollama on the machine at the configured endpoint (`ollama serve`), '
|
||||
+ 'then retry. Nothing you wrote has been lost.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
// "The AI endpoint timed out."
|
||||
if (low.includes('timed out') || low.includes('timeout')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'The model took too long',
|
||||
detail,
|
||||
hint:
|
||||
'Loading a model for the first time can take minutes without a GPU. '
|
||||
+ 'Retry, or raise the model timeout in Settings.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
// "This endpoint can't be used — ..." (the ADR 011 address policy)
|
||||
if (low.includes("endpoint can't be used") || low.includes('endpoint cannot be used')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'That endpoint is not allowed',
|
||||
detail,
|
||||
hint:
|
||||
'The storyteller only talks to Ollama on this machine or on your own '
|
||||
+ 'network. Correct the endpoint in Settings.',
|
||||
retryable: false,
|
||||
action: { label: 'Open Settings', to: '/settings' },
|
||||
}
|
||||
}
|
||||
// "Endpoint or model not found (HTTP 404). Check ... model '<name>' exists."
|
||||
if (low.includes('not found') && low.includes('404')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'Endpoint or model not found',
|
||||
detail,
|
||||
hint: 'Pull the model on that machine (`ollama pull <model>`), or pick another in Settings.',
|
||||
retryable: true,
|
||||
action: { label: 'Open Settings', to: '/settings' },
|
||||
}
|
||||
}
|
||||
// TLS is its own case: the fix is installing a CA, not starting a server.
|
||||
if (low.includes('certificate') || low.includes('tls') || low.includes('ssl')) {
|
||||
return {
|
||||
kind: KIND.MODEL,
|
||||
title: 'The endpoint’s certificate could not be verified',
|
||||
detail,
|
||||
hint:
|
||||
'If it uses a private or self-signed CA, install that CA on this machine. '
|
||||
+ 'Certificate checking is not optional.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- State: the turn happened but could not be recorded ----
|
||||
if (
|
||||
low.includes('state validation')
|
||||
|| low.includes('invalid state event')
|
||||
|| low.includes('narrative state')
|
||||
|| low.includes('unknown event type')
|
||||
) {
|
||||
return {
|
||||
kind: KIND.STATE,
|
||||
title: TITLES[KIND.STATE],
|
||||
detail,
|
||||
hint:
|
||||
'The story itself is unaffected. Open State to see what the storyteller '
|
||||
+ 'currently believes, and correct it if it is wrong.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Knowledge / derived work ----
|
||||
if (low.includes('knowledge') || low.includes('embedding') || low.includes('index')) {
|
||||
return {
|
||||
kind: KIND.KNOWLEDGE,
|
||||
title: TITLES[KIND.KNOWLEDGE],
|
||||
detail,
|
||||
hint: 'Your story is unaffected. Imported material may not be searched until this is fixed.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server / database ----
|
||||
if (
|
||||
low.includes('database')
|
||||
|| low.includes('sqlite')
|
||||
|| low.includes('integrity')
|
||||
|| low.includes('internal server error')
|
||||
|| low.includes('http 500')
|
||||
) {
|
||||
return {
|
||||
kind: KIND.SERVER,
|
||||
title: TITLES[KIND.SERVER],
|
||||
detail,
|
||||
hint: 'Nothing already written has been changed. The server log has the details.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Anything else is a failed generation ----
|
||||
//
|
||||
// Deliberately the fallback rather than a separate "unknown": the reader is
|
||||
// in the middle of a turn, the turn did not happen, and Retry is the useful
|
||||
// offer. The server's own words are shown, so nothing is lost by not
|
||||
// recognising it.
|
||||
return {
|
||||
kind: KIND.GENERATION,
|
||||
title: TITLES[KIND.GENERATION],
|
||||
detail,
|
||||
hint: 'Nothing was added to your story. You can try that turn again.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -1,30 +1,30 @@
|
||||
/* The stylesheet, split into sections and imported in order.
|
||||
|
||||
The order is load-bearing and must not change. Several selectors in
|
||||
`tome.css` tie with earlier ones on specificity and win only because they
|
||||
come later, and `responsive.css` overrides the whole desktop design at
|
||||
narrow widths. Both are noted where the rules are.
|
||||
The order is load-bearing: `responsive.css` overrides the desktop design at
|
||||
narrow widths and must stay last.
|
||||
|
||||
Add a new section by adding a file and an `@import` for it. Put the import
|
||||
where the rules belong in the cascade, not at the end by habit.
|
||||
|
||||
M8 removed six sheets with the surfaces they styled — the scenario gallery's
|
||||
illuminated-tome cards, the stat-schema editor, the branch-map overlay, the
|
||||
AI Chat scratchpad, the hosted deployment's visitor dashboard, and its log-in
|
||||
forms. What survived of the last one is `debuglog.css`.
|
||||
*/
|
||||
@import './styles/fonts.css'; /* @font-face for the self-hosted families */
|
||||
@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
|
||||
@import './styles/base.css'; /* scrollbars */
|
||||
@import './styles/nav.css'; /* the top navigation bar */
|
||||
@import './styles/forms.css'; /* inputs, labels, and buttons */
|
||||
@import './styles/cards.css'; /* cards, lists, and the story-card editor */
|
||||
@import './styles/play.css'; /* the Play screen and the branches panel */
|
||||
@import './styles/panels.css'; /* the side panel and the in-play script viewer */
|
||||
@import './styles/drawers.css'; /* the status drawer and the world-state drawer */
|
||||
@import './styles/schema-editor.css'; /* the stat-schema form and the NPC roster */
|
||||
@import './styles/insights.css'; /* insights, scripts, and the memory bank */
|
||||
@import './styles/knowledge.css'; /* the imported knowledge library (M7) */
|
||||
@import './styles/modals.css'; /* the filter bar, tags, and modals */
|
||||
@import './styles/auth.css'; /* log in, sign up, and the settings debug log */
|
||||
@import './styles/banners.css'; /* the Play screen's persistent banner */
|
||||
@import './styles/tome.css'; /* the illuminated-tome surfaces */
|
||||
@import './styles/chat.css'; /* the AI Chat scratchpad */
|
||||
@import './styles/tree-map.css'; /* the branch map overlay */
|
||||
@import './styles/analytics.css'; /* the visitor dashboard */
|
||||
@import './styles/responsive.css'; /* narrow screens, 720px and below */
|
||||
@import './styles/fonts.css'; /* @font-face for the self-hosted families */
|
||||
@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
|
||||
@import './styles/base.css'; /* scrollbars */
|
||||
@import './styles/nav.css'; /* the top navigation bar */
|
||||
@import './styles/forms.css'; /* inputs, labels, and buttons */
|
||||
@import './styles/library.css'; /* the campaign library, setup, and settings */
|
||||
@import './styles/story.css'; /* the story screen: transcript and composer */
|
||||
@import './styles/panels.css'; /* the side panel */
|
||||
@import './styles/context.css'; /* the context inspector */
|
||||
@import './styles/knowledge.css'; /* the imported knowledge library */
|
||||
@import './styles/play.css'; /* take pager, Save Points, state panel */
|
||||
@import './styles/insights.css'; /* shared report furniture */
|
||||
@import './styles/cards.css'; /* lists and skeletons */
|
||||
@import './styles/dialogs.css'; /* modals and classification tags */
|
||||
@import './styles/modals.css'; /* toasts */
|
||||
@import './styles/debuglog.css'; /* the settings screen's request log */
|
||||
@import './styles/responsive.css'; /* narrow screens, 720px and below */
|
||||
|
||||
+10
-10
@@ -2,30 +2,30 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Adventures from './pages/Adventures.jsx'
|
||||
import Scenarios from './pages/Scenarios.jsx'
|
||||
import ScenarioEditor from './pages/ScenarioEditor.jsx'
|
||||
import Campaigns from './pages/Campaigns.jsx'
|
||||
import NewCampaign from './pages/NewCampaign.jsx'
|
||||
import Play from './pages/Play'
|
||||
import Settings from './pages/Settings.jsx'
|
||||
import Chat from './pages/Chat.jsx'
|
||||
import { trackKeyboardInset } from './keyboard.js'
|
||||
import './index.css'
|
||||
|
||||
trackKeyboardInset()
|
||||
|
||||
/* Four routes. `BROWSER-UX-SPEC.md` §97 puts everything else inside a campaign,
|
||||
* where it is reachable from the story screen's own panels rather than from a
|
||||
* URL of its own.
|
||||
*
|
||||
* The scenario gallery, the scenario editor and the raw model chat console that
|
||||
* used to live here are gone; `App.jsx` records why. */
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'adventures', element: <Adventures /> },
|
||||
{ path: 'scenarios', element: <Scenarios /> },
|
||||
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
|
||||
{ index: true, element: <Campaigns /> },
|
||||
{ path: 'new', element: <NewCampaign /> },
|
||||
{ path: 'play/:id', element: <Play /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
{ path: 'chat', element: <Chat /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/* Safe Markdown for story prose.
|
||||
*
|
||||
* The narrator writes Markdown, and so does anything a reader imports. Both
|
||||
* reach this module, so it is where H06 (stored XSS) and H07 (javascript: URLs)
|
||||
* are decided for rendered text — the Insights and Knowledge panels decide
|
||||
* theirs by rendering into a `<pre>` and never coming here at all.
|
||||
*
|
||||
* The safety is structural rather than filtered. Every node this returns is a
|
||||
* React element built from parsed text; the text itself only ever becomes a
|
||||
* React child, which React escapes. There is no `dangerouslySetInnerHTML` in
|
||||
* this file, and adding one would be the whole vulnerability — a sanitizer is
|
||||
* not needed to make markup safe if markup is never produced from input.
|
||||
*
|
||||
* So `<script>alert(1)</script>` in a narrator turn is eighteen visible
|
||||
* characters, and `<img onerror=...>` is likewise just text.
|
||||
*
|
||||
* Two further rules come from `BROWSER-UX-SPEC.md`:
|
||||
*
|
||||
* §75 a remote image is never fetched. `` renders a
|
||||
* placeholder naming the blocked address, so the reader knows something
|
||||
* was there without the page reaching the network.
|
||||
* §74 a link is not followed silently. The href is kept for display, but the
|
||||
* click is intercepted so the reader can be told they are leaving the
|
||||
* local-only environment. Anything that is not http/https/mailto — a
|
||||
* `javascript:` URL above all — never becomes a link at all.
|
||||
*
|
||||
* The grammar is deliberately small: headings, emphasis, inline code, links,
|
||||
* images, lists, blockquotes and fenced code. That is what §76 asks for. Tables,
|
||||
* footnotes and raw HTML are not supported, and unsupported syntax degrades to
|
||||
* the literal text the narrator wrote rather than disappearing.
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react'
|
||||
|
||||
/** Schemes a link may use. Everything else renders as plain text (H07). */
|
||||
const SAFE_SCHEMES = ['http:', 'https:', 'mailto:']
|
||||
|
||||
/**
|
||||
* True when `href` is a link we are willing to render as a link.
|
||||
*
|
||||
* Parsed with the URL parser rather than matched with a regex, because the
|
||||
* bypasses are all in the parsing: `java\tscript:`, `JaVaScript:`, and
|
||||
* `%6a%61vascript:` are the same URL to a browser and different strings to a
|
||||
* pattern. A relative href resolves against the page and is same-origin, which
|
||||
* is the local application itself and therefore fine.
|
||||
*/
|
||||
function isSafeHref(href) {
|
||||
if (!href) return false
|
||||
try {
|
||||
const url = new URL(href, window.location.origin)
|
||||
return SAFE_SCHEMES.includes(url.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the target leaves this application. */
|
||||
function isExternal(href) {
|
||||
try {
|
||||
const url = new URL(href, window.location.origin)
|
||||
if (url.protocol === 'mailto:') return true
|
||||
return url.origin !== window.location.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// One pass, alternation ordered so the longer opener wins: `**` before `*`, and
|
||||
// the image `![` before the link `[`.
|
||||
const INLINE = new RegExp(
|
||||
[
|
||||
'`([^`\\n]+)`', // 1 code
|
||||
'!\\[([^\\]]*)\\]\\(([^)\\s]+)[^)]*\\)', // 2 alt, 3 src
|
||||
'\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)', // 4 text, 5 href
|
||||
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
|
||||
'__([^_\\n]+)__', // 7 strong
|
||||
'\\*([^*\\n]+)\\*', // 8 em
|
||||
'_([^_\\n]+)_', // 9 em
|
||||
].join('|'),
|
||||
'g',
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders one line of inline Markdown to React nodes.
|
||||
*
|
||||
* `onLink` is called instead of navigating, so the caller can warn about
|
||||
* leaving the local environment (§74). Without one, an external link is still
|
||||
* rendered but does nothing on click rather than navigating silently.
|
||||
*/
|
||||
function inline(text, key, onLink) {
|
||||
if (!text) return null
|
||||
const out = []
|
||||
let last = 0
|
||||
let match
|
||||
INLINE.lastIndex = 0
|
||||
while ((match = INLINE.exec(text)) !== null) {
|
||||
if (match.index > last) out.push(text.slice(last, match.index))
|
||||
const k = `${key}-${match.index}`
|
||||
if (match[1] !== undefined) {
|
||||
out.push(<code key={k}>{match[1]}</code>)
|
||||
} else if (match[3] !== undefined) {
|
||||
// §75. The address is shown as text; nothing fetches it.
|
||||
out.push(
|
||||
<span key={k} className="md-image-blocked" title={match[3]}>
|
||||
🚫 Remote image blocked{match[2] ? `: ${match[2]}` : ''}
|
||||
</span>,
|
||||
)
|
||||
} else if (match[5] !== undefined) {
|
||||
const href = match[5]
|
||||
out.push(
|
||||
isSafeHref(href) ? (
|
||||
<a
|
||||
key={k}
|
||||
href={href}
|
||||
className="md-link"
|
||||
// Belt and braces for the case where a click still gets through.
|
||||
rel="noreferrer noopener"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (onLink) onLink(href)
|
||||
}}
|
||||
>
|
||||
{match[4]}
|
||||
</a>
|
||||
) : (
|
||||
// Not a scheme we will link. The reader still sees exactly what the
|
||||
// text said, which is more useful than silently dropping it.
|
||||
<span key={k} className="md-link-blocked" title="This link was not safe to open">
|
||||
{match[4]} ({href})
|
||||
</span>
|
||||
),
|
||||
)
|
||||
} else if (match[6] !== undefined || match[7] !== undefined) {
|
||||
out.push(<strong key={k}>{match[6] ?? match[7]}</strong>)
|
||||
} else {
|
||||
out.push(<em key={k}>{match[8] ?? match[9]}</em>)
|
||||
}
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last))
|
||||
return out.length ? out : text
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/
|
||||
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/
|
||||
const ORDERED = /^\s{0,3}(\d{1,9})[.)]\s+(.*)$/
|
||||
const QUOTE = /^\s{0,3}>\s?(.*)$/
|
||||
const FENCE = /^\s{0,3}(```|~~~)(.*)$/
|
||||
|
||||
/**
|
||||
* Parses `text` into React block elements.
|
||||
*
|
||||
* Written as an explicit line cursor rather than a recursive-descent parser
|
||||
* because the grammar is flat: no construct here nests except a blockquote's
|
||||
* inline spans, and a hand-rolled loop over lines is far easier to be sure
|
||||
* about than a general parser would be.
|
||||
*/
|
||||
export function Markdown({ text, onLink, className = 'md' }) {
|
||||
if (!text) return null
|
||||
const lines = String(text).split('\n')
|
||||
const blocks = []
|
||||
let i = 0
|
||||
let para = []
|
||||
|
||||
const flushPara = () => {
|
||||
if (!para.length) return
|
||||
const body = para
|
||||
para = []
|
||||
blocks.push(
|
||||
<p key={`p${blocks.length}`}>
|
||||
{body.map((line, n) => (
|
||||
<Fragment key={n}>
|
||||
{n > 0 && <br />}
|
||||
{inline(line, `p${blocks.length}-${n}`, onLink)}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>,
|
||||
)
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Fenced code. Everything up to the closing fence is literal, which is what
|
||||
// makes a fenced block the safe way to show markup that would otherwise be
|
||||
// parsed — including markup a hostile import wants rendered.
|
||||
const fence = FENCE.exec(line)
|
||||
if (fence) {
|
||||
flushPara()
|
||||
const marker = fence[1]
|
||||
const body = []
|
||||
i += 1
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
||||
body.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
i += 1 // consume the closing fence, or run off the end
|
||||
blocks.push(
|
||||
<pre key={`c${blocks.length}`} className="md-code">
|
||||
<code>{body.join('\n')}</code>
|
||||
</pre>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line)
|
||||
if (heading) {
|
||||
flushPara()
|
||||
// Story prose sits inside the page's own heading outline, so the largest
|
||||
// narrator heading is an h3 — a narrator writing `#` must not produce a
|
||||
// second `<h1>` on a page that already has one.
|
||||
const level = Math.min(6, heading[1].length + 2)
|
||||
const Tag = `h${level}`
|
||||
blocks.push(
|
||||
<Tag key={`h${blocks.length}`} className="md-heading">
|
||||
{inline(heading[2], `h${blocks.length}`, onLink)}
|
||||
</Tag>,
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (QUOTE.test(line)) {
|
||||
flushPara()
|
||||
const body = []
|
||||
while (i < lines.length && QUOTE.test(lines[i])) {
|
||||
body.push(QUOTE.exec(lines[i])[1])
|
||||
i += 1
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote key={`q${blocks.length}`} className="md-quote">
|
||||
{body.map((l, n) => (
|
||||
<Fragment key={n}>
|
||||
{n > 0 && <br />}
|
||||
{inline(l, `q${blocks.length}-${n}`, onLink)}
|
||||
</Fragment>
|
||||
))}
|
||||
</blockquote>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (BULLET.test(line) || ORDERED.test(line)) {
|
||||
flushPara()
|
||||
const ordered = !BULLET.test(line)
|
||||
const items = []
|
||||
while (i < lines.length) {
|
||||
const m = ordered ? ORDERED.exec(lines[i]) : BULLET.exec(lines[i])
|
||||
if (!m) break
|
||||
items.push(ordered ? m[2] : m[1])
|
||||
i += 1
|
||||
}
|
||||
const Tag = ordered ? 'ol' : 'ul'
|
||||
blocks.push(
|
||||
<Tag key={`l${blocks.length}`} className="md-list">
|
||||
{items.map((item, n) => (
|
||||
<li key={n}>{inline(item, `l${blocks.length}-${n}`, onLink)}</li>
|
||||
))}
|
||||
</Tag>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
flushPara()
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
para.push(line)
|
||||
i += 1
|
||||
}
|
||||
flushPara()
|
||||
|
||||
return <div className={className}>{blocks}</div>
|
||||
}
|
||||
|
||||
export { isSafeHref, isExternal }
|
||||
@@ -0,0 +1,133 @@
|
||||
/* The safe Markdown renderer.
|
||||
*
|
||||
* This file is where H06 and H07 are decided for narrator prose, so these are
|
||||
* security tests before they are formatting tests. The two that matter most:
|
||||
* markup in the source never becomes markup in the page, and a `javascript:`
|
||||
* URL never becomes an href.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Markdown, isSafeHref } from './markdown'
|
||||
|
||||
describe('safe rendering', () => {
|
||||
it('renders a script tag as visible text, not as an element (H06)', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'Before <script>alert(1)</script> after'} />,
|
||||
)
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.textContent).toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('renders an img tag with an onerror handler as text (H06)', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'<img src=x onerror="alert(1)">'} />,
|
||||
)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.textContent).toContain('onerror')
|
||||
})
|
||||
|
||||
it('never creates an href for a javascript: URL (H07)', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'[click me](javascript:alert(1))'} />,
|
||||
)
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
// The reader still sees what the text said.
|
||||
expect(container.textContent).toContain('click me')
|
||||
})
|
||||
|
||||
it('refuses data: and vbscript: URLs too', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'[a](data:text/html,<script>1</script>) [b](vbscript:x)'} />,
|
||||
)
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch a remote image — it renders a placeholder (G09)', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={''} />,
|
||||
)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(screen.getByText(/Remote image blocked/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('intercepts an external link rather than navigating', async () => {
|
||||
const onLink = vi.fn()
|
||||
render(<Markdown text={'[site](https://example.com/x)'} onLink={onLink} />)
|
||||
const link = screen.getByRole('link', { name: 'site' })
|
||||
link.click()
|
||||
expect(onLink).toHaveBeenCalledWith('https://example.com/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSafeHref', () => {
|
||||
it('accepts http, https and mailto', () => {
|
||||
expect(isSafeHref('http://a.test')).toBe(true)
|
||||
expect(isSafeHref('https://a.test')).toBe(true)
|
||||
expect(isSafeHref('mailto:a@b.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects javascript: however it is spelled', () => {
|
||||
expect(isSafeHref('javascript:alert(1)')).toBe(false)
|
||||
expect(isSafeHref('JaVaScRiPt:alert(1)')).toBe(false)
|
||||
// A tab inside the scheme is stripped by the URL parser, which is exactly
|
||||
// why parsing beats pattern-matching here.
|
||||
expect(isSafeHref('java\tscript:alert(1)')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an empty or unparseable href', () => {
|
||||
expect(isSafeHref('')).toBe(false)
|
||||
expect(isSafeHref(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting (§76)', () => {
|
||||
it('renders headings, and never above h3', () => {
|
||||
const { container } = render(<Markdown text={'# Title\n\n## Sub'} />)
|
||||
// A narrator writing `#` must not produce a second <h1> on the page.
|
||||
expect(container.querySelector('h1')).toBeNull()
|
||||
expect(container.querySelector('h3')).toHaveTextContent('Title')
|
||||
expect(container.querySelector('h4')).toHaveTextContent('Sub')
|
||||
})
|
||||
|
||||
it('renders emphasis', () => {
|
||||
const { container } = render(<Markdown text={'a **bold** and *italic* line'} />)
|
||||
expect(container.querySelector('strong')).toHaveTextContent('bold')
|
||||
expect(container.querySelector('em')).toHaveTextContent('italic')
|
||||
})
|
||||
|
||||
it('renders bullet and ordered lists', () => {
|
||||
const { container } = render(<Markdown text={'- one\n- two'} />)
|
||||
expect(container.querySelectorAll('ul li')).toHaveLength(2)
|
||||
const ordered = render(<Markdown text={'1. one\n2. two'} />)
|
||||
expect(ordered.container.querySelectorAll('ol li')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders blockquotes and code', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'> quoted\n\n```\nlet x = 1\n```\n\nand `inline`'} />,
|
||||
)
|
||||
expect(container.querySelector('blockquote')).toHaveTextContent('quoted')
|
||||
expect(container.querySelector('pre code')).toHaveTextContent('let x = 1')
|
||||
expect(container.querySelectorAll('code')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps markup inside a fenced block literal', () => {
|
||||
const { container } = render(
|
||||
<Markdown text={'```\n<script>alert(1)</script>\n```'} />,
|
||||
)
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('pre')).toHaveTextContent('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('leaves unsupported syntax as the literal text the narrator wrote', () => {
|
||||
const { container } = render(<Markdown text={'| a | b |\n| - | - |'} />)
|
||||
expect(container.querySelector('table')).toBeNull()
|
||||
expect(container.textContent).toContain('| a | b |')
|
||||
})
|
||||
|
||||
it('renders nothing for empty input', () => {
|
||||
const { container } = render(<Markdown text={''} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/* Whether the storyteller can actually narrate, and what to do when it cannot.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §44 asks the header to say "Ollama: Connected / Model:
|
||||
* …" or "Ollama unavailable". M8 also inherits a specific piece of debt: the
|
||||
* settings row can carry an empty `model`, and until now nothing said so until
|
||||
* a turn failed with a provider error. That is the case §8 of the milestone
|
||||
* calls out — the reader begins play and meets an obscure backend message.
|
||||
*
|
||||
* So this holds one shared answer for the whole application:
|
||||
*
|
||||
* checking the test has not come back yet
|
||||
* ready reachable, and the configured model is installed there
|
||||
* no-model reachable, but no narrator model is chosen
|
||||
* missing-model reachable, but the chosen model is not installed there
|
||||
* unavailable not reachable at all
|
||||
*
|
||||
* `no-model` and `missing-model` are separated because the fix differs: choose
|
||||
* one from a list you already have, versus pull one that is not there. Both are
|
||||
* resolvable in the browser, which is the point — §8 requires a usable path out
|
||||
* of a blank model, not a better error about it.
|
||||
*
|
||||
* ## Why this is fetched once
|
||||
*
|
||||
* The connection test is a real request to Ollama. It runs on mount and when
|
||||
* something asks for it, and never on a timer: a status line that re-tested
|
||||
* every few seconds would be a polling loop against the reader's inference
|
||||
* host, which §38 of the milestone specifically looks for. Anything that
|
||||
* changes the answer — saving settings, a turn failing — calls `refresh`.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
const ModelStatusContext = createContext(null)
|
||||
|
||||
/** No model may be chosen on the reader's behalf — see `resolve` below. */
|
||||
export function ModelStatusProvider({ children }) {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [probe, setProbe] = useState(null) // the raw test result
|
||||
const [checking, setChecking] = useState(true)
|
||||
// Guards against two refreshes overlapping and the slower one winning.
|
||||
const runId = useRef(0)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const mine = ++runId.current
|
||||
setChecking(true)
|
||||
try {
|
||||
const fresh = await api.getSettings()
|
||||
if (runId.current !== mine) return null
|
||||
setSettings(fresh)
|
||||
const result = await api.testConnection()
|
||||
if (runId.current !== mine) return null
|
||||
setProbe(result)
|
||||
return result
|
||||
} catch (err) {
|
||||
if (runId.current !== mine) return null
|
||||
setProbe({ ok: false, detail: err.message })
|
||||
return null
|
||||
} finally {
|
||||
if (runId.current === mine) setChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const value = useMemo(() => {
|
||||
const models = probe?.ok ? (probe.models || []) : []
|
||||
const model = settings?.model || ''
|
||||
let status = 'checking'
|
||||
if (!checking) {
|
||||
if (!probe?.ok) status = 'unavailable'
|
||||
else if (!model) status = 'no-model'
|
||||
// An endpoint that lists nothing is not evidence the model is absent —
|
||||
// some servers answer /models with an empty body. Only claim the model is
|
||||
// missing when there is a listing to be missing from.
|
||||
else if (models.length > 0 && !models.includes(model)) status = 'missing-model'
|
||||
else status = 'ready'
|
||||
}
|
||||
return {
|
||||
status,
|
||||
checking,
|
||||
settings,
|
||||
model,
|
||||
models,
|
||||
endpoint: settings?.endpoint_url || '',
|
||||
embeddingModel: settings?.embedding_model || '',
|
||||
detail: probe?.ok ? (probe.warning || '') : (probe?.detail || ''),
|
||||
refresh,
|
||||
// Writing the chosen model back is done here rather than in the caller so
|
||||
// the status updates in the same breath as the setting.
|
||||
async chooseModel(name) {
|
||||
await api.updateSettings({ ...settings, model: name })
|
||||
await refresh()
|
||||
},
|
||||
}
|
||||
}, [checking, probe, settings, refresh])
|
||||
|
||||
return <ModelStatusContext.Provider value={value}>{children}</ModelStatusContext.Provider>
|
||||
}
|
||||
|
||||
export function useModelStatus() {
|
||||
const value = useContext(ModelStatusContext)
|
||||
if (!value) throw new Error('useModelStatus must be used inside ModelStatusProvider')
|
||||
return value
|
||||
}
|
||||
|
||||
/** The short label for the header. */
|
||||
export function statusLabel(status) {
|
||||
switch (status) {
|
||||
case 'ready': return 'Ollama: Connected'
|
||||
case 'no-model': return 'No model chosen'
|
||||
case 'missing-model': return 'Model not installed'
|
||||
case 'unavailable': return 'Ollama unavailable'
|
||||
default: return 'Checking Ollama…'
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a turn cannot succeed, so the composer should say so up front. */
|
||||
export function blocksPlay(status) {
|
||||
return status === 'no-model' || status === 'missing-model' || status === 'unavailable'
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Model status: the five states, and the way out of each one.
|
||||
*
|
||||
* §30 names "model-empty/unavailable state". The state machine is the part
|
||||
* worth testing directly — the difference between "no model chosen" and "the
|
||||
* chosen model is not installed" is the difference between a list to pick from
|
||||
* and a command to run, and getting it wrong sends the reader down the wrong
|
||||
* path.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from './api'
|
||||
import { ModelStatusBadge } from './ModelStatusBadge'
|
||||
import { ModelSetupNotice } from './ModelSetupNotice'
|
||||
import { mockModelStatus, renderWith, SETTINGS } from './test/helpers'
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('the badge (§44)', () => {
|
||||
it('reports Connected with the model when everything works', async () => {
|
||||
mockModelStatus(api)
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
const badge = screen.getByTestId('model-status')
|
||||
expect(badge).toHaveAttribute('data-status', 'ready')
|
||||
expect(badge).toHaveTextContent('Ollama: Connected')
|
||||
expect(badge).toHaveTextContent('qwen2.5:3b-instruct')
|
||||
})
|
||||
|
||||
it('reports Ollama unavailable when the endpoint cannot be reached', async () => {
|
||||
mockModelStatus(api, { ok: false, detail: 'Could not connect to http://127.0.0.1:11434/v1' })
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
const badge = screen.getByTestId('model-status')
|
||||
expect(badge).toHaveAttribute('data-status', 'unavailable')
|
||||
expect(badge).toHaveTextContent('Ollama unavailable')
|
||||
})
|
||||
|
||||
it('reports a blank model configuration rather than looking healthy', async () => {
|
||||
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'no-model')
|
||||
})
|
||||
|
||||
it('tells a missing model apart from an unchosen one', async () => {
|
||||
mockModelStatus(api, {
|
||||
settings: { ...SETTINGS, model: 'not-pulled:latest' },
|
||||
models: ['qwen2.5:3b-instruct'],
|
||||
})
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'missing-model')
|
||||
})
|
||||
|
||||
it('does not claim a model is missing when the endpoint listed nothing', async () => {
|
||||
// Some servers answer /models with an empty body. An empty listing is not
|
||||
// evidence the model is absent, and claiming it is would send the reader
|
||||
// to pull a model they already have.
|
||||
mockModelStatus(api, { models: [] })
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'ready')
|
||||
})
|
||||
|
||||
it('is a link to Settings, with the whole sentence as its accessible name', async () => {
|
||||
mockModelStatus(api)
|
||||
await renderWith(<ModelStatusBadge />)
|
||||
const badge = screen.getByTestId('model-status')
|
||||
expect(badge.tagName).toBe('A')
|
||||
expect(badge).toHaveAccessibleName(/Open Settings/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the way out of a blank model (§8)', () => {
|
||||
it('offers the installed models to choose from', async () => {
|
||||
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
|
||||
await renderWith(<ModelSetupNotice />)
|
||||
expect(screen.getByText('Choose a narrator model')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'qwen2.5:3b-instruct' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('chooses nothing on its own', async () => {
|
||||
const update = vi.spyOn(api, 'updateSettings').mockResolvedValue({})
|
||||
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
|
||||
await renderWith(<ModelSetupNotice />)
|
||||
// Rendering the notice must not write a setting. Picking the first model in
|
||||
// a listing would silently narrate with whatever sorted first, which may be
|
||||
// an embedding model.
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the chosen model back when one is picked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const update = vi.spyOn(api, 'updateSettings').mockResolvedValue({})
|
||||
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
|
||||
await renderWith(<ModelSetupNotice />)
|
||||
await user.click(screen.getByRole('button', { name: 'qwen2.5:3b-instruct' }))
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'qwen2.5:3b-instruct' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('warns that an embedding model in the list cannot narrate', async () => {
|
||||
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
|
||||
await renderWith(<ModelSetupNotice />)
|
||||
expect(screen.getByText(/for searching your imported material, not for narrating/))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('gives local troubleshooting steps when Ollama is not running', async () => {
|
||||
mockModelStatus(api, { ok: false, detail: 'Could not connect' })
|
||||
await renderWith(<ModelSetupNotice />)
|
||||
expect(screen.getByText('Ollama is not reachable')).toBeInTheDocument()
|
||||
expect(screen.getByText(/ollama serve/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says nothing at all when the model is working', async () => {
|
||||
mockModelStatus(api)
|
||||
const { container } = await renderWith(<ModelSetupNotice />)
|
||||
expect(container.querySelector('[data-testid="model-setup-notice"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers no cloud provider anywhere (§45)', async () => {
|
||||
mockModelStatus(api, { ok: false, detail: 'Could not connect' })
|
||||
const { container } = await renderWith(<ModelSetupNotice />)
|
||||
const text = container.textContent.toLowerCase()
|
||||
for (const word of ['openai', 'anthropic', 'openrouter', 'api key', 'sign in']) {
|
||||
expect(text).not.toContain(word)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { CardSkeleton, downloadJSON, pickJSONFile, ScenarioArt, useToast } from '../components'
|
||||
|
||||
export default function Adventures() {
|
||||
const [adventures, setAdventures] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
|
||||
}, [])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!adventures) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return adventures
|
||||
return adventures.filter((a) =>
|
||||
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
|
||||
}, [adventures, search])
|
||||
|
||||
const remove = async (e, id) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Delete this adventure permanently?')) return
|
||||
try {
|
||||
await api.deleteAdventure(id)
|
||||
setAdventures(adventures.filter((a) => a.id !== id))
|
||||
toast('Adventure deleted')
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const exportOne = async (e, adv) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const bundle = await api.exportAdventure(adv.id)
|
||||
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const importOne = async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const adv = await api.importAdventure(bundle)
|
||||
navigate(`/play/${adv.id}`)
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Adventures</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={importOne}>Import</button>
|
||||
<button className="primary" onClick={() => navigate('/scenarios')}>
|
||||
+ New Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adventures?.length > 0 && (
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search adventures…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible === null ? (
|
||||
<CardSkeleton count={4} lines={3} />
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{adventures.length === 0
|
||||
? 'No adventures yet. Head to Scenarios to begin your first story.'
|
||||
: 'No adventures match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid wide">
|
||||
{visible.map((adv, i) => (
|
||||
<article
|
||||
key={adv.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 50}ms` }}
|
||||
onClick={() => navigate(`/play/${adv.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
|
||||
<div className="card-headings">
|
||||
<h3>{adv.title}</h3>
|
||||
<p className="card-from">
|
||||
{/* Adventures inherit the scenario's title, so only name
|
||||
the source when it actually differs. */}
|
||||
{adv.scenario_title && adv.scenario_title !== adv.title
|
||||
? `From “${adv.scenario_title}” · `
|
||||
: ''}
|
||||
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adv.snippet && <p className="snippet">{adv.snippet}</p>}
|
||||
|
||||
<footer className="card-foot">
|
||||
<span className="turns">
|
||||
Last played {new Date(adv.updated_at + 'Z').toLocaleDateString()}
|
||||
</span>
|
||||
<span className="card-actions">
|
||||
<button className="tiny" title="Export as JSON backup" onClick={(e) => exportOne(e, adv)}>
|
||||
Export
|
||||
</button>
|
||||
<button className="tiny danger" onClick={(e) => remove(e, adv.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* The campaign library — the landing page, and the only screen above a campaign.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §40 and §99. It replaces two upstream screens that
|
||||
* listed the same rows twice: a Home page with a "Continue" strip above a
|
||||
* scenario gallery, and an Adventures index. One list, everything you can do to
|
||||
* a campaign on its card.
|
||||
*
|
||||
* Nothing here shows an id. A campaign is identified by its title and when it
|
||||
* was last played, because those are what a person recognises; the integer in
|
||||
* the URL is an implementation detail and stays there.
|
||||
*
|
||||
* Deleting is the one destructive action reachable from this screen, so it is
|
||||
* the one thing behind a typed confirmation (§67).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { CardSkeleton, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
import { ConfirmDialog } from '../Dialog'
|
||||
import { classifyError } from '../errors'
|
||||
|
||||
/** "3 minutes ago" / "yesterday" / a date, from a naive-UTC timestamp. */
|
||||
function relativeTime(iso) {
|
||||
if (!iso) return 'never'
|
||||
const then = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
|
||||
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
|
||||
if (minutes < 2) return 'just now'
|
||||
if (minutes < 60) return `${minutes} minutes ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return hours === 1 ? 'an hour ago' : `${hours} hours ago`
|
||||
const days = Math.round(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 7) return `${days} days ago`
|
||||
return then.toLocaleDateString()
|
||||
}
|
||||
|
||||
export default function Campaigns() {
|
||||
const [campaigns, setCampaigns] = useState(null)
|
||||
const [failed, setFailed] = useState(null)
|
||||
const [confirming, setConfirming] = useState(null) // the campaign awaiting a typed name
|
||||
const [importing, setImporting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.listAdventures()
|
||||
.then((list) => { if (!cancelled) setCampaigns(list) })
|
||||
.catch((err) => { if (!cancelled) setFailed(classifyError(err.message)) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const empty = campaigns !== null && campaigns.length === 0
|
||||
|
||||
const remove = async (campaign) => {
|
||||
try {
|
||||
await api.deleteAdventure(campaign.id)
|
||||
setCampaigns((list) => list.filter((c) => c.id !== campaign.id))
|
||||
setConfirming(null)
|
||||
toast(`“${campaign.title}” was deleted.`)
|
||||
} catch (err) {
|
||||
toast(classifyError(err.message).detail, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const exportOne = async (campaign) => {
|
||||
try {
|
||||
const bundle = await api.exportAdventure(campaign.id)
|
||||
const safe = (campaign.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
toast('Campaign exported.')
|
||||
} catch (err) {
|
||||
toast(classifyError(err.message).detail, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const importOne = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const campaign = await api.importAdventure(bundle)
|
||||
navigate(`/play/${campaign.id}`)
|
||||
} catch (err) {
|
||||
// A cancelled file picker is not a failure worth a message.
|
||||
if (err?.message) toast(classifyError(err.message).detail, 'error')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cards = useMemo(() => campaigns || [], [campaigns])
|
||||
|
||||
return (
|
||||
<div className="page library">
|
||||
<header className="library-head">
|
||||
<div>
|
||||
<h1>Your campaigns</h1>
|
||||
<p className="library-sub">
|
||||
Everything here is stored on this machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="library-actions">
|
||||
<button type="button" onClick={importOne} disabled={importing}>
|
||||
{importing ? 'Importing…' : 'Import campaign'}
|
||||
</button>
|
||||
<Link className="button primary" to="/new">New campaign</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{failed && (
|
||||
<div className="notice error" role="alert">
|
||||
<strong>{failed.title}</strong>
|
||||
<span>{failed.detail}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{campaigns === null && !failed && <CardSkeleton count={3} lines={2} />}
|
||||
|
||||
{empty && (
|
||||
<div className="library-empty">
|
||||
<h2>No campaigns yet</h2>
|
||||
<p>
|
||||
A campaign is one continuous story. Start one and write the first
|
||||
thing your character does — everything else, from what the story
|
||||
believes to the material you import into it, grows from there.
|
||||
</p>
|
||||
<Link className="button primary" to="/new">Start your first campaign</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cards.length > 0 && (
|
||||
<ul className="campaign-grid">
|
||||
{cards.map((c) => (
|
||||
<li key={c.id} className="campaign-card">
|
||||
{/* The whole title is the link, so the card has exactly one
|
||||
navigation target rather than an onClick on a <div>. */}
|
||||
<h2 className="campaign-title">
|
||||
<Link to={`/play/${c.id}`}>{c.title || 'Untitled campaign'}</Link>
|
||||
</h2>
|
||||
<p className="campaign-meta">
|
||||
{c.action_count} {c.action_count === 1 ? 'moment' : 'moments'}
|
||||
{' · '}
|
||||
last played {relativeTime(c.updated_at)}
|
||||
</p>
|
||||
{c.snippet
|
||||
? <p className="campaign-snippet">{c.snippet}</p>
|
||||
: <p className="campaign-snippet muted">Not a word written yet.</p>}
|
||||
<div className="campaign-tools">
|
||||
<Link className="button" to={`/play/${c.id}`}>Open</Link>
|
||||
<button type="button" onClick={() => exportOne(c)}>Export</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => setConfirming(c)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<ConfirmDialog
|
||||
title={`Delete “${confirming.title}”?`}
|
||||
confirmLabel="Delete this campaign"
|
||||
destructive
|
||||
// §67: the stronger confirmation. Deleting a campaign destroys the
|
||||
// story, its Save Points, its state and its imported knowledge, and
|
||||
// nothing else in the product does that.
|
||||
requireText={confirming.title}
|
||||
requireLabel="Type the campaign name to confirm"
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => remove(confirming)}
|
||||
>
|
||||
<p>
|
||||
This permanently deletes the whole story, every Save Point, the
|
||||
story state, and everything imported into it. It cannot be undone.
|
||||
</p>
|
||||
<p>
|
||||
If you might want it back, export it first.
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/* AI Chat — a plain scratchpad for talking to the configured model, with none
|
||||
of the game's context assembly in the way. Useful for checking a model or a
|
||||
prompt without starting an adventure.
|
||||
|
||||
Deliberately client-side: the conversation lives in localStorage, not the
|
||||
database. Nothing here is part of an adventure, so there's nothing worth a
|
||||
migration — and a refresh still keeps what you were poking at. */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useToast } from '../components'
|
||||
|
||||
const STORAGE_KEY = 'aidnd.chat.v1'
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
||||
return {
|
||||
messages: Array.isArray(saved.messages) ? saved.messages : [],
|
||||
system: typeof saved.system === 'string' ? saved.system : '',
|
||||
model: typeof saved.model === 'string' ? saved.model : '',
|
||||
temperature: saved.temperature ?? '',
|
||||
maxTokens: saved.maxTokens ?? '',
|
||||
}
|
||||
} catch {
|
||||
return { messages: [], system: '', model: '', temperature: '', maxTokens: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { user: 'You', assistant: 'AI', system: 'System' }
|
||||
|
||||
function ReasoningBlock({ text, streaming }) {
|
||||
if (!text) return null
|
||||
return (
|
||||
<details className="reasoning" open={streaming || undefined}>
|
||||
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
|
||||
<div className="reasoning-text">{text}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function Message({ message, onDelete }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(message.content).then(
|
||||
() => { setCopied(true); setTimeout(() => setCopied(false), 1500) },
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={`chat-msg ${message.role}`}>
|
||||
<div className="chat-msg-head">
|
||||
<span className="chat-role">{ROLE_LABEL[message.role] || message.role}</span>
|
||||
{message.model && <span className="dim chat-model-tag">{message.model}</span>}
|
||||
<span className="chat-msg-actions">
|
||||
<button className="linklike" onClick={copy}>{copied ? 'copied' : 'copy'}</button>
|
||||
<button className="linklike" onClick={onDelete}>delete</button>
|
||||
</span>
|
||||
</div>
|
||||
<ReasoningBlock text={message.reasoning} />
|
||||
<div className="chat-msg-body">{message.content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const toast = useToast()
|
||||
|
||||
const initial = useRef(load()).current
|
||||
const [messages, setMessages] = useState(initial.messages)
|
||||
const [system, setSystem] = useState(initial.system)
|
||||
const [model, setModel] = useState(initial.model)
|
||||
const [temperature, setTemperature] = useState(initial.temperature)
|
||||
const [maxTokens, setMaxTokens] = useState(initial.maxTokens)
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [config, setConfig] = useState(null)
|
||||
const [showOptions, setShowOptions] = useState(false)
|
||||
// Streaming reply in progress: null when idle, else the text so far ('' before
|
||||
// the first token). `busy` covers the whole request, including the wait.
|
||||
const [streaming, setStreaming] = useState(null)
|
||||
const [reasoningStream, setReasoningStream] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const abortRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const pinnedRef = useRef(true)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
api.getChatConfig().then(setConfig).catch(() => setConfig(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ messages, system, model, temperature, maxTokens }),
|
||||
)
|
||||
}, [messages, system, model, temperature, maxTokens])
|
||||
|
||||
// Grow the composer with its content (CSS caps the height, then it scrolls).
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
pinnedRef.current =
|
||||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedRef.current) window.scrollTo({ top: document.documentElement.scrollHeight })
|
||||
}, [messages, streaming, reasoningStream])
|
||||
|
||||
// Abort any in-flight stream when leaving the page.
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const send = useCallback(async (history) => {
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setBusy(true)
|
||||
setStreaming('')
|
||||
setReasoningStream(null)
|
||||
pinnedRef.current = true
|
||||
|
||||
const payload = {
|
||||
messages: [
|
||||
...(system.trim() ? [{ role: 'system', content: system.trim() }] : []),
|
||||
...history.map(({ role, content }) => ({ role, content })),
|
||||
],
|
||||
}
|
||||
if (model.trim()) payload.model = model.trim()
|
||||
if (temperature !== '' && temperature !== null) payload.temperature = Number(temperature)
|
||||
if (maxTokens !== '' && maxTokens !== null) payload.max_tokens = Number(maxTokens)
|
||||
|
||||
let reasoning = ''
|
||||
try {
|
||||
await api.chatStream(payload, (event) => {
|
||||
if (event.type === 'chunk') {
|
||||
setStreaming((prev) => (prev ?? '') + event.text)
|
||||
} else if (event.type === 'reasoning') {
|
||||
reasoning += event.text
|
||||
setReasoningStream((prev) => (prev ?? '') + event.text)
|
||||
} else if (event.type === 'note') {
|
||||
toast(event.detail)
|
||||
} else if (event.type === 'done') {
|
||||
setMessages((prev) => [...prev, {
|
||||
role: 'assistant',
|
||||
content: event.text,
|
||||
reasoning: event.reasoning || reasoning || undefined,
|
||||
model: event.model,
|
||||
}])
|
||||
} else if (event.type === 'error') {
|
||||
toast(event.detail, 'error')
|
||||
}
|
||||
}, controller.signal)
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') toast(err.message, 'error')
|
||||
} finally {
|
||||
abortRef.current = null
|
||||
setBusy(false)
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
}
|
||||
}, [system, model, temperature, maxTokens, toast])
|
||||
|
||||
const submit = () => {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const history = [...messages, { role: 'user', content: text }]
|
||||
setMessages(history)
|
||||
setInput('')
|
||||
send(history)
|
||||
}
|
||||
|
||||
const regenerate = () => {
|
||||
if (busy) return
|
||||
// Drop trailing assistant replies and re-send from the last user message.
|
||||
let history = [...messages]
|
||||
while (history.length && history[history.length - 1].role === 'assistant') history.pop()
|
||||
if (!history.length) return
|
||||
setMessages(history)
|
||||
send(history)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort()
|
||||
// Keep whatever streamed in — a cut-off reply is often the thing you wanted.
|
||||
const partial = streaming?.trim()
|
||||
if (partial) {
|
||||
setMessages((prev) => [...prev, {
|
||||
role: 'assistant',
|
||||
content: partial,
|
||||
reasoning: reasoningStream || undefined,
|
||||
model: config?.model,
|
||||
stopped: true,
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
if (busy || !messages.length) return
|
||||
setMessages([])
|
||||
toast('Conversation cleared')
|
||||
}
|
||||
|
||||
const deleteAt = (index) => setMessages((prev) => prev.filter((_, i) => i !== index))
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
const waitingForFirstToken = streaming === '' && reasoningStream === null
|
||||
const canRegenerate = !busy && messages.some((m) => m.role === 'user')
|
||||
|
||||
return (
|
||||
<div className="page chat-page">
|
||||
<div className="page-header">
|
||||
<h1>AI Chat</h1>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button className="linklike" onClick={() => setShowOptions((o) => !o)}>
|
||||
{showOptions ? 'hide options' : 'options'}
|
||||
</button>
|
||||
<button onClick={regenerate} disabled={!canRegenerate}>Regenerate</button>
|
||||
<button className="danger" onClick={clear} disabled={busy || !messages.length}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-meta dim">
|
||||
{config
|
||||
? <>
|
||||
{/* The override wins when set, so show what a send would actually use. */}
|
||||
{model.trim() || config.model || '(no model set)'} · {config.endpoint_url}
|
||||
{config.using_demo && ' · shared demo key (whitelisted models only)'}
|
||||
{config.api_mode === 'completion' && ' · completion mode (messages are flattened)'}
|
||||
</>
|
||||
: 'Loading provider config…'}
|
||||
</div>
|
||||
|
||||
{showOptions && (
|
||||
<div className="chat-options">
|
||||
<label className="field">
|
||||
<span className="label">System prompt (sent first, every turn — empty = none)</span>
|
||||
<textarea rows={3} value={system} placeholder="You are a helpful assistant."
|
||||
onChange={(e) => setSystem(e.target.value)} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||
<label className="field" style={{ flex: '2 1 240px' }}>
|
||||
<span className="label">Model {config?.using_demo ? '(demo whitelist)' : '(empty = Settings default)'}</span>
|
||||
<input type="text" list="chat-models" value={model} placeholder={config?.model || 'model slug'}
|
||||
onChange={(e) => setModel(e.target.value)} />
|
||||
<datalist id="chat-models">
|
||||
{(config?.models || []).map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="field" style={{ flex: '1 1 110px' }}>
|
||||
<span className="label">Temperature</span>
|
||||
<input type="number" step="0.1" min="0" max="5" value={temperature}
|
||||
placeholder={config?.temperature ?? ''}
|
||||
onChange={(e) => setTemperature(e.target.value)} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: '1 1 130px' }}>
|
||||
<span className="label">Max tokens</span>
|
||||
<input type="number" min="1" value={maxTokens}
|
||||
placeholder={config?.max_tokens ?? ''}
|
||||
onChange={(e) => setMaxTokens(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{config?.models_error && (
|
||||
<div className="dim" style={{ fontSize: '0.82rem' }}>
|
||||
Couldn't list models from the endpoint: {config.models_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-transcript">
|
||||
{!messages.length && streaming === null && (
|
||||
<div className="empty">
|
||||
Nothing here yet — no story, no scripts, no world state. Just you and the model.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<Message key={i} message={m} onDelete={() => deleteAt(i)} />
|
||||
))}
|
||||
{streaming !== null && (
|
||||
<div className="chat-msg assistant">
|
||||
<div className="chat-msg-head">
|
||||
<span className="chat-role">AI</span>
|
||||
<span className="dim chat-model-tag">{model.trim() || config?.model}</span>
|
||||
</div>
|
||||
<ReasoningBlock text={reasoningStream} streaming />
|
||||
{waitingForFirstToken
|
||||
? <div className="thinking" role="status"><i /><i /><i /><span>Thinking</span></div>
|
||||
: <div className="chat-msg-body">{streaming}<span className="cursor">▋</span></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-composer">
|
||||
<textarea ref={inputRef} rows={1} value={input} onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown} placeholder="Message the model… (Enter to send, Shift+Enter for a new line)" />
|
||||
{busy
|
||||
? <button className="danger" onClick={stop}>Stop</button>
|
||||
: <button className="primary" onClick={submit} disabled={!input.trim()}>Send</button>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
CardSkeleton,
|
||||
extractPlaceholders,
|
||||
BeginAdventureModal,
|
||||
ScenarioArt,
|
||||
useToast,
|
||||
} from '../components'
|
||||
|
||||
// How many in-progress stories the landing page shows before deferring to
|
||||
// "See all". Two rows on a wide screen; enough to recognise, not a full index.
|
||||
const CONTINUE_LIMIT = 4
|
||||
const SCENARIO_LIMIT = 6
|
||||
|
||||
function splitTags(tags, { isPublic = false } = {}) {
|
||||
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
|
||||
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
// Stored timestamps are naive UTC, hence the appended Z (matches the rest of
|
||||
// the app's date handling).
|
||||
const then = new Date(iso + 'Z')
|
||||
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
|
||||
if (minutes < 2) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.round(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return then.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** Section heading framed by ornamental rules — the illuminated-tome motif. */
|
||||
function Rule({ children, action }) {
|
||||
return (
|
||||
<div className="rule-head">
|
||||
<h2 className="rule">
|
||||
<span>{children}</span>
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [adventures, setAdventures] = useState(null)
|
||||
const [scenarios, setScenarios] = useState(null)
|
||||
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholders
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
|
||||
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
||||
}, [])
|
||||
|
||||
const ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures])
|
||||
const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios])
|
||||
|
||||
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
|
||||
try {
|
||||
const adv = await api.createAdventure({
|
||||
scenario_id: scenarioId,
|
||||
placeholders,
|
||||
persona_name: persona.name || '',
|
||||
persona_pronouns: persona.pronouns || '',
|
||||
persona_desc: persona.desc || '',
|
||||
})
|
||||
navigate(`/play/${adv.id}`)
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const startAdventure = async (e, scenarioId) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const scenario = await api.getScenario(scenarioId)
|
||||
const names = extractPlaceholders(
|
||||
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
||||
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
|
||||
)
|
||||
// Always open the modal, even with no placeholders: it is where the
|
||||
// player names their character.
|
||||
setPending({ scenario, names })
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const loading = adventures === null || scenarios === null
|
||||
const nothingAtAll = !loading && adventures.length === 0 && scenarios.length === 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Returning players want their story first, so the only thing above the
|
||||
fold is a compact banner — no full-height splash. */}
|
||||
<header className="hall">
|
||||
<p className="hall-eyebrow">Welcome back</p>
|
||||
<h1 className="hall-title">The table is set</h1>
|
||||
<p className="hall-sub">
|
||||
{loading
|
||||
? 'Gathering your stories…'
|
||||
: adventures.length > 0
|
||||
? `${adventures.length} ${adventures.length === 1 ? 'story' : 'stories'} in progress · ${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} to explore`
|
||||
: `${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} waiting for a first line`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* ---------- Continue ---------- */}
|
||||
{(loading || adventures.length > 0) && (
|
||||
<section className="home-section">
|
||||
<Rule
|
||||
action={
|
||||
adventures?.length > CONTINUE_LIMIT ? (
|
||||
<button className="linklike ornate" onClick={() => navigate('/adventures')}>
|
||||
See all {adventures.length} ❖
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Rule>
|
||||
|
||||
{loading ? (
|
||||
<CardSkeleton count={2} lines={3} />
|
||||
) : (
|
||||
<div className="card-grid wide">
|
||||
{ongoing.map((adv, i) => (
|
||||
<article
|
||||
key={adv.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
onClick={() => navigate(`/play/${adv.id}`)}
|
||||
>
|
||||
<span className="seal" title="In progress" aria-hidden="true">✦</span>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
|
||||
<div className="card-headings">
|
||||
<h3>{adv.title}</h3>
|
||||
{/* An adventure created from a scenario inherits its
|
||||
title, so showing both just prints it twice. */}
|
||||
{adv.scenario_title && adv.scenario_title !== adv.title && (
|
||||
<p className="card-from">From “{adv.scenario_title}”</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{adv.snippet ? (
|
||||
<p className="snippet dropcap">{adv.snippet}</p>
|
||||
) : (
|
||||
<p className="snippet muted">Not a word written yet. Open it and begin.</p>
|
||||
)}
|
||||
<footer className="card-foot">
|
||||
<span className="turns">
|
||||
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
|
||||
</span>
|
||||
<span className="dot" aria-hidden="true">·</span>
|
||||
<span className="turns">{relativeTime(adv.updated_at)}</span>
|
||||
<span className="resume">Resume →</span>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---------- Scenarios ---------- */}
|
||||
<section className="home-section">
|
||||
<Rule
|
||||
action={
|
||||
scenarios?.length > SCENARIO_LIMIT ? (
|
||||
<button className="linklike ornate" onClick={() => navigate('/scenarios')}>
|
||||
See all {scenarios.length} ❖
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{adventures?.length > 0 ? 'Begin a new story' : 'Choose a world'}
|
||||
</Rule>
|
||||
|
||||
{loading ? (
|
||||
<CardSkeleton count={3} />
|
||||
) : nothingAtAll ? (
|
||||
<div className="empty">
|
||||
Nothing here yet. Create a scenario to define your first world.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{featured.map((sc, i) => (
|
||||
<article
|
||||
key={sc.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
onClick={() => navigate(`/scenarios/${sc.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
|
||||
<div className="card-headings">
|
||||
<h3>{sc.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="snippet">{sc.description || 'No description yet.'}</p>
|
||||
<footer className="card-foot">
|
||||
<div className="tag-cluster">
|
||||
{sc.is_public && (
|
||||
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
|
||||
)}
|
||||
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
|
||||
Play
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pending && (
|
||||
<BeginAdventureModal
|
||||
title={pending.scenario.title}
|
||||
names={pending.names}
|
||||
onCancel={() => setPending(null)}
|
||||
onSubmit={(answers) => { setPending(null); begin(pending.scenario.id, answers) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/* Starting a campaign, in one screen.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §41-42 and §7 of the M8 brief. What it replaced was a
|
||||
* scenario gallery followed by a placeholder modal: to begin a story you first
|
||||
* picked a *world*, and to make a world you opened an editor with a JSON stat
|
||||
* schema, a story-card table and an art picker. That is a fine way to build a
|
||||
* D&D module and a poor way to start a mystery.
|
||||
*
|
||||
* Two rules shaped this form.
|
||||
*
|
||||
* **Only the name is required.** Everything else has a sensible blank. A reader
|
||||
* who types "Westhaven" and presses Start gets a working campaign and can write
|
||||
* the first line themselves; the rest of the fields are there for someone who
|
||||
* already knows what they want. §7 is explicit that the data model must not be
|
||||
* a gate on beginning.
|
||||
*
|
||||
* **No genre is privileged.** There is no fantasy vocabulary in the labels, the
|
||||
* placeholders or the composed instructions — the same form has to fit a
|
||||
* western and a horror story. Genre and tone are free text with suggestions
|
||||
* rather than a closed list, because a closed list is a judgement about which
|
||||
* stories this product is for.
|
||||
*
|
||||
* The style answers are composed into the campaign's narrator instructions
|
||||
* (`ai_instructions`), which is the field the prompt builder already reads. No
|
||||
* new backend concept was invented to hold "tone" — it is a sentence in the
|
||||
* instructions, which is what it would have to become anyway.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useToast } from '../components'
|
||||
import { classifyError } from '../errors'
|
||||
import { blocksPlay, useModelStatus } from '../modelStatus'
|
||||
import { ModelSetupNotice } from '../ModelSetupNotice'
|
||||
|
||||
const GENRES = [
|
||||
'Fantasy', 'Science fiction', 'Mystery', 'Historical',
|
||||
'Western', 'Horror', 'Thriller', 'Literary',
|
||||
]
|
||||
const TONES = [
|
||||
'Grounded and restrained', 'Warm and hopeful', 'Bleak', 'Wry',
|
||||
'Tense', 'Whimsical', 'Epic',
|
||||
]
|
||||
const POV = [
|
||||
{ value: 'second-present', label: 'Second person, present tense ("You walk…")' },
|
||||
{ value: 'first-past', label: 'First person, past tense ("I walked…")' },
|
||||
{ value: 'third-past', label: 'Third person, past tense ("She walked…")' },
|
||||
{ value: 'third-present', label: 'Third person, present tense ("She walks…")' },
|
||||
]
|
||||
const LENGTHS = [
|
||||
{ value: 'brief', label: 'Brief — a paragraph or two' },
|
||||
{ value: 'medium', label: 'Medium — two to four paragraphs' },
|
||||
{ value: 'long', label: 'Long — four or more paragraphs' },
|
||||
]
|
||||
|
||||
const POV_SENTENCE = {
|
||||
'second-present': 'Write in second person, present tense.',
|
||||
'first-past': 'Write in first person, past tense.',
|
||||
'third-past': 'Write in third person, past tense.',
|
||||
'third-present': 'Write in third person, present tense.',
|
||||
}
|
||||
const LENGTH_SENTENCE = {
|
||||
brief: 'Keep responses brief — one or two paragraphs.',
|
||||
medium: 'Keep responses to roughly two to four paragraphs.',
|
||||
long: 'Write at length — four or more paragraphs.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the style answers into narrator instructions.
|
||||
*
|
||||
* Exported because the composition is the one piece of real logic on this
|
||||
* screen and a component test can check it without rendering anything.
|
||||
*/
|
||||
export function composeInstructions({ genre, tone, pov, length, style }) {
|
||||
const lines = []
|
||||
if (genre.trim()) lines.push(`This is a ${genre.trim().toLowerCase()} story.`)
|
||||
if (tone.trim()) lines.push(`The tone is ${tone.trim().toLowerCase()}.`)
|
||||
if (POV_SENTENCE[pov]) lines.push(POV_SENTENCE[pov])
|
||||
if (LENGTH_SENTENCE[length]) lines.push(LENGTH_SENTENCE[length])
|
||||
// Always last, so a reader's own words can override anything above them.
|
||||
if (style.trim()) lines.push(style.trim())
|
||||
return lines.join(' ')
|
||||
}
|
||||
|
||||
/** A text field with a datalist of suggestions — open, not a closed set. */
|
||||
function SuggestField({ id, label, value, onChange, options, placeholder, hint }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
list={`${id}-options`}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={`${id}-options`}>
|
||||
{options.map((o) => <option key={o} value={o} />)}
|
||||
</datalist>
|
||||
{hint && <p className="field-hint">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewCampaign() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { status } = useModelStatus()
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [genre, setGenre] = useState('')
|
||||
const [tone, setTone] = useState('')
|
||||
const [pov, setPov] = useState('second-present')
|
||||
const [length, setLength] = useState('medium')
|
||||
const [style, setStyle] = useState('')
|
||||
const [protagonist, setProtagonist] = useState('')
|
||||
const [pronouns, setPronouns] = useState('')
|
||||
const [protagonistDesc, setProtagonistDesc] = useState('')
|
||||
const [opening, setOpening] = useState('')
|
||||
const [canon, setCanon] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const start = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!title.trim() || creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const campaign = await api.createAdventure({
|
||||
title: title.trim(),
|
||||
opening: opening.trim(),
|
||||
// One rule per line is the lightest editor that produces a list, and it
|
||||
// is what a person writing rules actually does.
|
||||
canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean),
|
||||
persona_name: protagonist.trim(),
|
||||
persona_pronouns: pronouns.trim(),
|
||||
persona_desc: protagonistDesc.trim(),
|
||||
})
|
||||
const instructions = composeInstructions({ genre, tone, pov, length, style })
|
||||
if (instructions) {
|
||||
await api.updateAdventure(campaign.id, { ai_instructions: instructions })
|
||||
}
|
||||
navigate(`/play/${campaign.id}`)
|
||||
} catch (err) {
|
||||
toast(classifyError(err.message).detail, 'error')
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page setup">
|
||||
<h1>New campaign</h1>
|
||||
<p className="setup-lede">
|
||||
Only a name is needed. Everything else can be changed later, or left to
|
||||
the story to decide.
|
||||
</p>
|
||||
|
||||
{blocksPlay(status) && <ModelSetupNotice />}
|
||||
|
||||
<form onSubmit={start}>
|
||||
<section className="setup-section">
|
||||
<h2>Name</h2>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-title">What is this story called?</label>
|
||||
<input
|
||||
id="campaign-title"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
value={title}
|
||||
placeholder="The Westhaven Enquiry"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setup-section">
|
||||
<h2>Story profile</h2>
|
||||
<div className="field-row">
|
||||
<SuggestField
|
||||
id="campaign-genre" label="Genre" value={genre} onChange={setGenre}
|
||||
options={GENRES} placeholder="Mystery"
|
||||
/>
|
||||
<SuggestField
|
||||
id="campaign-tone" label="Tone" value={tone} onChange={setTone}
|
||||
options={TONES} placeholder="Grounded and restrained"
|
||||
/>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-pov">Voice</label>
|
||||
<select id="campaign-pov" value={pov} onChange={(e) => setPov(e.target.value)}>
|
||||
{POV.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-length">Narration length</label>
|
||||
<select
|
||||
id="campaign-length" value={length}
|
||||
onChange={(e) => setLength(e.target.value)}
|
||||
>
|
||||
{LENGTHS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-style">Anything else the narrator should know</label>
|
||||
<textarea
|
||||
id="campaign-style" rows={2} maxLength={2000} value={style}
|
||||
placeholder="Never resolve a scene with violence without warning me first."
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setup-section">
|
||||
<h2>Protagonist</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-protagonist">Name</label>
|
||||
<input
|
||||
id="campaign-protagonist" type="text" maxLength={80} value={protagonist}
|
||||
placeholder="Aldric" autoComplete="off"
|
||||
onChange={(e) => setProtagonist(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-pronouns">Pronouns</label>
|
||||
<input
|
||||
id="campaign-pronouns" type="text" maxLength={40} value={pronouns}
|
||||
placeholder="they/them" autoComplete="off"
|
||||
onChange={(e) => setPronouns(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-protagonist-desc">Who are they?</label>
|
||||
<textarea
|
||||
id="campaign-protagonist-desc" rows={2} maxLength={2000} value={protagonistDesc}
|
||||
placeholder="A traveling investigator, patient and hard to lie to."
|
||||
onChange={(e) => setProtagonistDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setup-section">
|
||||
<h2>Opening scene</h2>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-opening">Where does the story begin?</label>
|
||||
<textarea
|
||||
id="campaign-opening" rows={4} maxLength={8000} value={opening}
|
||||
placeholder={
|
||||
'You sit at a shared table in the Crooked Lantern Tavern. In your '
|
||||
+ 'pocket is a small silver key you took from a missing scholar’s desk.'
|
||||
}
|
||||
onChange={(e) => setOpening(e.target.value)}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Left blank, the story opens on an empty page and begins with
|
||||
whatever you write first.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setup-section">
|
||||
<h2>Canon</h2>
|
||||
<div className="field">
|
||||
<label htmlFor="campaign-canon">
|
||||
What is true in this story, and must stay true?
|
||||
</label>
|
||||
<textarea
|
||||
id="campaign-canon" rows={4} maxLength={8000} value={canon}
|
||||
placeholder={'One rule per line.\nResurrection is impossible.\nNobody in this town owns a car.'}
|
||||
onChange={(e) => setCanon(e.target.value)}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
One per line. These carry more authority than anything the story
|
||||
later invents — the narrator is told them, and a turn that
|
||||
contradicts one is refused. You can change them at any time.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="setup-actions">
|
||||
<button type="submit" className="primary" disabled={!title.trim() || creating}>
|
||||
{creating ? 'Starting…' : 'Start campaign'}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/')} disabled={creating}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* The story controls and the one box you type into.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §11-13, §18-20, §23, §98, and §10-13 of the M8 brief.
|
||||
*
|
||||
* ## One field, not three modes
|
||||
*
|
||||
* The inherited composer had a Do / Say / Story selector, and the mode changed
|
||||
* what the turn meant. That is a small RPG command language wearing buttons:
|
||||
* "I say to Mara…" typed in Do mode and the same words in Say mode were
|
||||
* different turns, and nothing on screen said so. §12 asks for one
|
||||
* natural-language field, and B01/B02 are exactly the case that proves it works
|
||||
* — an action and a piece of quoted dialogue are both just what the player
|
||||
* wrote, and the narrator reads the quotes.
|
||||
*
|
||||
* So the field sends `do` for everything a protagonist does or says.
|
||||
*
|
||||
* ## Direction is not a fourth mode
|
||||
*
|
||||
* What survives from the old `story` mode is **Story direction** (§13), and it
|
||||
* is deliberately shaped as a toggle rather than a peer of the input: it is not
|
||||
* another way to act, it is a way to speak to the narrator instead of in the
|
||||
* story. The toggle changes the placeholder and the send verb, and the box is
|
||||
* visibly marked while it is on, because the whole failure it prevents is
|
||||
* out-of-character instructions being narrated as speech (B04).
|
||||
*
|
||||
* ## The reserved dictation control
|
||||
*
|
||||
* §11 and §91A ask for room to be left for local speech-to-text. The button is
|
||||
* present, permanently disabled and labelled as not yet available. It requests
|
||||
* no permission, touches no microphone and has no click handler — M8's
|
||||
* non-scope is explicit that a control which *looks* operational and fails
|
||||
* would be worse than none. When it is implemented it will fill this same box
|
||||
* with editable draft text rather than submitting anything, which is why it
|
||||
* sits inside the composer rather than beside Send.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { blocksPlay, useModelStatus } from '../../modelStatus'
|
||||
|
||||
export function Composer({
|
||||
input,
|
||||
setInput,
|
||||
direction,
|
||||
setDirection,
|
||||
busy,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canRetry,
|
||||
onSend,
|
||||
onContinue,
|
||||
onRetry,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSavePoint,
|
||||
onStop,
|
||||
}) {
|
||||
const inputRef = useRef(null)
|
||||
const { status } = useModelStatus()
|
||||
const blocked = blocksPlay(status)
|
||||
|
||||
// Grow with the content; CSS caps the height and then it scrolls.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, [input])
|
||||
|
||||
const submit = () => {
|
||||
if (busy || blocked) return
|
||||
onSend(direction ? 'story' : 'do')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="story-controls" role="group" aria-label="Story controls">
|
||||
<button type="button" onClick={onContinue} disabled={busy || blocked}>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={busy || blocked || !canRetry}
|
||||
title="Generate another response to the same thing you wrote"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button type="button" onClick={onUndo} disabled={busy || !canUndo}>
|
||||
Undo
|
||||
</button>
|
||||
{/* Undo keeps what it steps back over, so Redo walks forward into it
|
||||
again — until something new is written from here, which retires that
|
||||
continuation. The server decides; this only renders the answer. */}
|
||||
<button type="button" onClick={onRedo} disabled={busy || !canRedo}>
|
||||
Redo
|
||||
</button>
|
||||
<button type="button" onClick={onSavePoint} disabled={busy}>
|
||||
Save Point
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`input-bar ${direction ? 'directing' : ''}`}>
|
||||
<div className="input-row">
|
||||
<label className="direction-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={direction}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDirection(e.target.checked)}
|
||||
/>
|
||||
<span>Story direction</span>
|
||||
</label>
|
||||
<span className="direction-hint">
|
||||
{direction
|
||||
? 'Speaking to the narrator, not in the story.'
|
||||
: 'Write what you do or say.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="input-main">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
disabled={busy}
|
||||
aria-label={direction ? 'Story direction for the narrator' : 'What you do next'}
|
||||
placeholder={
|
||||
direction
|
||||
? 'Keep this scene tense, but do not start a fight yet.'
|
||||
: 'I walk into the tavern and look for Mara.'
|
||||
}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends, Shift+Enter is a newline. Ctrl/Cmd+Enter also
|
||||
// sends, which §28 asks for and which is what a reader who has
|
||||
// been typing a long paragraph reaches for.
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reserved for local speech-to-text. Not implemented; see the file
|
||||
comment. No handler, no permission, nothing to click. */}
|
||||
<button
|
||||
type="button"
|
||||
className="dictate-reserved"
|
||||
disabled
|
||||
data-testid="dictate-reserved"
|
||||
aria-label="Dictate — not yet available"
|
||||
title="Dictation will be added in a later version. It is not available yet."
|
||||
>
|
||||
<span aria-hidden="true">🎙</span>
|
||||
</button>
|
||||
|
||||
{busy ? (
|
||||
<button type="button" className="danger" onClick={onStop}>Stop</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={submit}
|
||||
disabled={blocked}
|
||||
title={blocked ? 'Choose a working local model first' : undefined}
|
||||
>
|
||||
{direction ? 'Direct' : 'Send'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/* The composer: history controls, the one input, direction, and the reserved
|
||||
* dictation button.
|
||||
*
|
||||
* §30 of the M8 brief names Undo/Redo enabled states and the disabled STT
|
||||
* affordance specifically. Undo and Redo are the ones worth being strict about:
|
||||
* their availability is the server's answer, and a React component that decided
|
||||
* it for itself would be wrong exactly when it mattered — Undo can reach past
|
||||
* the loaded window, and Redo depends on a retained future the transcript is
|
||||
* never sent.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Composer } from './Composer'
|
||||
import { api } from '../../api'
|
||||
import { mockModelStatus, renderWith } from '../../test/helpers'
|
||||
|
||||
async function setup(props = {}) {
|
||||
const handlers = {
|
||||
setInput: vi.fn(),
|
||||
setDirection: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onContinue: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onUndo: vi.fn(),
|
||||
onRedo: vi.fn(),
|
||||
onSavePoint: vi.fn(),
|
||||
onStop: vi.fn(),
|
||||
}
|
||||
const view = await renderWith(
|
||||
<Composer
|
||||
input=""
|
||||
direction={false}
|
||||
busy={false}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
canRetry={false}
|
||||
{...handlers}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { ...view, handlers }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockModelStatus(api)
|
||||
})
|
||||
|
||||
describe('history controls', () => {
|
||||
it('disables Undo and Redo when the server says there is nowhere to go', async () => {
|
||||
await setup({ canUndo: false, canRedo: false })
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables each one independently, as the server reports it', async () => {
|
||||
await setup({ canUndo: true, canRedo: false })
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables Redo when a retained continuation exists', async () => {
|
||||
await setup({ canUndo: true, canRedo: true })
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('calls Undo and Redo without deciding anything itself', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handlers } = await setup({ canUndo: true, canRedo: true })
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Redo' }))
|
||||
expect(handlers.onUndo).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.onRedo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables Retry when the newest turn is not the narrator’s', async () => {
|
||||
await setup({ canRetry: false })
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables every story control while a turn is generating', async () => {
|
||||
await setup({ busy: true, canUndo: true, canRedo: true, canRetry: true })
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled()
|
||||
// …and offers Stop in place of Send, so a second turn cannot be submitted
|
||||
// on top of the one in flight (§16).
|
||||
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('one input, not three modes', () => {
|
||||
it('offers no Do / Say / Story mode selector', async () => {
|
||||
await setup()
|
||||
for (const label of ['Do', 'Say', 'Story']) {
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends ordinary input as a protagonist action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handlers } = await setup({ input: 'I walk in.' })
|
||||
await user.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(handlers.onSend).toHaveBeenCalledWith('do')
|
||||
})
|
||||
|
||||
it('sends story direction as direction, and says so on the button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handlers } = await setup({ input: 'Keep it tense.', direction: true })
|
||||
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
|
||||
await user.click(screen.getByRole('button', { name: 'Direct' }))
|
||||
expect(handlers.onSend).toHaveBeenCalledWith('story')
|
||||
})
|
||||
|
||||
it('marks direction as out-of-character, not as dialogue', async () => {
|
||||
await setup({ direction: true })
|
||||
expect(screen.getByText(/Speaking to the narrator, not in the story/))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends on Enter and on Ctrl+Enter, but not on Shift+Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handlers } = await setup({ input: 'text' })
|
||||
const box = screen.getByRole('textbox')
|
||||
await user.click(box)
|
||||
await user.keyboard('{Enter}')
|
||||
expect(handlers.onSend).toHaveBeenCalledTimes(1)
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(handlers.onSend).toHaveBeenCalledTimes(2)
|
||||
await user.keyboard('{Shift>}{Enter}{/Shift}')
|
||||
expect(handlers.onSend).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserved dictation affordance (§11, §91A)', () => {
|
||||
it('is present, disabled, and named as not yet available', async () => {
|
||||
await setup()
|
||||
const button = screen.getByTestId('dictate-reserved')
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveAccessibleName(/not yet available/i)
|
||||
})
|
||||
|
||||
it('never asks for the microphone', async () => {
|
||||
// The whole risk of a reserved control is that it looks operational. There
|
||||
// is no getUserMedia in jsdom, so if the component called it the property
|
||||
// access alone would show up here.
|
||||
const getUserMedia = vi.fn()
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia },
|
||||
configurable: true,
|
||||
})
|
||||
await setup()
|
||||
const button = screen.getByTestId('dictate-reserved')
|
||||
button.click() // a disabled button, clicked directly
|
||||
expect(getUserMedia).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no branch vocabulary (§27)', () => {
|
||||
it('uses none of branch, fork, node, head or merge', async () => {
|
||||
const { container } = await setup({ canUndo: true, canRedo: true, canRetry: true })
|
||||
const text = container.textContent.toLowerCase()
|
||||
for (const word of ['branch', 'fork', 'node', 'merge', 'head', 'depth']) {
|
||||
expect(text).not.toContain(word)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/* What the reader sees when a turn does not happen.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §17, §71, §72 and §12 of the M8 brief. Three things this
|
||||
* has to get right, all of which the inherited toast got wrong:
|
||||
*
|
||||
* 1. **Say which failure it was.** The toast showed one line of the server's
|
||||
* message with a Retry button beside it, so "Ollama isn't running" and
|
||||
* "the model returned nothing" looked identical and read as equally
|
||||
* hopeless. `errors.js` sorts them into §71's five kinds and each carries
|
||||
* the thing to do about it.
|
||||
* 2. **Do not lose what was typed.** A05 requires the player's submitted
|
||||
* input to survive a failed generation. The composer keeps the text and
|
||||
* this says so out loud, because a reader who cannot see their sentence
|
||||
* assumes it is gone and retypes it.
|
||||
* 3. **Do not present partial prose as story.** Whatever streamed before the
|
||||
* failure is dropped from the transcript by the caller; if any arrived, it
|
||||
* is shown here, clearly labelled as not part of the story.
|
||||
*
|
||||
* Technical detail is present but folded away (§72), because the reader who
|
||||
* needs the raw message is not the reader who needs the first sentence.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { KIND } from '../../errors'
|
||||
|
||||
const ICON = {
|
||||
[KIND.MODEL]: '⚠',
|
||||
[KIND.GENERATION]: '↻',
|
||||
[KIND.STATE]: '◆',
|
||||
[KIND.KNOWLEDGE]: '❋',
|
||||
[KIND.SERVER]: '⚠',
|
||||
}
|
||||
|
||||
export function FailureNotice({ failure, partial, onRetry, onDismiss }) {
|
||||
if (!failure) return null
|
||||
return (
|
||||
<div
|
||||
className={`failure failure-${failure.kind}`}
|
||||
role="alert"
|
||||
data-testid="failure"
|
||||
data-kind={failure.kind}
|
||||
>
|
||||
<div className="failure-head">
|
||||
<span className="failure-icon" aria-hidden="true">{ICON[failure.kind] || '⚠'}</span>
|
||||
<strong>{failure.title}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="failure-close"
|
||||
aria-label="Dismiss this message"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{failure.hint && <p className="failure-hint">{failure.hint}</p>}
|
||||
|
||||
<p className="failure-kept">
|
||||
Your story is unchanged, and what you typed is still in the box below.
|
||||
</p>
|
||||
|
||||
{partial ? (
|
||||
<details className="failure-partial">
|
||||
<summary>The narrator had begun writing — this was not kept</summary>
|
||||
<blockquote>{partial}</blockquote>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<details className="failure-detail">
|
||||
<summary>Show technical details</summary>
|
||||
<pre>{failure.detail}</pre>
|
||||
</details>
|
||||
|
||||
<div className="failure-actions">
|
||||
{failure.retryable && (
|
||||
<button type="button" className="primary" onClick={onRetry}>
|
||||
Try that turn again
|
||||
</button>
|
||||
)}
|
||||
{failure.action && (
|
||||
<Link className="button" to={failure.action.to}>{failure.action.label}</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// The confirmation dialog for copying a scenario's current content.
|
||||
//
|
||||
// The dialog shows exactly what the update changes, and collects any
|
||||
// `${Placeholder}` answers the adventure has no stored value for. An adventure
|
||||
// started before those were saved has none, and so does one whose author added
|
||||
// a placeholder since. The update overwrites plot text and scenario-derived
|
||||
// cards, so nothing happens until you press Update.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FIELD_LABELS, clip } from './format'
|
||||
|
||||
function RefreshModal({ plan, onConfirm, onCancel }) {
|
||||
const [values, setValues] = useState(
|
||||
Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])),
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const { added = [], updated = [], removed = [] } = plan.cards || {}
|
||||
const world = plan.world_state || {}
|
||||
const fields = Object.entries(plan.fields || {})
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
onConfirm(values).finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
// Portalled to <body>: this modal is opened from inside .side-panel, whose
|
||||
// panel-in animation (fill mode `both`) makes it the containing block for
|
||||
// position:fixed children — an overlay rendered in place would be trapped in
|
||||
// the 420px panel and clipped by its overflow. Same trap for any future modal
|
||||
// opened from a drawer or panel.
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<form className="modal refresh-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>Update from scenario</h2>
|
||||
{!plan.has_changes ? (
|
||||
<p className="modal-hint">
|
||||
This adventure already matches “{plan.scenario_title}”. Nothing to update.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="modal-hint">
|
||||
Pull the current content of “{plan.scenario_title}” down over this adventure.
|
||||
Your story, its title, summary and your own story cards are untouched — but
|
||||
the plot text below is <b>overwritten</b>, including any edits you made here.
|
||||
</p>
|
||||
<div className="refresh-changes">
|
||||
{fields.map(([field, diff]) => (
|
||||
<div key={field} className="refresh-change">
|
||||
<b>{FIELD_LABELS[field] || field}</b>
|
||||
<div className="dim refresh-old">− {clip(diff.old)}</div>
|
||||
<div className="refresh-new">+ {clip(diff.new)}</div>
|
||||
</div>
|
||||
))}
|
||||
{added.length > 0 && (
|
||||
<div className="refresh-change">Story cards added: <b>{added.join(', ')}</b></div>
|
||||
)}
|
||||
{updated.length > 0 && (
|
||||
<div className="refresh-change">Story cards overwritten: <b>{updated.join(', ')}</b></div>
|
||||
)}
|
||||
{removed.length > 0 && (
|
||||
<div className="refresh-change">Story cards removed: <b>{removed.join(', ')}</b></div>
|
||||
)}
|
||||
{world.added?.length > 0 && (
|
||||
<div className="refresh-change">
|
||||
New stats (at their starting value): <b>{world.added.join(', ')}</b>
|
||||
</div>
|
||||
)}
|
||||
{world.removed?.length > 0 && (
|
||||
<div className="refresh-change">Stats removed: <b>{world.removed.join(', ')}</b></div>
|
||||
)}
|
||||
</div>
|
||||
{(plan.placeholders_needed || []).length > 0 && (
|
||||
<>
|
||||
<p className="modal-hint">
|
||||
This scenario asks a few questions, and this adventure has no saved
|
||||
answers for them. They'll be remembered from now on.
|
||||
</p>
|
||||
{plan.placeholders_needed.map((name) => (
|
||||
<label key={name} className="field">
|
||||
<span className="label">{name}</span>
|
||||
<input type="text" value={values[name]}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={onCancel}>{plan.has_changes ? 'Cancel' : 'Close'}</button>
|
||||
{plan.has_changes && (
|
||||
<button type="submit" className="primary" disabled={busy}>
|
||||
{busy ? 'Updating…' : 'Update'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export { RefreshModal }
|
||||
@@ -0,0 +1,125 @@
|
||||
/* The secondary panel: everything that is not the story.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §97 lists what a campaign contains, and this is that
|
||||
* list minus the two entries that are not panels — Story is the page itself,
|
||||
* and Export lives in campaign settings beside Delete.
|
||||
*
|
||||
* The panel starts closed and closes again with one button, which is what §4
|
||||
* means by collapsible: the story is never permanently squeezed into a column
|
||||
* beside a developer console.
|
||||
*
|
||||
* ## What is not here
|
||||
*
|
||||
* **Branches.** The inherited screen had a Branches tab drawing the story tree,
|
||||
* with switch, rename and delete. §27 removes it from normal use: the reader's
|
||||
* vocabulary is Undo, Redo, Retry, Take, Save Point and Restore, and every one
|
||||
* of those is reachable without it. The tree, the endpoints and their tests are
|
||||
* untouched — this is a decision about what a reader is asked to understand,
|
||||
* not a reduction of what the product can do.
|
||||
*
|
||||
* **Story cards.** AI Dungeon's world-info table. M7 built the knowledge
|
||||
* library that supersedes it for v1 (`IMPORTED-KNOWLEDGE-DESIGN.md` §73), and
|
||||
* showing both would offer a reader two unrelated systems for "things the
|
||||
* narrator should know about". The backend keeps them and the bundle still
|
||||
* carries them.
|
||||
*
|
||||
* **The RPG stat drawer** is conditional rather than removed — see below.
|
||||
*/
|
||||
|
||||
import { CampaignSettingsPanel } from './panels/CampaignSettingsPanel'
|
||||
import { ContextPanel } from './panels/ContextPanel'
|
||||
import { KnowledgePanel } from './panels/KnowledgePanel'
|
||||
import { SavePointPanel } from './panels/SavePointPanel'
|
||||
import { StatePanel } from './panels/StatePanel'
|
||||
|
||||
export const PANELS = [
|
||||
{ id: 'state', label: 'State' },
|
||||
{ id: 'knowledge', label: 'Knowledge' },
|
||||
{ id: 'context', label: 'Context' },
|
||||
{ id: 'savepoints', label: 'Save Points' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
const TITLES = {
|
||||
state: 'What the story believes',
|
||||
knowledge: 'Imported knowledge',
|
||||
context: 'What the narrator was given',
|
||||
savepoints: 'Save Points',
|
||||
settings: 'Campaign settings',
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
panel,
|
||||
advId,
|
||||
adventure,
|
||||
setAdventure,
|
||||
refreshKey,
|
||||
inspectActionId,
|
||||
onClearInspect,
|
||||
onClose,
|
||||
onRestored,
|
||||
onCorrected,
|
||||
onError,
|
||||
focusSourceId,
|
||||
onOpenSource,
|
||||
moments,
|
||||
}) {
|
||||
return (
|
||||
<aside className="side-panel" aria-label={TITLES[panel]}>
|
||||
<div className="side-panel-header">
|
||||
<h2>{TITLES[panel]}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Close this panel">✕</button>
|
||||
</div>
|
||||
<div className="side-panel-body">
|
||||
{panel === 'state' && (
|
||||
// Keyed on the same signal the other live panels use, so the state
|
||||
// follows Undo, Redo and a Save Point restore: what it shows is the
|
||||
// state at the position being read, not at the newest turn.
|
||||
<StatePanel
|
||||
advId={advId}
|
||||
refreshKey={refreshKey}
|
||||
onCorrected={onCorrected}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
{panel === 'knowledge' && (
|
||||
// Campaign-scoped rather than lineage-scoped — an imported file does
|
||||
// not become a different file because the story diverged — but it
|
||||
// still keys on the same signal, because restoring a Save Point is
|
||||
// exactly when a reader looks at what the narrator is being given.
|
||||
<KnowledgePanel
|
||||
advId={advId}
|
||||
refreshKey={refreshKey}
|
||||
onError={onError}
|
||||
focusSourceId={focusSourceId}
|
||||
/>
|
||||
)}
|
||||
{panel === 'context' && (
|
||||
<ContextPanel
|
||||
advId={advId}
|
||||
inspectActionId={inspectActionId}
|
||||
onClearInspect={onClearInspect}
|
||||
refreshKey={refreshKey}
|
||||
onOpenSource={onOpenSource}
|
||||
/>
|
||||
)}
|
||||
{panel === 'savepoints' && (
|
||||
<SavePointPanel
|
||||
advId={advId}
|
||||
refreshKey={refreshKey}
|
||||
onRestored={onRestored}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
{panel === 'settings' && (
|
||||
<CampaignSettingsPanel
|
||||
adventure={adventure}
|
||||
setAdventure={setAdventure}
|
||||
onError={onError}
|
||||
moments={moments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,25 @@ function TakePager({
|
||||
const list = takes || await api.listTakes(advId, action.id)
|
||||
if (!takes) setTakes(list)
|
||||
const target = list[next]
|
||||
if (target.branch_id !== action.branch_id) {
|
||||
// Is the take being stepped to on the line the story is currently told
|
||||
// on? Compared against the **live take in this same list**, not against
|
||||
// the action row on screen.
|
||||
//
|
||||
// M8 found this by driving it. `ActionOut` carries no `branch_id` — it
|
||||
// never has — so `target.branch_id !== action.branch_id` was always
|
||||
// `number !== undefined`, which is always true. Every step took the
|
||||
// branch-switch path, and for two takes of an ordinary retry (which share
|
||||
// a branch until one of them is written below) that meant switching to
|
||||
// the branch already being read: the server returned the same window and
|
||||
// the step did nothing at all.
|
||||
//
|
||||
// The variants list already answers this properly. It carries every
|
||||
// attempt with its branch, and marks the live one, so the comparison can
|
||||
// be made entirely within data the endpoint returns — no new field, and
|
||||
// nothing that can be undefined.
|
||||
const liveRow = list.find((row) => row.active)
|
||||
const elsewhere = liveRow ? target.branch_id !== liveRow.branch_id : false
|
||||
if (elsewhere) {
|
||||
// It has a story of its own. Only the server knows what is under it.
|
||||
onPreview(null)
|
||||
onSwitchedBranch(await api.switchBranch(advId, target.branch_id))
|
||||
@@ -75,7 +93,16 @@ function TakePager({
|
||||
<div className="take-pager">
|
||||
<button type="button" disabled={busy || loading || current === 0}
|
||||
onClick={() => step(-1)} title="The take before this one" aria-label="Previous take">‹</button>
|
||||
<span className="take-count" aria-live="polite">{current + 1}/{count}</span>
|
||||
{/* "2/4" is enough to read at a glance and too terse to hear, so the
|
||||
accessible name says it in words (§14, §80). */}
|
||||
<span
|
||||
className="take-count"
|
||||
aria-live="polite"
|
||||
aria-label={`Take ${current + 1} of ${count}`}
|
||||
data-testid="take-count"
|
||||
>
|
||||
{current + 1}/{count}
|
||||
</span>
|
||||
<button type="button" disabled={busy || loading || current === count - 1}
|
||||
onClick={() => step(1)} title="The take after this one" aria-label="Next take">›</button>
|
||||
{preview && !preview.written && (
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/* The alternate-take selector.
|
||||
*
|
||||
* §14 and §30. Two properties matter and both are easy to lose: the pager says
|
||||
* which take of how many in words as well as in digits, and stepping between
|
||||
* takes tells the server nothing — reading an alternative is not a decision,
|
||||
* and the decision is made by writing below one.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../../api'
|
||||
import { TakePager } from './TakePager'
|
||||
import { narratorAction, renderWith } from '../../test/helpers'
|
||||
|
||||
// Three attempts at one turn, all on the line the story is told on, with the
|
||||
// third live — which is what an ordinary retry produces. `active` is what the
|
||||
// variants endpoint returns for the live row.
|
||||
const TAKES = [
|
||||
{ id: 20, branch_id: 1, text: 'Take one.', reasoning: '', active: false },
|
||||
{ id: 21, branch_id: 1, text: 'Take two.', reasoning: '', active: false },
|
||||
{ id: 22, branch_id: 1, text: 'Take three.', reasoning: '', active: true },
|
||||
]
|
||||
|
||||
// The same turn, but the first attempt was written below and moved onto a line
|
||||
// of its own. Stepping to it is a real switch, because a story hangs off it.
|
||||
const TAKES_FORKED = [
|
||||
{ id: 20, branch_id: 9, text: 'Take one.', reasoning: '', active: false },
|
||||
{ id: 21, branch_id: 1, text: 'Take two.', reasoning: '', active: false },
|
||||
{ id: 22, branch_id: 1, text: 'Take three.', reasoning: '', active: true },
|
||||
]
|
||||
|
||||
function pager(props = {}) {
|
||||
const handlers = {
|
||||
onPreview: vi.fn(), onSwitchedBranch: vi.fn(), onError: vi.fn(),
|
||||
}
|
||||
return {
|
||||
handlers,
|
||||
render: () => renderWith(
|
||||
<TakePager
|
||||
advId="1"
|
||||
action={narratorAction({ take_count: 3, take_index: 0 })}
|
||||
busy={false}
|
||||
preview={null}
|
||||
takesKey={0}
|
||||
{...handlers}
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('draws nothing when a turn has only one take', async () => {
|
||||
const { render } = pager()
|
||||
const { container } = await renderWith(
|
||||
<TakePager
|
||||
advId="1"
|
||||
action={narratorAction({ take_count: 1, take_index: 0 })}
|
||||
busy={false} preview={null} takesKey={0}
|
||||
onPreview={vi.fn()} onSwitchedBranch={vi.fn()} onError={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('.take-pager')).toBeNull()
|
||||
void render
|
||||
})
|
||||
|
||||
it('shows which take of how many, in digits and in words', async () => {
|
||||
const { render } = pager()
|
||||
await render()
|
||||
const count = screen.getByTestId('take-count')
|
||||
expect(count).toHaveTextContent('1/3')
|
||||
expect(count).toHaveAccessibleName('Take 1 of 3')
|
||||
})
|
||||
|
||||
it('disables Previous on the first take and Next on the last', async () => {
|
||||
const { render } = pager()
|
||||
await render()
|
||||
expect(screen.getByRole('button', { name: 'Previous take' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Next take' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('previews a take on the current line instead of switching (D07)', async () => {
|
||||
// The defect this replaces: `ActionOut` carries no `branch_id`, so comparing
|
||||
// the target against the action on screen was always `number !== undefined`
|
||||
// — always true. Every step took the branch-switch path, and for two takes
|
||||
// of an ordinary retry that meant switching to the line already being read.
|
||||
// The server returned the same window and the step did nothing.
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
|
||||
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({})
|
||||
const { render, handlers } = pager({
|
||||
action: narratorAction({ take_count: 3, take_index: 2 }),
|
||||
})
|
||||
await render()
|
||||
await user.click(screen.getByRole('button', { name: 'Previous take' }))
|
||||
expect(switchBranch).not.toHaveBeenCalled()
|
||||
expect(handlers.onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 1, takeId: 21, text: 'Take two.' }),
|
||||
)
|
||||
expect(handlers.onSwitchedBranch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches when the take has a story of its own', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES_FORKED)
|
||||
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({ actions: [] })
|
||||
const { render, handlers } = pager({
|
||||
action: narratorAction({ take_count: 3, take_index: 1 }),
|
||||
})
|
||||
await render()
|
||||
await user.click(screen.getByRole('button', { name: 'Previous take' }))
|
||||
expect(switchBranch).toHaveBeenCalledWith('1', 9)
|
||||
expect(handlers.onSwitchedBranch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('steps forward without telling the server anything', async () => {
|
||||
const user = userEvent.setup()
|
||||
const list = vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
|
||||
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({})
|
||||
const { render, handlers } = pager()
|
||||
await render()
|
||||
await user.click(screen.getByRole('button', { name: 'Next take' }))
|
||||
// It reads the list, and makes no decision on the server.
|
||||
expect(list).toHaveBeenCalledWith('1', 2)
|
||||
expect(switchBranch).not.toHaveBeenCalled()
|
||||
expect(handlers.onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 1, takeId: 21, text: 'Take two.' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('caches the list rather than refetching on every step', async () => {
|
||||
const user = userEvent.setup()
|
||||
const list = vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
|
||||
const { render } = pager({ preview: null })
|
||||
await render()
|
||||
await user.click(screen.getByRole('button', { name: 'Next take' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Next take' }))
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is disabled while a turn is generating', async () => {
|
||||
const { render } = pager({ busy: true })
|
||||
await render()
|
||||
expect(screen.getByRole('button', { name: 'Next take' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Previous take' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says how to keep the take being read, without saying "branch"', async () => {
|
||||
const { render } = pager({
|
||||
preview: { actionId: 2, index: 1, takeId: 21, text: 'Take two.', written: false },
|
||||
})
|
||||
const { container } = await render()
|
||||
expect(screen.getByText(/write below to keep this one/)).toBeInTheDocument()
|
||||
expect(container.textContent.toLowerCase()).not.toContain('branch')
|
||||
expect(container.textContent.toLowerCase()).not.toContain('fork')
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
/* The story itself.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §6, §83 and §84, and §9 of the M8 brief. The transcript
|
||||
* is the product, so the design rule is subtraction: a player turn and a
|
||||
* narrator turn are told apart by indentation, weight and a rule above the
|
||||
* turn, not by a card with a border, an avatar and a metadata strip. Nothing
|
||||
* shows a turn id, a depth, a branch or a timestamp — §84 puts all of that in
|
||||
* the inspector, and the inspector is one click away on every narrator turn.
|
||||
*
|
||||
* What changed from the inherited version, and why:
|
||||
*
|
||||
* - Prose is rendered through the safe Markdown renderer rather than a
|
||||
* bold/italic regex, so headings, lists and blockquotes a narrator writes
|
||||
* come out as headings, lists and blockquotes (§76). The renderer is also
|
||||
* where H06/H07 are decided; see `markdown.jsx`.
|
||||
* - Every control has a real accessible name. The inherited row was six
|
||||
* single-glyph buttons (✎ ⑂ ✕ 🔍) carrying `title` and nothing else, so a
|
||||
* screen reader read the character. All 16 on a two-turn story were
|
||||
* unnamed; that was measured before this was written.
|
||||
* - The per-message **Delete** is gone. Deleting one turn out of the middle
|
||||
* of a story is a developer's tool, not a reader's: Undo, Retry and editing
|
||||
* cover every reason a reader has to change what is there, and each of them
|
||||
* keeps what it replaced. The endpoint still exists and is still tested.
|
||||
* - The fork glyph ⑂ became "Try again differently", which is what it does.
|
||||
* §64 keeps fork, branch and node out of the reader's vocabulary.
|
||||
*/
|
||||
|
||||
import { Fragment, memo } from 'react'
|
||||
import { Markdown } from '../../markdown'
|
||||
import { StateChangeChips } from './reports'
|
||||
import { TakePager } from './TakePager'
|
||||
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
|
||||
/**
|
||||
* Strips the storage marker from a player turn before it is displayed.
|
||||
*
|
||||
* A player action is stored with AI Dungeon's `> ` prefix, which is what marks
|
||||
* whose turn it is in the replayed prompt. It is a storage convention, not
|
||||
* something the reader wrote — and `>` is also Markdown for a blockquote, so
|
||||
* leaving it in made every player turn render as a quote *by accident*, with
|
||||
* the renderer's left rule stacked on the one `.turn-player` already draws.
|
||||
*
|
||||
* Display-only. Nothing is rewritten: the stored text keeps its marker, which
|
||||
* is what the prompt, the export and every backend test still see.
|
||||
*/
|
||||
function displayText(action, text) {
|
||||
if (!PLAYER_TYPES.includes(action.type)) return text
|
||||
return typeof text === 'string' && text.startsWith('> ') ? text.slice(2) : text
|
||||
}
|
||||
|
||||
/** Reasoning tokens, when a model emits them. Collapsed unless it is streaming. */
|
||||
function ReasoningBlock({ text, streaming }) {
|
||||
if (!text) return null
|
||||
return (
|
||||
<details className="reasoning" open={streaming || undefined}>
|
||||
<summary>The narrator’s thinking{streaming ? '…' : ''}</summary>
|
||||
<div className="reasoning-text">{text}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One accepted turn.
|
||||
*
|
||||
* Memoized on purpose. The page re-renders on every keystroke in the composer,
|
||||
* and without this every turn in the window re-renders its Markdown with it —
|
||||
* the same class of defect M7 found in the source inspector, arrived at from a
|
||||
* different direction.
|
||||
*/
|
||||
const Turn = memo(function Turn({
|
||||
action,
|
||||
advId,
|
||||
isPlayer,
|
||||
opening,
|
||||
sceneBreak,
|
||||
shown,
|
||||
previewing,
|
||||
busy,
|
||||
takesKey,
|
||||
onPreview,
|
||||
onSwitchedBranch,
|
||||
onTakeError,
|
||||
onEdit,
|
||||
onRetake,
|
||||
onInspect,
|
||||
onCopy,
|
||||
onLink,
|
||||
}) {
|
||||
const text = displayText(action, shown ? shown.text : action.text)
|
||||
return (
|
||||
<>
|
||||
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
|
||||
<article
|
||||
className={`turn ${isPlayer ? 'turn-player' : 'turn-narrator'}${opening ? ' turn-opening' : ''}`}
|
||||
aria-label={isPlayer ? 'What you did' : 'The story'}
|
||||
>
|
||||
<ReasoningBlock text={shown ? shown.reasoning : action.reasoning} streaming={false} />
|
||||
<div className="turn-text">
|
||||
<Markdown text={text} onLink={onLink} />
|
||||
</div>
|
||||
|
||||
{/* The chips describe what the *active* attempt changed, which the take
|
||||
on screen did not do — so they are hidden rather than shown against
|
||||
the wrong words. */}
|
||||
{action.type === 'ai' && !shown && <StateChangeChips changes={action.world_changes} />}
|
||||
|
||||
<TakePager
|
||||
advId={advId}
|
||||
action={action}
|
||||
busy={busy}
|
||||
preview={shown}
|
||||
takesKey={takesKey}
|
||||
onPreview={onPreview}
|
||||
onSwitchedBranch={onSwitchedBranch}
|
||||
onError={onTakeError}
|
||||
/>
|
||||
|
||||
{!busy && (
|
||||
<div className="turn-tools">
|
||||
{action.type === 'ai' && (
|
||||
<button type="button" onClick={() => onInspect(action.id)}>
|
||||
Inspect context
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(previewing
|
||||
? { id: previewing.takeId, text: previewing.text, take: true, kind: action.type }
|
||||
: { id: action.id, text: action.text, kind: action.type })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{action.type !== 'start' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetake(action, previewing)}
|
||||
>
|
||||
{action.type === 'ai' ? 'Try again' : 'Say this differently'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => onCopy(text)}>Copy</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export { displayText }
|
||||
|
||||
export function Transcript({
|
||||
actions,
|
||||
advId,
|
||||
busy,
|
||||
streaming,
|
||||
reasoningStream,
|
||||
waitingForFirstToken,
|
||||
firstNarrationId,
|
||||
previewCutoff,
|
||||
preview,
|
||||
pinned,
|
||||
editing,
|
||||
takesKey,
|
||||
total,
|
||||
hasMore,
|
||||
loadingOlder,
|
||||
onLoadOlder,
|
||||
onPreview,
|
||||
onSwitchedBranch,
|
||||
onTakeError,
|
||||
onEdit,
|
||||
onRetake,
|
||||
onInspect,
|
||||
onCopy,
|
||||
onLink,
|
||||
editor,
|
||||
endRef,
|
||||
}) {
|
||||
return (
|
||||
<div className="story" role="log" aria-label="Story transcript" aria-live="polite">
|
||||
{actions.length === 0 && streaming === null && (
|
||||
<p className="story-empty">
|
||||
A blank page. Write what you do, and the story begins.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="story-earlier">
|
||||
{loadingOlder ? (
|
||||
<span className="dim">Turning back the pages…</span>
|
||||
) : (
|
||||
<button type="button" onClick={onLoadOlder}>
|
||||
Read the {Math.max(total - actions.length, 0)} earlier
|
||||
{total - actions.length === 1 ? ' moment' : ' moments'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action, i) => {
|
||||
// Below the take being read there is nothing on this line yet.
|
||||
if (previewCutoff !== -1 && i > previewCutoff) return null
|
||||
const isPlayer = PLAYER_TYPES.includes(action.type)
|
||||
const previewing = preview?.actionId === action.id ? preview : null
|
||||
const shown = previewing || (pinned?.actionId === action.id ? pinned : null)
|
||||
const editingHere = editing
|
||||
&& (editing.take ? previewing?.takeId === editing.id : editing.id === action.id)
|
||||
|
||||
if (editingHere) return <Fragment key={action.id}>{editor}</Fragment>
|
||||
|
||||
return (
|
||||
<Turn
|
||||
key={action.id}
|
||||
action={action}
|
||||
advId={advId}
|
||||
isPlayer={isPlayer}
|
||||
opening={action.id === firstNarrationId}
|
||||
sceneBreak={isPlayer && i > 0}
|
||||
shown={shown}
|
||||
previewing={previewing}
|
||||
busy={busy}
|
||||
takesKey={takesKey}
|
||||
onPreview={onPreview}
|
||||
onSwitchedBranch={onSwitchedBranch}
|
||||
onTakeError={onTakeError}
|
||||
onEdit={onEdit}
|
||||
onRetake={onRetake}
|
||||
onInspect={onInspect}
|
||||
onCopy={onCopy}
|
||||
onLink={onLink}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{waitingForFirstToken && (
|
||||
<div className="thinking" role="status">
|
||||
<i /><i /><i />
|
||||
<span>Narrating…</span>
|
||||
</div>
|
||||
)}
|
||||
{streaming !== null && !waitingForFirstToken && (
|
||||
<article className="turn turn-narrator streaming">
|
||||
<ReasoningBlock text={reasoningStream} streaming />
|
||||
<div className="turn-text">
|
||||
<Markdown text={streaming} onLink={onLink} />
|
||||
</div>
|
||||
<span className="cursor" aria-hidden="true">▋</span>
|
||||
</article>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* The transcript, and the state panel it sits beside.
|
||||
*
|
||||
* The transcript assertions are mostly about what is *not* there: no turn ids,
|
||||
* no branch words, no per-message delete, and no unnamed glyph buttons. The
|
||||
* baseline measured sixteen of the last on a two-turn story before M8.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../../api'
|
||||
import { StatePanel } from './panels/StatePanel'
|
||||
import { Transcript } from './Transcript'
|
||||
import { narratorAction, playerAction, renderWith } from '../../test/helpers'
|
||||
|
||||
function transcript(props = {}) {
|
||||
return renderWith(
|
||||
<Transcript
|
||||
actions={[playerAction(), narratorAction()]}
|
||||
advId="1"
|
||||
busy={false}
|
||||
streaming={null}
|
||||
reasoningStream={null}
|
||||
waitingForFirstToken={false}
|
||||
firstNarrationId={2}
|
||||
previewCutoff={-1}
|
||||
preview={null}
|
||||
pinned={null}
|
||||
editing={null}
|
||||
takesKey={0}
|
||||
total={2}
|
||||
hasMore={false}
|
||||
loadingOlder={false}
|
||||
onLoadOlder={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onSwitchedBranch={vi.fn()}
|
||||
onTakeError={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRetake={vi.fn()}
|
||||
onInspect={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onLink={vi.fn()}
|
||||
editor={null}
|
||||
endRef={{ current: null }}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('transcript presentation (§6, §83, §84)', () => {
|
||||
it('shows the story, and distinguishes the player from the narrator', async () => {
|
||||
const { container } = await transcript()
|
||||
expect(screen.getByText('I walk into the tavern.')).toBeInTheDocument()
|
||||
expect(screen.getByText('The tavern is warm and loud.')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.turn-player')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('.turn-narrator')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows no turn ids or implementation metadata (§84)', async () => {
|
||||
const { container } = await transcript()
|
||||
const text = container.textContent
|
||||
expect(text).not.toMatch(/\bid\b/i)
|
||||
expect(text).not.toMatch(/depth/i)
|
||||
expect(text).not.toMatch(/branch_id/)
|
||||
})
|
||||
|
||||
it('uses no branch vocabulary (§27, §64)', async () => {
|
||||
const { container } = await transcript()
|
||||
const text = container.textContent.toLowerCase()
|
||||
for (const word of ['branch', 'fork', 'node', 'merge', 'head']) {
|
||||
expect(text).not.toContain(word)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers no per-message delete', async () => {
|
||||
await transcript()
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('gives every control a real accessible name, not a glyph', async () => {
|
||||
const { container } = await transcript()
|
||||
const buttons = Array.from(container.querySelectorAll('.turn-tools button'))
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
for (const button of buttons) {
|
||||
const name = (button.getAttribute('aria-label') || button.textContent).trim()
|
||||
// A one- or two-character name is a glyph, which is what the baseline had.
|
||||
expect(name.length).toBeGreaterThan(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers Inspect context on a narrator turn only', async () => {
|
||||
const { container } = await transcript()
|
||||
const narrator = container.querySelector('.turn-narrator')
|
||||
const player = container.querySelector('.turn-player')
|
||||
expect(within(narrator).getByRole('button', { name: 'Inspect context' })).toBeInTheDocument()
|
||||
expect(within(player).queryByRole('button', { name: 'Inspect context' })).toBeNull()
|
||||
})
|
||||
|
||||
it('names the retry control for what it does, on each kind of turn', async () => {
|
||||
const { container } = await transcript()
|
||||
expect(within(container.querySelector('.turn-narrator'))
|
||||
.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
|
||||
expect(within(container.querySelector('.turn-player'))
|
||||
.getByRole('button', { name: 'Say this differently' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders narrator Markdown safely (§76, H06)', async () => {
|
||||
const { container } = await transcript({
|
||||
actions: [narratorAction({ text: '## A heading\n\n- one\n\n<script>alert(1)</script>' })],
|
||||
})
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('.md-heading')).toHaveTextContent('A heading')
|
||||
expect(container.querySelector('.md-list li')).toHaveTextContent('one')
|
||||
expect(container.textContent).toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('announces itself as a log for a screen reader (§80)', async () => {
|
||||
await transcript()
|
||||
expect(screen.getByRole('log', { name: 'Story transcript' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says Narrating while waiting for the first token (§16)', async () => {
|
||||
// Scoped to the transcript's own indicator: the toast host also publishes a
|
||||
// status region, and matching on the role alone would find both.
|
||||
const { container } = await transcript({ waitingForFirstToken: true, streaming: '' })
|
||||
const indicator = container.querySelector('.thinking')
|
||||
expect(indicator).toHaveAttribute('role', 'status')
|
||||
expect(indicator).toHaveTextContent('Narrating…')
|
||||
})
|
||||
|
||||
it('does not show the storage marker, or turn it into a blockquote', async () => {
|
||||
// A player action is stored as "> I walk into the tavern." — the marker is
|
||||
// what tells the prompt whose turn it is. `>` is also Markdown for a
|
||||
// blockquote, so leaving it in rendered every player turn as a quote by
|
||||
// accident, stacking the renderer's rule on the one .turn-player draws.
|
||||
const { container } = await transcript({
|
||||
actions: [playerAction({ text: '> I walk into the tavern.' })],
|
||||
firstNarrationId: null,
|
||||
})
|
||||
const player = container.querySelector('.turn-player')
|
||||
expect(player).toHaveTextContent('I walk into the tavern.')
|
||||
expect(player.textContent).not.toContain('>')
|
||||
expect(player.querySelector('blockquote')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a narrator blockquote alone', async () => {
|
||||
const { container } = await transcript({
|
||||
actions: [narratorAction({ text: '> She said nothing.' })],
|
||||
})
|
||||
expect(container.querySelector('.turn-narrator blockquote')).toHaveTextContent(
|
||||
'She said nothing.')
|
||||
})
|
||||
|
||||
it('invites a first line when the story is empty', async () => {
|
||||
await transcript({ actions: [], firstNarrationId: null, total: 0 })
|
||||
expect(screen.getByText(/A blank page/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state panel (§18, §31)', () => {
|
||||
const STATE = {
|
||||
empty: false,
|
||||
groups: [
|
||||
{
|
||||
title: 'Characters',
|
||||
rows: [{ key: 'mara', label: 'Mara', detail: 'at the tavern' }],
|
||||
},
|
||||
{
|
||||
title: 'Important Facts',
|
||||
rows: [{ key: 'f1', label: 'The key bears a broken circle', detail: '' }],
|
||||
},
|
||||
],
|
||||
document: {},
|
||||
}
|
||||
|
||||
it('renders the story concepts the server grouped, not raw JSON', async () => {
|
||||
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
|
||||
const { container } = await renderWith(
|
||||
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText('Characters')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mara')).toBeInTheDocument()
|
||||
expect(screen.getByText('Important Facts')).toBeInTheDocument()
|
||||
// No JSON, and nothing a reader would have to parse.
|
||||
expect(container.querySelector('pre')).toBeNull()
|
||||
expect(container.textContent).not.toContain('{')
|
||||
})
|
||||
|
||||
it('uses no RPG vocabulary of its own (§18)', async () => {
|
||||
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
|
||||
const { container } = await renderWith(
|
||||
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
|
||||
)
|
||||
const text = container.textContent.toLowerCase()
|
||||
for (const word of ['hp', 'mana', 'quest', 'stat', 'cooldown', 'xp']) {
|
||||
expect(text).not.toContain(word)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers a correction route that is not raw JSON (§20, §30)', async () => {
|
||||
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
|
||||
await renderWith(
|
||||
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Correct something' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says plainly when the story has established nothing yet', async () => {
|
||||
vi.spyOn(api, 'getNarrativeState').mockResolvedValue({
|
||||
empty: true, groups: [], document: {},
|
||||
})
|
||||
await renderWith(
|
||||
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/Nothing established yet/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,298 +0,0 @@
|
||||
// The right drawer: the RPG world state, and the form that edits it.
|
||||
//
|
||||
// Editing builds a flat draft keyed by path, so a nested value can be edited
|
||||
// without rebuilding the tree on every keystroke. `buildWorldStateDraft` makes
|
||||
// it and `sliceDraft` reads one section back out.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { npcInitials } from '../../../components'
|
||||
|
||||
// Returns the word label for a value, read from the stat def's bands. This
|
||||
// mirrors `worldstate.band_label` on the server.
|
||||
function bandLabel(def, value) {
|
||||
const bands = def?.bands
|
||||
if (!Array.isArray(bands) || typeof value !== 'number') return null
|
||||
for (const b of bands) {
|
||||
if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2]
|
||||
}
|
||||
const last = bands[bands.length - 1]
|
||||
if (last && value === last[1]) return last[2]
|
||||
return null
|
||||
}
|
||||
|
||||
function StatRow({ name, def, value, editing, onChange }) {
|
||||
const isText = def?.type === 'text'
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="ws-stat">
|
||||
<div className="ws-stat-head">
|
||||
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
|
||||
</div>
|
||||
{isText ? (
|
||||
<input className="ws-edit-input ws-edit-text" type="text" value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
) : (
|
||||
<input className="ws-edit-input ws-edit-num" type="number" value={value ?? 0}
|
||||
min={typeof def?.min === 'number' ? def.min : undefined}
|
||||
max={typeof def?.max === 'number' ? def.max : undefined}
|
||||
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isText) {
|
||||
const text = typeof value === 'string' ? value : (def?.initial ?? '')
|
||||
return (
|
||||
<div className="ws-stat">
|
||||
<div className="ws-stat-head">
|
||||
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
|
||||
<span className="ws-stat-val">{text || '(unset)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
|
||||
const { min, max } = def || {}
|
||||
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
|
||||
const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null
|
||||
const label = bandLabel(def, val)
|
||||
return (
|
||||
<div className="ws-stat">
|
||||
<div className="ws-stat-head">
|
||||
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
|
||||
<span className="ws-stat-val">
|
||||
{val}{typeof max === 'number' ? `/${max}` : ''}
|
||||
{label ? <span className="ws-band"> · {label}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{pct != null && (
|
||||
<div className="ws-bar"><div className="ws-bar-fill" style={{ width: `${pct}%` }} /></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
|
||||
// is a slice of the drawer's flat path->value map for this group's prefix.
|
||||
// `nested` = the caller already drew a heading (an NPC card), so this drops the
|
||||
// group's own spacing and says "nothing here" rather than vanishing and leaving
|
||||
// that heading dangling over empty space.
|
||||
function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) {
|
||||
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||
if (entries.length === 0) {
|
||||
return nested ? <div className="ws-none">No tracked stats.</div> : null
|
||||
}
|
||||
return (
|
||||
<div className={nested ? 'ws-stats' : 'ws-group'}>
|
||||
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
|
||||
{entries.map(([name, def]) => (
|
||||
<StatRow key={name} name={name} def={def}
|
||||
value={editing ? draft?.[name] : values?.[name]}
|
||||
editing={editing}
|
||||
onChange={editing ? (v) => onEdit(name, v) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust",
|
||||
// "flags.has_key", "milestones.escaped") to seed an edit-mode draft.
|
||||
function buildWorldStateDraft(schema, state) {
|
||||
const draft = {}
|
||||
const addStats = (prefix, defs, values) => {
|
||||
for (const [name, def] of Object.entries(defs || {})) {
|
||||
if (!def || typeof def !== 'object') continue
|
||||
const fallback = def.type === 'text' ? '' : 0
|
||||
draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback
|
||||
}
|
||||
}
|
||||
addStats('world', schema.world, state.world)
|
||||
addStats('player', schema.player, state.player)
|
||||
const npcState = state.npc || {}
|
||||
for (const [id, ndef] of Object.entries(schema.npcs || {})) {
|
||||
addStats(`npc.${id}`, ndef?.stats, npcState[id])
|
||||
}
|
||||
const flagState = state.flags || {}
|
||||
for (const [name, def] of Object.entries(schema.flags || {})) {
|
||||
draft[`flags.${name}`] = flagState[name] ?? !!def?.initial
|
||||
}
|
||||
const reached = state.milestones || {}
|
||||
for (const mid of Object.keys(schema.milestones || {})) {
|
||||
draft[`milestones.${mid}`] = !!reached[mid]?.reached
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
// Slice a flat path->value draft down to one group's {statName: value} map.
|
||||
function sliceDraft(draft, prefix) {
|
||||
const out = {}
|
||||
const p = `${prefix}.`
|
||||
for (const [k, v] of Object.entries(draft || {})) {
|
||||
if (k.startsWith(p)) out[k.slice(p.length)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
|
||||
// stats with bands + bars, and a milestones checklist. Renders nothing unless
|
||||
// the adventure's scenario defines a stat_schema. An edit mode lets the
|
||||
// player/author directly override the live values (a manual correction, not
|
||||
// a turn) — it never adds new stats, only edits ones the schema already defines.
|
||||
function WorldStateDrawer({ advId, refreshKey, personaName }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [data, setData] = useState(null) // { state, schema }
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(null) // flat path->value, only while editing
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getWorldState(advId)
|
||||
.then((r) => { setData(r); setFailed(false) })
|
||||
.catch(() => setFailed(true))
|
||||
}, [advId])
|
||||
|
||||
// Load once to learn whether there's an RPG layer, then refresh after turns.
|
||||
useEffect(() => { load() }, [load, refreshKey])
|
||||
|
||||
const schema = data?.schema
|
||||
if (!schema) return null // no RPG layer for this adventure
|
||||
|
||||
const state = data?.state || {}
|
||||
const npcState = state.npc || {}
|
||||
const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats
|
||||
const flags = Object.entries(schema.flags || {})
|
||||
const flagState = state.flags || {}
|
||||
const milestones = Object.entries(schema.milestones || {})
|
||||
const reached = state.milestones || {}
|
||||
|
||||
const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) }
|
||||
const cancelEdit = () => { setEditing(false); setDraft(null) }
|
||||
const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value }))
|
||||
const saveEdit = () => {
|
||||
setSaving(true)
|
||||
api.overrideWorldState(advId, draft)
|
||||
.then(() => { setEditing(false); setDraft(null); load() })
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
|
||||
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
|
||||
title="RPG world state">
|
||||
{open ? '‹' : '›'}<span className="status-toggle-label">World</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="status-body">
|
||||
<div className="side-panel-header">
|
||||
<h2>World State</h2>
|
||||
{editing ? (
|
||||
<div className="ws-edit-actions">
|
||||
<button onClick={cancelEdit} disabled={saving} title="Discard changes">Cancel</button>
|
||||
<button onClick={saveEdit} disabled={saving} title="Save overrides">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ws-edit-actions">
|
||||
<button onClick={startEdit} title="Edit current values">✎</button>
|
||||
<button onClick={load} title="Refresh">↻</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{failed ? (
|
||||
<div className="empty">Couldn’t load world state.</div>
|
||||
) : (
|
||||
<>
|
||||
<StatGroup title={null} defs={schema.world} values={state.world}
|
||||
editing={editing} draft={sliceDraft(draft, 'world')}
|
||||
onEdit={(name, v) => setPath(`world.${name}`, v)} />
|
||||
{/* Named after the persona when there is one, matching the label
|
||||
the prompt uses. Read-only here — the name is edited in the
|
||||
Plot panel, so there is one place to change it. */}
|
||||
<StatGroup title={personaName || 'You'} defs={schema.player} values={state.player}
|
||||
editing={editing} draft={sliceDraft(draft, 'player')}
|
||||
onEdit={(name, v) => setPath(`player.${name}`, v)} />
|
||||
{npcs.length > 0 && (
|
||||
<div className="ws-group">
|
||||
<h3 className="ws-group-title">Cast</h3>
|
||||
{npcs.map(([id, def]) => (
|
||||
<div key={id} className="ws-npc">
|
||||
<div className="ws-npc-head" title={def.desc || undefined}>
|
||||
<span className="ws-npc-avatar" aria-hidden="true">
|
||||
{npcInitials(def.name, id)}
|
||||
</span>
|
||||
<span className="ws-npc-name">{def.name || id}</span>
|
||||
</div>
|
||||
<StatGroup defs={def.stats} values={npcState[id]}
|
||||
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
|
||||
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)}
|
||||
nested />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{flags.length > 0 && (
|
||||
<div className="ws-group">
|
||||
<h3 className="ws-group-title">Flags</h3>
|
||||
<ul className="ws-flags">
|
||||
{flags.map(([fid, def]) => {
|
||||
const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial)
|
||||
return (
|
||||
<li key={fid} title={def.desc || undefined}>
|
||||
{editing ? (
|
||||
<label className="ws-flag-edit">
|
||||
<input type="checkbox" checked={on}
|
||||
onChange={(e) => setPath(`flags.${fid}`, e.target.checked)} />
|
||||
<span className="ws-flag-name">{fid}</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
|
||||
<span className="ws-flag-name">{fid}</span>
|
||||
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{milestones.length > 0 && (
|
||||
<div className="ws-group">
|
||||
<h3 className="ws-group-title">Milestones</h3>
|
||||
<ul className="ws-milestones">
|
||||
{milestones.map(([mid, def]) => {
|
||||
const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached
|
||||
return (
|
||||
<li key={mid} className={done ? 'done' : ''}>
|
||||
{editing ? (
|
||||
<label className="ws-flag-edit">
|
||||
<input type="checkbox" checked={done}
|
||||
onChange={(e) => setPath(`milestones.${mid}`, e.target.checked)} />
|
||||
<span>{def.desc || mid}</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<span className="ws-check">{done ? '☑' : '☐'}</span>
|
||||
{def.desc || mid}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { WorldStateDrawer }
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Which operation an "Edit" becomes, and why it differs by whose turn it was.
|
||||
*
|
||||
* D09 and D10 both start from the same control and must not reach the same
|
||||
* endpoint:
|
||||
*
|
||||
* narrator turn PATCH — `_edit_narration` forks it per §§14-15: the
|
||||
* corrected prose becomes a new continuation, downstream state
|
||||
* is re-evaluated, the old narration keeps its future.
|
||||
* player turn replay — `PATCH` on a player row rewrites it *in place* and
|
||||
* re-evaluates nothing, and refuses outright when displaced
|
||||
* history hangs off it. Neither creates the new continuation
|
||||
* D09 requires. `addTake` does.
|
||||
*
|
||||
* M8 routed both through PATCH. The confirmation still said "this will continue
|
||||
* the story differently… what you have already read is kept", so the dialog
|
||||
* promised fork semantics the code did not perform, and the edited player action
|
||||
* never reached the story. Found by the browser acceptance run.
|
||||
*
|
||||
* These assert the routing decision directly, because it is a decision — the
|
||||
* browser suite proves the outcome, and this pins the reason.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* The rule `beginEdit` applies, extracted so it can be stated once.
|
||||
*
|
||||
* `kind` is the action type; `hasStoryAfter` is whether anything is written
|
||||
* below it on the active line.
|
||||
*/
|
||||
function routeFor(kind, hasStoryAfter, isTake) {
|
||||
if (isTake || !hasStoryAfter) return 'edit-in-place'
|
||||
return kind !== 'ai' ? 'replay' : 'patch-which-forks'
|
||||
}
|
||||
|
||||
describe('editing a turn with story after it', () => {
|
||||
it('replays a player turn, because PATCH would not create a continuation', () => {
|
||||
expect(routeFor('do', true, false)).toBe('replay')
|
||||
expect(routeFor('say', true, false)).toBe('replay')
|
||||
expect(routeFor('story', true, false)).toBe('replay')
|
||||
})
|
||||
|
||||
it('patches a narrator turn, because that path already forks', () => {
|
||||
expect(routeFor('ai', true, false)).toBe('patch-which-forks')
|
||||
})
|
||||
})
|
||||
|
||||
describe('editing a turn with nothing after it', () => {
|
||||
it('is a plain in-place edit, for either kind', () => {
|
||||
// Nothing descends from it, so nothing can be contradicted and no model
|
||||
// call is needed.
|
||||
expect(routeFor('do', false, false)).toBe('edit-in-place')
|
||||
expect(routeFor('ai', false, false)).toBe('edit-in-place')
|
||||
})
|
||||
|
||||
it('is in-place for a take being read, which is a leaf by construction', () => {
|
||||
// Whatever was played after this turn was played after the take that *is*
|
||||
// live, so a take that is not live has nothing under it.
|
||||
expect(routeFor('ai', true, true)).toBe('edit-in-place')
|
||||
expect(routeFor('do', true, true)).toBe('edit-in-place')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/* A05 must behave the same however the failure arrives.
|
||||
*
|
||||
* There are two paths and only one of them used to work:
|
||||
*
|
||||
* - a transport or HTTP failure **throws**, and `runTurn`'s catch sees it;
|
||||
* - a *generation* failure does not throw. The server reports it as an SSE
|
||||
* `error` event and the request then completes normally, so the catch never
|
||||
* runs.
|
||||
*
|
||||
* Restoring the reader's words and re-testing the model both lived in the catch
|
||||
* alone, so on the commoner path the failure notice said "what you typed is
|
||||
* still in the box" while the box was empty, and the header went on claiming
|
||||
* the model was connected. Found by breaking a real endpoint mid-session in a
|
||||
* browser; asserted here so it cannot come back quietly.
|
||||
*
|
||||
* These drive `classifyError` and the shared handler rather than the whole Play
|
||||
* screen, because the Play screen needs a router, a campaign fetch and an SSE
|
||||
* transport — and the property under test is which path restores what.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { classifyError, KIND } from '../../errors'
|
||||
import { FailureNotice } from './FailureNotice'
|
||||
import { renderWith } from '../../test/helpers'
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('the failure notice states only what is true', () => {
|
||||
it('claims the typed words were kept — so the caller must actually keep them', async () => {
|
||||
// This assertion is the contract the defect broke. The notice is
|
||||
// unconditional, so every path that shows it owes the reader their text.
|
||||
await renderWith(
|
||||
<FailureNotice failure={classifyError('Could not connect to http://127.0.0.1:9/v1')}
|
||||
partial={null} onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('classifies an unreachable endpoint as a model failure, whichever path found it', () => {
|
||||
// The SSE error event carries `detail`; a thrown error carries `message`.
|
||||
// Both are the same string from the same provider, so both classify alike.
|
||||
const fromEvent = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
|
||||
const fromThrow = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
|
||||
expect(fromEvent.kind).toBe(KIND.MODEL)
|
||||
expect(fromEvent).toEqual(fromThrow)
|
||||
// And a model failure is what triggers the status re-test.
|
||||
expect(fromEvent.kind).toBe('model')
|
||||
})
|
||||
|
||||
it('offers Retry, and dismissing does not retry', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
await renderWith(
|
||||
<FailureNotice failure={classifyError('The AI endpoint timed out.')}
|
||||
partial={null} onRetry={onRetry} onDismiss={onDismiss} />,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss this message' }))
|
||||
expect(onDismiss).toHaveBeenCalled()
|
||||
expect(onRetry).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Try that turn again' }))
|
||||
expect(onRetry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels partial prose as not kept rather than showing it as story', async () => {
|
||||
await renderWith(
|
||||
<FailureNotice failure={classifyError('boom')} partial="The tavern door swung"
|
||||
onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/The tavern door swung/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+400
-582
File diff suppressed because it is too large
Load Diff
@@ -1,195 +0,0 @@
|
||||
// The branch panel: every line the story has taken, and the map of them.
|
||||
//
|
||||
// One request draws the whole panel. `fork_depth` says where a branch leaves
|
||||
// its parent and `depth` says where it currently ends, so the shape is two
|
||||
// numbers a row rather than a walk.
|
||||
//
|
||||
// Delete belongs here rather than in a later subphase because nothing prunes
|
||||
// the tree on its own. This panel is the first place you can make a fork, so
|
||||
// it has to be the first place you can unmake one.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { BranchMap } from '../../../BranchMap'
|
||||
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
|
||||
|
||||
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
|
||||
const [branches, setBranches] = useState(null)
|
||||
const [failed, setFailed] = useState(null)
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [renaming, setRenaming] = useState(null) // { id, text }
|
||||
const [confirming, setConfirming] = useState(null)
|
||||
const [mapOpen, setMapOpen] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFailed(null)
|
||||
api.listBranches(advId)
|
||||
.then((list) => { if (!cancelled) setBranches(list) })
|
||||
.catch((err) => { if (!cancelled) setFailed(err.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [advId, refreshKey, tick])
|
||||
|
||||
// Answers whether it worked. Both callers keep an editor open on a refusal —
|
||||
// a rename the server turned down must not take the typed name with it.
|
||||
async function run(branchId, work) {
|
||||
setBusyId(branchId)
|
||||
try {
|
||||
await work()
|
||||
setTick((t) => t + 1)
|
||||
// Deleting a branch takes its memories with it, and nothing else on the
|
||||
// screen would hear about that — no turn is played, and the story on the
|
||||
// current path does not change by a single action.
|
||||
onTreeChanged()
|
||||
return true
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
return false
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// One copy of each operation. The list below and the map overlay both go
|
||||
// through these, so a rule cannot hold in one view and not the other, and a
|
||||
// failure is reported one way wherever it was asked for.
|
||||
const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id)))
|
||||
const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name))
|
||||
const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id))
|
||||
|
||||
const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) }
|
||||
const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) }
|
||||
|
||||
if (failed) return <div className="panel-empty">Couldn’t read the branches — {failed}</div>
|
||||
if (!branches) return <div className="panel-empty">Reading the tree…</div>
|
||||
|
||||
const lineage = headLineage(branches)
|
||||
|
||||
return (
|
||||
<div className="branch-panel">
|
||||
{/* The list says which lines exist; the map says where they parted and
|
||||
how much story each one is, which is the part a list cannot draw. */}
|
||||
<button type="button" className="branch-map-open" onClick={() => setMapOpen(true)}>
|
||||
⌗ See the tree
|
||||
</button>
|
||||
{branches.length === 1 && (
|
||||
<p className="branch-intro">
|
||||
One thread so far. Retry a turn, then take an attempt the story moved
|
||||
past — that is what makes a second one.
|
||||
</p>
|
||||
)}
|
||||
<div className="branch-list">
|
||||
{orderBranches(branches).map(({ branch, indent }) => {
|
||||
const isRenaming = renaming?.id === branch.id
|
||||
const isConfirming = confirming === branch.id
|
||||
const busy = busyId === branch.id
|
||||
// The server refuses to delete the line being read or any line it
|
||||
// was forked from. The button said nothing about that and answered
|
||||
// with a toast; it now says so before it is pressed.
|
||||
const loadBearing = lineage.has(branch.id)
|
||||
// A Save Point naming a moment on this branch, or on anything forked
|
||||
// from it, keeps the branch alive: named moments last until the user
|
||||
// deletes them, so the server refuses the deletion rather than taking
|
||||
// them with it. Counted over the subtree, because deleting a branch
|
||||
// takes its descendants.
|
||||
const protecting = savePointsUnder(branches, branch.id)
|
||||
return (
|
||||
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
|
||||
style={{ marginLeft: indent * 12 }}>
|
||||
<div className="branch-head">
|
||||
<span className="branch-glyph" aria-hidden="true">
|
||||
{branch.parent_branch_id === null ? '●' : '└'}
|
||||
</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
className="branch-rename"
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
value={renaming.text}
|
||||
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveName(branch)
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="branch-name">{branchLabel(branch)}</span>
|
||||
)}
|
||||
{branch.is_head && <span className="branch-here">reading</span>}
|
||||
</div>
|
||||
|
||||
<div className="branch-meta">
|
||||
{branch.own_actions} of its own
|
||||
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
|
||||
{` · ends at ${branch.depth + 1}`}
|
||||
{protecting > 0 &&
|
||||
` · ${protecting === 1 ? '1 Save Point' : `${protecting} Save Points`} kept here`}
|
||||
</div>
|
||||
|
||||
{isConfirming ? (
|
||||
<div className="branch-confirm">
|
||||
{/* Only reachable when no Save Point points into this
|
||||
subtree — the button is disabled otherwise, and the server
|
||||
refuses regardless. So this says what the deletion costs
|
||||
without hedging about Save Points that cannot be at risk. */}
|
||||
<span>
|
||||
Delete this branch and everything forked from it? The story
|
||||
on other paths, and every Save Point, is unaffected.
|
||||
</span>
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={() => remove(branch)}>Delete</button>
|
||||
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="branch-tools">
|
||||
{!branch.is_head && (
|
||||
<button type="button" disabled={busy} onClick={() => switchTo(branch)}>Switch</button>
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<button type="button" disabled={busy} onClick={() => saveName(branch)}>Save</button>
|
||||
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" disabled={busy}
|
||||
onClick={() => setRenaming({ id: branch.id, text: branch.name || '' })}>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{/* The root holds the turns every other branch borrows, and
|
||||
the server refuses it — so it is not offered. */}
|
||||
{branch.parent_branch_id !== null && (
|
||||
<button type="button" className="danger"
|
||||
disabled={busy || loadBearing || protecting > 0}
|
||||
title={
|
||||
loadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: protecting > 0
|
||||
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setConfirming(branch.id)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mapOpen && (
|
||||
<BranchMap
|
||||
branches={branches}
|
||||
busyId={busyId}
|
||||
onSwitch={switchTo}
|
||||
onRename={renameTo}
|
||||
onDelete={removeBranch}
|
||||
onClose={() => setMapOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BranchPanel }
|
||||
@@ -0,0 +1,195 @@
|
||||
/* Campaign settings: the things a campaign is, rather than the things it did.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §39 lists Campaign Settings, Export and Delete Campaign
|
||||
* as campaign-level actions, and this is where they live — inside the campaign,
|
||||
* which is the only place they mean anything.
|
||||
*
|
||||
* It replaced the inherited "Plot" panel, which held the campaign's copy of a
|
||||
* scenario's text alongside a story-card table, an "Update from scenario"
|
||||
* dialog and an art picker. The fields worth keeping were the ones that
|
||||
* actually reach the prompt; the rest was AI Dungeon's world-info editor, which
|
||||
* the M7 knowledge library supersedes for v1.
|
||||
*
|
||||
* Canon gets its own editor here because it is the highest authority in the
|
||||
* campaign and the thing a reader most often needs to correct *after* the story
|
||||
* has tried to contradict it. It is written one rule per line and stored in the
|
||||
* same document the prompt builder and the state validator already read.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../../../api'
|
||||
import { downloadJSON, useToast } from '../../../components'
|
||||
import { ConfirmDialog } from '../../../Dialog'
|
||||
import { classifyError } from '../../../errors'
|
||||
|
||||
export function CampaignSettingsPanel({ adventure, setAdventure, onError, moments = 0 }) {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const [title, setTitle] = useState(adventure.title || '')
|
||||
const [instructions, setInstructions] = useState(adventure.ai_instructions || '')
|
||||
const [canon, setCanon] = useState((adventure.canon_rules || []).join('\n'))
|
||||
const [persona, setPersona] = useState(adventure.persona_name || '')
|
||||
const [personaDesc, setPersonaDesc] = useState(adventure.persona_desc || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
// Re-seed if the campaign is replaced under us (an import, a restore).
|
||||
useEffect(() => {
|
||||
setTitle(adventure.title || '')
|
||||
setInstructions(adventure.ai_instructions || '')
|
||||
setCanon((adventure.canon_rules || []).join('\n'))
|
||||
setPersona(adventure.persona_name || '')
|
||||
setPersonaDesc(adventure.persona_desc || '')
|
||||
setDirty(false)
|
||||
}, [adventure.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const edit = (setter) => (event) => {
|
||||
setter(event.target.value)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
// Saved on an explicit press rather than on a debounce timer. A debounced
|
||||
// save writes on every pause in typing, which is a request per few keystrokes
|
||||
// against a field a reader edits for a minute at a time — the request-per-
|
||||
// keystroke class of defect §38 looks for, arrived at politely.
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await api.updateAdventure(adventure.id, {
|
||||
title: title.trim() || 'Untitled campaign',
|
||||
ai_instructions: instructions,
|
||||
canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean),
|
||||
persona_name: persona.trim(),
|
||||
persona_desc: personaDesc,
|
||||
})
|
||||
setAdventure((prev) => ({ ...prev, ...updated }))
|
||||
setDirty(false)
|
||||
toast('Campaign settings saved.')
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportCampaign = async () => {
|
||||
try {
|
||||
const bundle = await api.exportAdventure(adventure.id)
|
||||
const safe = (adventure.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
toast('Campaign exported.')
|
||||
} catch (err) {
|
||||
onError(classifyError(err.message).detail)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await api.deleteAdventure(adventure.id)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
onError(classifyError(err.message).detail)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="campaign-settings">
|
||||
<div className="field">
|
||||
<label htmlFor="cs-title">Title</label>
|
||||
<input id="cs-title" type="text" maxLength={120} value={title} onChange={edit(setTitle)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="cs-instructions">How the narrator should write</label>
|
||||
<textarea
|
||||
id="cs-instructions" rows={5} maxLength={8000}
|
||||
value={instructions} onChange={edit(setInstructions)}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Genre, tone, voice, length — anything you want true of every turn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="cs-canon">Canon</label>
|
||||
<textarea
|
||||
id="cs-canon" rows={5} maxLength={8000}
|
||||
value={canon} onChange={edit(setCanon)}
|
||||
placeholder={'One rule per line.\nResurrection is impossible.'}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
One per line. The narrator is told these, and a turn that contradicts
|
||||
one is refused. They outrank anything the story later invents.
|
||||
</p>
|
||||
{/* Canon is the highest authority in a campaign, so changing it once a
|
||||
story exists must not read like correcting a typo. It is a
|
||||
configuration change, not a retroactive one, and the distinction is
|
||||
the thing a reader needs told: what is already written keeps the
|
||||
canon it was written under, and each turn's context proves it.
|
||||
Saying so here is what stops a post-play edit passing silently. */}
|
||||
{moments > 0 && (
|
||||
<p className="field-warning" data-testid="canon-history-warning">
|
||||
This story already has {moments} {moments === 1 ? 'moment' : 'moments'}.
|
||||
Changing canon changes what the narrator is told <strong>from here
|
||||
on</strong>. Everything already written stays exactly as it is, and
|
||||
each turn keeps a record of the canon it was actually given — open
|
||||
<em> Inspect context</em> on any narrator turn to see it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="cs-persona">Protagonist</label>
|
||||
<input
|
||||
id="cs-persona" type="text" maxLength={80}
|
||||
value={persona} onChange={edit(setPersona)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="cs-persona-desc">Who they are</label>
|
||||
<textarea
|
||||
id="cs-persona-desc" rows={3} maxLength={2000}
|
||||
value={personaDesc} onChange={edit(setPersonaDesc)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel-actions">
|
||||
<button type="button" className="primary" onClick={save} disabled={saving || !dirty}>
|
||||
{saving ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="panel-rule" />
|
||||
|
||||
<div className="panel-actions">
|
||||
<button type="button" onClick={exportCampaign}>Export campaign</button>
|
||||
<button type="button" className="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete campaign
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint">
|
||||
Export writes a single file holding the whole campaign — its story, its
|
||||
Save Points, its state and everything imported into it.
|
||||
</p>
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title={`Delete “${adventure.title}”?`}
|
||||
confirmLabel="Delete this campaign"
|
||||
destructive
|
||||
requireText={adventure.title}
|
||||
requireLabel="Type the campaign name to confirm"
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={remove}
|
||||
>
|
||||
<p>
|
||||
This permanently deletes the whole story, every Save Point, the
|
||||
story state, and everything imported into it. It cannot be undone.
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/* What the narrator was given, and what it cost.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §53-59 and §23 of the M8 brief. M6 and M7 already
|
||||
* assembled all of this; what M8 owed was making it readable.
|
||||
*
|
||||
* The inherited panel showed everything at once — a metadata header, then every
|
||||
* assembled prompt section dumped into a `<pre>`. On a two-turn campaign that
|
||||
* measured just under twelve thousand characters of text, which is the "giant
|
||||
* raw prompt" §55 says not to open with. The reader's question is almost never
|
||||
* "what were the exact bytes"; it is "why did the narrator say that?", and the
|
||||
* answer is one of four things: what it was told to be, what it remembered,
|
||||
* what it read, or what it believes.
|
||||
*
|
||||
* So the default view is those four, summarised, with the exact prompt kept as
|
||||
* an advanced section at the bottom (§55).
|
||||
*
|
||||
* ## Narrator-only passages
|
||||
*
|
||||
* §19 asks that narrator-only material not be revealed by accident. The
|
||||
* narrative state has no hidden dimension — a secret never enters it, which M7
|
||||
* verified against a real narrator — so the only place a campaign's secrets are
|
||||
* reachable from a normal screen is here, in a retrieved passage that was
|
||||
* marked narrator-only on import.
|
||||
*
|
||||
* That is a real spoiler, and it is shown behind an explicit control that is
|
||||
* off by default and says what it will reveal. Turning it on is a preference in
|
||||
* this component and nothing else: it reads no differently, retrieves no
|
||||
* differently, and changes no story state (§92).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { SECTION_LABELS, pctLabel } from '../format'
|
||||
import { CacheReport, WorldStateReport } from '../reports'
|
||||
import { CLASS_LABEL } from './KnowledgePanel'
|
||||
|
||||
/** A collapsible block. Open by default for the sections a reader came for. */
|
||||
function Section({ title, count, children, open = false, testId }) {
|
||||
return (
|
||||
<details className="ctx-block" open={open} data-testid={testId}>
|
||||
<summary>
|
||||
<span className="ctx-block-title">{title}</span>
|
||||
{count != null && <span className="ctx-block-count">{count}</span>}
|
||||
</summary>
|
||||
<div className="ctx-block-body">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function TokenBar({ sections, total }) {
|
||||
if (!sections.length || total <= 0) return null
|
||||
return (
|
||||
<div className="token-bar" role="img" aria-label="How the context is divided">
|
||||
{sections.filter((s) => s.tokens > 0).map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`token-bar-slice slice-${i % 8}`}
|
||||
style={{ width: `${(s.tokens / total) * 100}%` }}
|
||||
title={`${SECTION_LABELS[s.label] || s.label}: ${s.tokens} tokens`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextPanel({
|
||||
advId, inspectActionId, onClearInspect, refreshKey, onOpenSource,
|
||||
}) {
|
||||
const [report, setReport] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [revealHidden, setRevealHidden] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setError(null)
|
||||
const load = inspectActionId
|
||||
? api.getActionContext(advId, inspectActionId)
|
||||
: api.getAdventureContext(advId)
|
||||
load
|
||||
.then((r) => { if (!stale) setReport(r) })
|
||||
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
|
||||
return () => { stale = true }
|
||||
}, [advId, inspectActionId, refreshKey])
|
||||
|
||||
// Turning the panel off a past turn should not leave a stale reveal on.
|
||||
useEffect(() => { setRevealHidden(false) }, [inspectActionId])
|
||||
|
||||
if (error) return <div className="panel-empty">Couldn’t read the context — {error}</div>
|
||||
if (!report) return <div className="panel-empty">Reading the context…</div>
|
||||
|
||||
const { tokens, history, sections, knowledge, memories, summary, derived } = report
|
||||
const overBudget = tokens.total > tokens.budget
|
||||
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
|
||||
const failing = (derived || []).filter((d) => d.status === 'failed')
|
||||
const summaryText = sections.find((s) => s.label === 'story_summary')?.text || ''
|
||||
// Whether anything narrator-only reached this prompt at all. If it did, the
|
||||
// assembled prompt below contains it verbatim — see the guard on that section.
|
||||
const hasHidden = (knowledge?.used || []).some((k) => k.visibility === 'hidden')
|
||||
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="ctx-mode">
|
||||
{inspectActionId ? (
|
||||
<>
|
||||
<span>What the narrator was given for this turn</span>
|
||||
<button type="button" onClick={onClearInspect}>Show the next turn instead</button>
|
||||
</>
|
||||
) : (
|
||||
<span>What the narrator will be given on the next turn</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Token usage (§58) ---- */}
|
||||
<div className={`ctx-tokens ${overBudget ? 'over' : ''}`} data-testid="ctx-tokens">
|
||||
<div className="ctx-token-line">
|
||||
<span>Story and instructions</span>
|
||||
<strong>{tokens.total.toLocaleString()} tokens</strong>
|
||||
</div>
|
||||
{tokens.output_reserve > 0 && (
|
||||
<div className="ctx-token-line dim">
|
||||
<span>Held back for the reply</span>
|
||||
<span>{tokens.output_reserve.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ctx-token-line dim">
|
||||
<span>What the model can hold</span>
|
||||
<span>{tokens.budget.toLocaleString()}</span>
|
||||
</div>
|
||||
<TokenBar sections={sections} total={sectionTotal} />
|
||||
{overBudget && (
|
||||
<p className="ctx-warn">
|
||||
This is more than the model can hold. The oldest story will be
|
||||
trimmed to fit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failing.length > 0 && (
|
||||
<div className="notice error" role="alert" data-testid="ctx-derived-failing">
|
||||
<strong>Background work is failing</strong>
|
||||
<p>
|
||||
New {failing.map((d) => d.kind).join(' and ')} work is not being
|
||||
written. Your story is unaffected, but the narrator will stop getting
|
||||
fresh {failing.map((d) => d.kind).join(' and ')}.
|
||||
</p>
|
||||
{failing.map((d) => (
|
||||
<p key={d.kind} className="notice-detail">{d.kind}: {d.detail}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Retrieved knowledge (§57) ---- */}
|
||||
<Section
|
||||
title="Imported knowledge the narrator read"
|
||||
count={knowledge?.used?.length ?? 0}
|
||||
open
|
||||
testId="ctx-knowledge"
|
||||
>
|
||||
{!knowledge || knowledge.generated === 0 ? (
|
||||
<p className="dim">Nothing has been imported into this campaign.</p>
|
||||
) : knowledge.used?.length === 0 ? (
|
||||
<p className="dim" data-testid="ctx-knowledge-none">
|
||||
{knowledge.generated} passage{knowledge.generated === 1 ? '' : 's'} were
|
||||
looked at, and none were close enough to this scene to be worth
|
||||
supplying.
|
||||
{knowledge.terms?.length > 0 && (
|
||||
<> Searched for: {knowledge.terms.slice(0, 10).join(', ')}.</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{knowledge.used.some((k) => k.visibility === 'hidden') && (
|
||||
<label className="reveal-toggle" data-testid="reveal-hidden">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={revealHidden}
|
||||
onChange={(e) => setRevealHidden(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show narrator-only text — this will reveal secrets you marked
|
||||
for the narrator alone
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<ul className="ctx-rows">
|
||||
{knowledge.used.map((k) => {
|
||||
const hidden = k.visibility === 'hidden'
|
||||
return (
|
||||
<li
|
||||
key={k.chunk_id}
|
||||
className="ctx-row"
|
||||
data-chunk-id={k.chunk_id}
|
||||
data-classification={k.classification}
|
||||
>
|
||||
<div className="ctx-row-head">
|
||||
{/* §57: click through to the source this came from. The
|
||||
row carries `source_id`, so the Knowledge panel can be
|
||||
opened on exactly that file rather than on a list the
|
||||
reader then has to search. */}
|
||||
{onOpenSource && k.source_id != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike ctx-source-link"
|
||||
onClick={() => onOpenSource(k.source_id)}
|
||||
>
|
||||
{k.filename || k.title}
|
||||
</button>
|
||||
) : (
|
||||
<strong>{k.filename || k.title}</strong>
|
||||
)}
|
||||
{/* The same label the Knowledge panel uses. A class that
|
||||
reads "canon" here and "Canon" there is two things to
|
||||
a reader, and §48 turns on the class being one. */}
|
||||
<span className={`class-tag class-${k.classification}`}>
|
||||
{CLASS_LABEL[k.classification] || k.classification}
|
||||
</span>
|
||||
{hidden && <span className="hidden-tag">narrator only</span>}
|
||||
</div>
|
||||
<div className="ctx-row-meta">
|
||||
{k.heading_path && <span>{k.heading_path}</span>}
|
||||
<span>passage {k.chunk_index + 1}</span>
|
||||
{k.always_include
|
||||
? <span>always included</span>
|
||||
: (
|
||||
<>
|
||||
<span>found by {k.mode}</span>
|
||||
{k.cosine > 0 && <span>closeness {k.cosine.toFixed(2)}</span>}
|
||||
</>
|
||||
)}
|
||||
<span>{k.prompt_tokens} tokens</span>
|
||||
</div>
|
||||
{/* Rendered as a text node in a <pre>, never as markup —
|
||||
this is where H06 and H07 are decided for imported
|
||||
content, and it is why no Markdown renderer was added
|
||||
here for appearance. */}
|
||||
{hidden && !revealHidden ? (
|
||||
<p className="ctx-row-withheld">
|
||||
Hidden — this passage is narrator-only.
|
||||
</p>
|
||||
) : (
|
||||
<pre className="ctx-row-text">{k.text}</pre>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<p className="dim ctx-foot">
|
||||
{knowledge.generated} passage{knowledge.generated === 1 ? '' : 's'} considered
|
||||
{knowledge.rejected > 0 && `, ${knowledge.rejected} not close enough`}
|
||||
{knowledge.spent > 0
|
||||
&& `, ${knowledge.spent} of ${knowledge.budget} tokens used`}
|
||||
</p>
|
||||
{knowledge.suppressed?.map((k) => (
|
||||
<p key={`s${k.chunk_id}`} className="dim">
|
||||
{k.filename} passage {k.chunk_index + 1} — set aside as repeating
|
||||
another passage.
|
||||
</p>
|
||||
))}
|
||||
{knowledge.dropped?.map((k) => (
|
||||
<p key={`d${k.chunk_id}`} className="dim">
|
||||
{k.filename} passage {k.chunk_index + 1} — {k.reason} ({k.tokens} tokens).
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{knowledge?.semantic_note && (
|
||||
<p className="ctx-note" data-testid="ctx-semantic-note">{knowledge.semantic_note}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ---- Retrieved memories (§56) ---- */}
|
||||
<Section
|
||||
title="What the story remembered"
|
||||
count={memories?.used?.length ?? 0}
|
||||
open
|
||||
testId="ctx-memories"
|
||||
>
|
||||
{memories?.error && <p className="notice-detail error">{memories.error}</p>}
|
||||
{!memories?.used?.length && !memories?.error && (
|
||||
<p className="dim">Nothing was recalled for this turn.</p>
|
||||
)}
|
||||
<ul className="ctx-rows">
|
||||
{memories?.used?.map((m, i) => (
|
||||
<li key={i} className="ctx-row">
|
||||
<div className="ctx-row-head">
|
||||
<strong>
|
||||
{m.authority === 'heuristic' ? 'Something inferred' : 'Something that happened'}
|
||||
</strong>
|
||||
{m.pinned
|
||||
? <span className="class-tag">always kept</span>
|
||||
: <span className="dim">closeness {m.similarity?.toFixed(2)}</span>}
|
||||
</div>
|
||||
<div className="ctx-row-meta">
|
||||
{m.source?.depth != null && (
|
||||
<span>
|
||||
from moment {m.source.source_start === m.source.source_end
|
||||
? m.source.depth
|
||||
: `${m.source.source_start}–${m.source.source_end}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="ctx-row-text-plain">{m.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{memories?.considered != null && (
|
||||
<p className="dim ctx-foot">
|
||||
{memories.considered} memories were eligible on this line of the story.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ---- Summary (§59) ----
|
||||
The provenance record carries where the summary came from but not its
|
||||
words; the words are the assembled `story_summary` section, which is
|
||||
the same text the narrator was sent. Read from there rather than
|
||||
adding a second copy to the API. */}
|
||||
<Section title="The story so far, as summarised" testId="ctx-summary">
|
||||
{summary ? (
|
||||
<>
|
||||
{summaryText && <p className="ctx-row-text-plain">{summaryText}</p>}
|
||||
<p className="dim ctx-foot">
|
||||
Covers {summary.source_start != null
|
||||
? `moments ${summary.source_start}–${summary.source_end}`
|
||||
: `up to moment ${summary.source_end ?? summary.depth}`}
|
||||
{summary.trigger === 'manual' ? ' · written by you' : ' · written for you'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dim">
|
||||
No summary applies at this point in the story yet.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ---- Model settings (§54) ---- */}
|
||||
<Section title="What produced this" testId="ctx-settings">
|
||||
<dl className="ctx-settings">
|
||||
<dt>Model</dt><dd>{report.settings?.model || '(none chosen)'}</dd>
|
||||
<dt>Temperature</dt><dd>{report.settings?.temperature}</dd>
|
||||
<dt>Longest reply</dt><dd>{report.settings?.max_output_tokens} tokens</dd>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* ---- History ---- */}
|
||||
<Section title="How much of the story was included" testId="ctx-history">
|
||||
<p>
|
||||
{history.included} of {history.total} moments were sent.
|
||||
{history.total > history.included && ' Older ones were trimmed to fit.'}
|
||||
{history.oldest_truncated && ' The oldest one was cut mid-sentence.'}
|
||||
</p>
|
||||
<CacheReport usage={report.usage} />
|
||||
</Section>
|
||||
|
||||
<WorldStateReport worldState={report.world_state} />
|
||||
|
||||
{/* ---- The exact prompt (§55) ----
|
||||
Withheld entirely while narrator-only material is in it and the reader
|
||||
has not asked to see it. The passage rows above hide the secret one by
|
||||
one; this section is the *assembled* prompt, so it contains the same
|
||||
text verbatim — and a closed `<details>` still holds its contents in
|
||||
the DOM, where find-in-page reaches it. Hiding it visually would have
|
||||
been the appearance of a guard rather than a guard, which is what the
|
||||
sentinel test was written to catch. */}
|
||||
<Section
|
||||
title="The exact text the narrator was sent"
|
||||
count={hasHidden && !revealHidden ? 'withheld' : `${sectionTotal.toLocaleString()} tokens`}
|
||||
testId="ctx-raw"
|
||||
>
|
||||
{hasHidden && !revealHidden ? (
|
||||
<p className="ctx-row-withheld" data-testid="ctx-raw-withheld">
|
||||
The assembled prompt contains a narrator-only passage, so it is
|
||||
withheld. Turn on <em>Show narrator-only text</em> above to read it —
|
||||
it will reveal secrets you marked for the narrator alone.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="dim">
|
||||
This is the assembled prompt, section by section, exactly as it went to
|
||||
the model.
|
||||
</p>
|
||||
{sections.map((s, i) => (
|
||||
<div key={i} className="ctx-raw-section">
|
||||
<div className="ctx-raw-head">
|
||||
<span>{SECTION_LABELS[s.label] || s.label}</span>
|
||||
<span>
|
||||
{s.tokens} tok
|
||||
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
|
||||
</span>
|
||||
</div>
|
||||
<pre>{s.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
{report.raw_output && (
|
||||
<div className="ctx-raw-section">
|
||||
<div className="ctx-raw-head">
|
||||
<span>What came back, before the state block was removed</span>
|
||||
</div>
|
||||
<pre>{report.raw_output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// The insights panel: what the last turn sent, and what it cost.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
|
||||
import { CacheReport, TokenBreakdown, WorldStateReport } from '../reports'
|
||||
|
||||
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
const [report, setReport] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false // a slow earlier request must not clobber a newer one
|
||||
setError(null)
|
||||
const load = inspectActionId
|
||||
? api.getActionContext(advId, inspectActionId)
|
||||
: api.getAdventureContext(advId)
|
||||
load
|
||||
.then((r) => { if (!stale) setReport(r) })
|
||||
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
|
||||
return () => { stale = true }
|
||||
}, [advId, inspectActionId, refreshKey])
|
||||
|
||||
if (error) return <div className="empty">{error}</div>
|
||||
if (!report) return <div className="empty">Loading…</div>
|
||||
|
||||
const { tokens, cards, history, sections } = report
|
||||
const overBudget = tokens.total > tokens.budget
|
||||
// The per-section sum, not tokens.total: the total also counts the separators
|
||||
// between sections, and percentages have to add up to 100 for the reader.
|
||||
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
|
||||
const jumpToSection = (i) => {
|
||||
document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="insights">
|
||||
<div className="insights-meta">
|
||||
{inspectActionId ? (
|
||||
<div className="insights-mode">
|
||||
Snapshot of a past turn
|
||||
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="insights-mode">What will be sent on the next turn</div>
|
||||
)}
|
||||
<div className={`token-total ${overBudget ? 'over' : ''}`}>
|
||||
{tokens.total} / {tokens.budget} tokens
|
||||
{/* M6: the reply has to fit in the same window, so say how much of it
|
||||
is being held back for one. Without this the reader can see that
|
||||
the prompt fits and still get a truncated turn. */}
|
||||
{tokens.output_reserve > 0 && (
|
||||
<span className="dim"> · {tokens.output_reserve} reserved for the reply</span>
|
||||
)}
|
||||
</div>
|
||||
<TokenBreakdown
|
||||
sections={sections}
|
||||
tokens={tokens}
|
||||
used={sectionTotal}
|
||||
onJump={jumpToSection}
|
||||
/>
|
||||
<div className="insights-history">
|
||||
History: {history.included} of {history.total} actions in context
|
||||
{history.total > history.included && ' (older history trimmed)'}
|
||||
{history.oldest_truncated && ' — oldest entry cut mid-text'}
|
||||
</div>
|
||||
<CacheReport usage={report.usage} />
|
||||
{cards.length > 0 && (
|
||||
<div className="insights-cards">
|
||||
{cards.map((c, i) => (
|
||||
<div key={i} className={c.included ? '' : 'dropped'}>
|
||||
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
|
||||
{!c.included && ' — dropped (over card budget)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{report.memories && (
|
||||
<div className="insights-cards">
|
||||
{report.memories.error && (
|
||||
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
|
||||
)}
|
||||
{report.memories.used?.map((m, i) => (
|
||||
<div key={i}>
|
||||
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`})
|
||||
{/* M6: what weight it carries, and where it came from. An
|
||||
inference must not read like a record. */}
|
||||
{m.authority === 'heuristic'
|
||||
? <span className="mem-heuristic"> · inferred</span>
|
||||
: <span className="dim"> · from the story</span>}
|
||||
{m.source?.depth != null && (
|
||||
<span className="dim">
|
||||
{' '}· turn {m.source.source_start === m.source.source_end
|
||||
? m.source.depth
|
||||
: `${m.source.source_start}–${m.source.source_end}`}
|
||||
</span>
|
||||
)}
|
||||
: {m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
|
||||
</div>
|
||||
))}
|
||||
{report.memories.considered != null && (
|
||||
<div className="dim">
|
||||
{report.memories.considered} memories eligible on this line of the story.
|
||||
</div>
|
||||
)}
|
||||
{!report.memories.error && report.memories.used?.length === 0 && (
|
||||
<div className="dim">Memory bank on — no memories retrieved.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* M7: which imported passages the narrator was given, and why each of
|
||||
them won. This is F05's "retrieved knowledge" row and F06's imported
|
||||
half: the file, the class, the visibility, the passage, how it was
|
||||
found, what each retrieval path scored it, and what it cost.
|
||||
|
||||
Rendered as text, never as markup — the passage text below is a
|
||||
React child in a <pre>, so imported script or a javascript: URL is
|
||||
inert here exactly as it is in the Knowledge panel (H06, H07). */}
|
||||
{/* M7 corrective: "nothing matched" is a real answer and has to be
|
||||
said. Retrieval can now return no passages at all, and a panel that
|
||||
simply showed nothing would be indistinguishable from a library that
|
||||
was never searched. */}
|
||||
{report.knowledge && report.knowledge.generated > 0
|
||||
&& report.knowledge.used?.length === 0 && (
|
||||
<div className="insights-cards" data-testid="insights-knowledge-none">
|
||||
<div className="dim">
|
||||
▸ Imported knowledge: {report.knowledge.generated} passage(s)
|
||||
{' '}considered, none relevant enough to this scene to be supplied.
|
||||
{report.knowledge.terms?.length > 0 && (
|
||||
<> Searched on: {report.knowledge.terms.slice(0, 10).join(', ')}.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{report.knowledge && (report.knowledge.used?.length > 0
|
||||
|| report.knowledge.suppressed?.length > 0
|
||||
|| report.knowledge.dropped?.length > 0) && (
|
||||
<div className="insights-cards" data-testid="insights-knowledge">
|
||||
{report.knowledge.used?.map((k) => (
|
||||
<div key={`k${k.chunk_id}`} className="knowledge-used"
|
||||
data-chunk-id={k.chunk_id} data-classification={k.classification}>
|
||||
▸ <b>{k.filename || k.title}</b>
|
||||
<span className={`knowledge-class knowledge-class-${k.classification}`}>
|
||||
{k.classification}
|
||||
</span>
|
||||
{k.heading_path && <span className="dim"> · {k.heading_path}</span>}
|
||||
<span className="dim"> · passage {k.chunk_index + 1}</span>
|
||||
{k.visibility === 'hidden' && (
|
||||
<span className="knowledge-badge" title="Supplied to the narrator only">
|
||||
narrator only
|
||||
</span>
|
||||
)}
|
||||
{k.always_include
|
||||
? <span className="dim"> · always included</span>
|
||||
: (
|
||||
<span className="dim">
|
||||
{' '}· {k.mode} match
|
||||
{k.matched_terms?.length > 0
|
||||
&& ` on ${k.matched_terms.slice(0, 6).join(', ')}`}
|
||||
{k.cosine > 0 && ` · similarity ${k.cosine.toFixed(2)}`}
|
||||
{' '}· rank {k.score.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span className="dim"> · {k.prompt_tokens} tok</span>
|
||||
<pre className="knowledge-text">{k.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
{report.knowledge.suppressed?.map((k) => (
|
||||
<div key={`ks${k.chunk_id}`} className="dropped">
|
||||
▸ {k.filename} passage {k.chunk_index + 1} — set aside as
|
||||
repeating passage {k.duplicate_of}
|
||||
</div>
|
||||
))}
|
||||
{report.knowledge.dropped?.map((k) => (
|
||||
<div key={`kd${k.chunk_id}`} className="dropped">
|
||||
▸ {k.filename} passage {k.chunk_index + 1} — {k.reason}
|
||||
{' '}({k.tokens} tok)
|
||||
</div>
|
||||
))}
|
||||
<div className="dim">
|
||||
{report.knowledge.generated} passage(s) considered
|
||||
{report.knowledge.rejected > 0
|
||||
&& `, ${report.knowledge.rejected} not relevant enough`}
|
||||
{report.knowledge.spent > 0 && (
|
||||
<>, {report.knowledge.spent} of {report.knowledge.budget} knowledge tokens used</>
|
||||
)}
|
||||
{report.knowledge.terms?.length > 0 && (
|
||||
<> · searched on: {report.knowledge.terms.slice(0, 10).join(', ')}</>
|
||||
)}
|
||||
</div>
|
||||
{report.knowledge.semantic_note && (
|
||||
<div className="dim">{report.knowledge.semantic_note}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* M6: which summary was used, and what stretch of story it covers, so
|
||||
"what history did that summary cover?" is answerable here. */}
|
||||
{report.summary ? (
|
||||
<div className="insights-cards">
|
||||
<div>
|
||||
▸ summary in use
|
||||
<span className="dim">
|
||||
{' '}· covers {report.summary.source_start != null
|
||||
? `turns ${report.summary.source_start}–${report.summary.source_end}`
|
||||
: `up to turn ${report.summary.source_end ?? report.summary.depth}`}
|
||||
{report.summary.trigger === 'manual' ? ' · written by you' : ' · generated'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="insights-cards dim">▸ no summary is eligible for this point in the story.</div>
|
||||
)}
|
||||
{/* M6: a dead memory bank used to be invisible. It is not any more. */}
|
||||
{report.derived?.some((d) => d.status === 'failed') && (
|
||||
<div className="insights-cards">
|
||||
{report.derived.filter((d) => d.status === 'failed').map((d) => (
|
||||
<div key={d.kind} className="dropped">
|
||||
⚠ Background {d.kind} work is failing ({d.failures}
|
||||
{d.failures === 1 ? ' attempt' : ' attempts'}): {d.detail}
|
||||
</div>
|
||||
))}
|
||||
<div className="dim">
|
||||
The story itself is unaffected — this only stops new {' '}
|
||||
{report.derived.filter((d) => d.status === 'failed')
|
||||
.map((d) => d.kind).join(' and ')} work being written.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
id={`ctx-sec-${i}`}
|
||||
className={`ctx-section ctx-${s.label}`}
|
||||
style={{ borderLeftColor: sectionColor(s.label) }}
|
||||
>
|
||||
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
|
||||
<span>{SECTION_LABELS[s.label] || s.label}</span>
|
||||
<span>
|
||||
{s.tokens} tok
|
||||
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
|
||||
</span>
|
||||
</div>
|
||||
<pre>{s.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
<WorldStateReport worldState={report.world_state} />
|
||||
{report.raw_output && (
|
||||
<div className="ctx-section">
|
||||
<div className="ctx-header">
|
||||
<span>Raw AI output (before state block was stripped)</span>
|
||||
</div>
|
||||
<pre>{report.raw_output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InsightsPanel }
|
||||
@@ -1,26 +1,63 @@
|
||||
// M7: the imported knowledge library — import, classify, inspect, disable, delete.
|
||||
//
|
||||
// Functional rather than finished. M8 owns the designed knowledge surface; what
|
||||
// this has to do is make every M7 behaviour reachable in a browser without
|
||||
// anyone opening the database, which is the milestone's own standard.
|
||||
//
|
||||
// **Nothing here renders imported text as HTML.** Source text and passage text
|
||||
// both go into a `<pre>` as React children, which React escapes — so a
|
||||
// `<script>` in a file is five visible characters and a `javascript:` URL is
|
||||
// never an href, on first inspection and on every reopen (H06, H07). A remote
|
||||
// Markdown image reference is likewise just characters: no `<img>` is created,
|
||||
// so no request is made (G09). Adding a Markdown renderer would buy appearance
|
||||
// and cost exactly those three properties. `SECURITY-THREAT-MODEL.md` §14 names
|
||||
// sanitized presentation text as the safer default; rendering no markup at all
|
||||
// is one step safer still.
|
||||
/* The imported knowledge library.
|
||||
*
|
||||
* M7 built every behaviour here and M8 owns how it reads
|
||||
* (`BROWSER-UX-SPEC.md` §46-52, §21-22 of the M8 brief). What changed is
|
||||
* presentation and vocabulary, not capability: the same imports, the same
|
||||
* classification, enable/disable, narrator-only, always-include, delete,
|
||||
* inspect and rebuild.
|
||||
*
|
||||
* **Nothing here renders imported text as HTML.** Source text and passage text
|
||||
* both go into a `<pre>` as React children, which React escapes — so a
|
||||
* `<script>` in a file is visible characters, a `javascript:` URL is never an
|
||||
* href, and a remote Markdown image reference creates no `<img>` and therefore
|
||||
* makes no request (H06, H07, G09). The safe Markdown renderer the transcript
|
||||
* uses is deliberately *not* used here: this panel exists to show a reader
|
||||
* exactly what is in their file, and rendering is the opposite of that.
|
||||
* `SECURITY-THREAT-MODEL.md` §14 names sanitized presentation text as the safer
|
||||
* default; rendering no markup at all is one step safer still.
|
||||
*
|
||||
* ## The classification is explained, not iconified
|
||||
*
|
||||
* §48 is explicit that the three classes must not rely on unexplained icons,
|
||||
* because the difference between them is the whole feature: it decides the
|
||||
* words a passage is framed with in the prompt, the weight it carries in
|
||||
* ranking, and which budget it competes in. The explanations are on the import
|
||||
* form where the decision is made, not in a tooltip.
|
||||
*
|
||||
* ## Semantic status
|
||||
*
|
||||
* §22. Three states have to be told apart and the inherited line ran them
|
||||
* together: searching by meaning is on; it is off because no embedding model is
|
||||
* configured, which is a supported setup; or a model *is* configured and is not
|
||||
* being used because M7 has no measured calibration for it, so retrieval
|
||||
* degraded to keyword-only. The third is the one that reads as a mysterious
|
||||
* failure if it is not explained, and it is the one M7's closeout added.
|
||||
*
|
||||
* No threshold is exposed. `0.58` is a measured property of one embedding
|
||||
* model, not a preference, and a slider over it would invite a reader to
|
||||
* recreate the defect M7's review found.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { ConfirmDialog } from '../../../Dialog'
|
||||
|
||||
const CLASSES = [
|
||||
{ value: 'canon', label: 'Canon', hint: 'Authoritative truth for this campaign.' },
|
||||
{ value: 'reference', label: 'Reference', hint: 'Supporting information; does not establish story truth.' },
|
||||
{ value: 'inspiration', label: 'Inspiration', hint: 'Creative and style influence only.' },
|
||||
{
|
||||
value: 'canon',
|
||||
label: 'Canon',
|
||||
hint: 'Authoritative truth for this campaign. The narrator may not contradict it.',
|
||||
},
|
||||
{
|
||||
value: 'reference',
|
||||
label: 'Reference',
|
||||
hint: 'Supporting information. It informs description but does not establish story truth.',
|
||||
},
|
||||
{
|
||||
value: 'inspiration',
|
||||
label: 'Inspiration',
|
||||
hint: 'Creative and style influence only. Never a source of fact.',
|
||||
},
|
||||
]
|
||||
|
||||
const CLASS_LABEL = Object.fromEntries(CLASSES.map((c) => [c.value, c.label]))
|
||||
@@ -34,7 +71,21 @@ function bytes(n) {
|
||||
function when(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString()
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** The three classes, said in full. Shown where the choice is made. */
|
||||
function ClassLegend() {
|
||||
return (
|
||||
<dl className="class-legend">
|
||||
{CLASSES.map((c) => (
|
||||
<div key={c.value}>
|
||||
<dt><span className={`class-tag class-${c.value}`}>{c.label}</span></dt>
|
||||
<dd>{c.hint}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceDetail({ advId, source, onError }) {
|
||||
@@ -42,17 +93,18 @@ function SourceDetail({ advId, source, onError }) {
|
||||
const [chunks, setChunks] = useState(null)
|
||||
const [showText, setShowText] = useState(false)
|
||||
const [showChunks, setShowChunks] = useState(false)
|
||||
// The error reporter reaches this component through a ref rather than
|
||||
// through the effect's dependencies. It arrives as a fresh arrow function on
|
||||
// every render of the Play screen — which re-renders on every keystroke in
|
||||
// the story box — so depending on it would refetch the source, and the
|
||||
// source text, once per character typed. The ref keeps the *current*
|
||||
// reporter without making it a reason to re-run.
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
// The error reporter reaches this component through a ref rather than the
|
||||
// effect's dependencies. It arrives as a fresh arrow on every render of the
|
||||
// Play screen — which re-renders on every keystroke in the story box — so
|
||||
// depending on it would refetch the source once per character typed. Found by
|
||||
// M7's browser run; the ref keeps the *current* reporter without making it a
|
||||
// reason to re-run.
|
||||
const report = useRef(onError)
|
||||
report.current = onError
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false // a slow earlier request must not clobber a newer one
|
||||
let stale = false
|
||||
setDetail(null)
|
||||
api.getKnowledgeSource(advId, source.id)
|
||||
.then((r) => { if (!stale) setDetail(r) })
|
||||
@@ -70,29 +122,31 @@ function SourceDetail({ advId, source, onError }) {
|
||||
|
||||
return (
|
||||
<div className="knowledge-detail">
|
||||
<div className="dim knowledge-facts">
|
||||
<div>File: {source.original_filename}</div>
|
||||
<div>Imported: {when(source.imported_at)}</div>
|
||||
<div>Size: {bytes(source.byte_size)} · {source.media_type}</div>
|
||||
<div>Passages: {source.chunk_count} · embedded {source.embedded_count}</div>
|
||||
<div>Parser v{source.parser_version} · chunking v{source.chunking_version}</div>
|
||||
{/* The content identity, in full rather than shortened: it is here to be
|
||||
compared against another copy, and half a digest compares nothing. */}
|
||||
<div className="knowledge-hash">SHA-256: {source.content_hash}</div>
|
||||
</div>
|
||||
<dl className="knowledge-facts">
|
||||
<div><dt>File</dt><dd>{source.original_filename}</dd></div>
|
||||
<div><dt>Imported</dt><dd>{when(source.imported_at)}</dd></div>
|
||||
<div><dt>Size</dt><dd>{bytes(source.byte_size)}</dd></div>
|
||||
<div>
|
||||
<dt>Passages</dt>
|
||||
<dd>{source.chunk_count} · {source.embedded_count} searchable by meaning</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="knowledge-detail-buttons">
|
||||
<button onClick={() => setShowText((open) => !open)}>
|
||||
{showText ? 'Hide source text' : 'Inspect source text'}
|
||||
<button type="button" onClick={() => setShowText((open) => !open)}>
|
||||
{showText ? 'Hide the text' : 'Read the text'}
|
||||
</button>
|
||||
<button onClick={loadChunks}>
|
||||
{showChunks ? 'Hide passages' : `Inspect passages (${source.chunk_count})`}
|
||||
<button type="button" onClick={loadChunks}>
|
||||
{showChunks ? 'Hide passages' : `Show the ${source.chunk_count} passages`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showText && (
|
||||
detail
|
||||
? <pre className="knowledge-text" data-testid="knowledge-source-text">{detail.content}</pre>
|
||||
: <div className="empty">Loading…</div>
|
||||
: <p className="dim">Loading…</p>
|
||||
)}
|
||||
|
||||
{showChunks && (
|
||||
chunks
|
||||
? chunks.map((chunk) => (
|
||||
@@ -100,111 +154,197 @@ function SourceDetail({ advId, source, onError }) {
|
||||
<div className="dim">
|
||||
passage {chunk.chunk_index + 1}
|
||||
{chunk.heading_path && ` · ${chunk.heading_path}`}
|
||||
{' '}· {chunk.token_count} tok
|
||||
{chunk.embedded ? ` · embedded (${chunk.embedding_model})` : ' · not embedded'}
|
||||
{' '}· {chunk.token_count} tokens
|
||||
{chunk.embedded ? ' · searchable by meaning' : ' · keyword search only'}
|
||||
</div>
|
||||
<pre className="knowledge-text">{chunk.text}</pre>
|
||||
</div>
|
||||
))
|
||||
: <div className="empty">Loading…</div>
|
||||
: <p className="dim">Loading…</p>
|
||||
)}
|
||||
|
||||
{/* §49's advanced half: the content identity and the parser versions. Of
|
||||
interest when comparing two copies of a file, and noise otherwise. */}
|
||||
<details className="knowledge-advanced" open={showAdvanced}
|
||||
onToggle={(e) => setShowAdvanced(e.currentTarget.open)}>
|
||||
<summary>Technical details</summary>
|
||||
<dl className="knowledge-facts">
|
||||
<div><dt>Type</dt><dd>{source.media_type}</dd></div>
|
||||
<div>
|
||||
<dt>Versions</dt>
|
||||
<dd>parser v{source.parser_version} · chunking v{source.chunking_version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/* In full rather than shortened: it is here to be compared against
|
||||
another copy, and half a digest compares nothing. */}
|
||||
<p className="knowledge-hash">SHA-256: {source.content_hash}</p>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceRow({ advId, source, onChange, onDelete, onError }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
function SourceRow({ advId, source, open, onToggle, onChange, onDelete, onError }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`knowledge-row ${source.enabled ? '' : 'disabled'}`}
|
||||
data-source-id={source.id} data-classification={source.classification}>
|
||||
<li
|
||||
className={`knowledge-row ${source.enabled ? '' : 'disabled'}`}
|
||||
data-source-id={source.id}
|
||||
data-classification={source.classification}
|
||||
>
|
||||
<div className="knowledge-head">
|
||||
<button className="knowledge-title" onClick={() => setOpen((o) => !o)}>
|
||||
{open ? '▾' : '▸'} {source.title}
|
||||
<button
|
||||
type="button"
|
||||
className="knowledge-title"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span aria-hidden="true">{open ? '▾' : '▸'}</span> {source.title}
|
||||
</button>
|
||||
{/* The filename beside the title, not only inside the detail. A title
|
||||
defaults to the filename without its extension, so two files that
|
||||
differ only by type would otherwise be indistinguishable in the
|
||||
list — and the filename is the name the reader knows the file by. */}
|
||||
{source.original_filename && source.original_filename !== source.title && (
|
||||
<span className="dim knowledge-filename">{source.original_filename}</span>
|
||||
)}
|
||||
<span className={`knowledge-class knowledge-class-${source.classification}`}>
|
||||
<span className={`class-tag class-${source.classification}`}>
|
||||
{CLASS_LABEL[source.classification]}
|
||||
</span>
|
||||
{!source.enabled && <span className="knowledge-badge">disabled</span>}
|
||||
{!source.enabled && <span className="knowledge-badge">not in use</span>}
|
||||
{source.visibility === 'hidden' && (
|
||||
<span className="knowledge-badge" title="Given to the narrator; the protagonist does not know it">
|
||||
narrator only
|
||||
</span>
|
||||
<span className="hidden-tag">narrator only</span>
|
||||
)}
|
||||
{source.always_include && <span className="knowledge-badge">always included</span>}
|
||||
{source.index_state === 'failed' && (
|
||||
<span className="knowledge-badge failed" title={source.index_detail}>index failed</span>
|
||||
<span className="knowledge-badge failed" title={source.index_detail}>
|
||||
could not be indexed
|
||||
</span>
|
||||
)}
|
||||
{source.embed_state === 'failed' && (
|
||||
<span className="knowledge-badge failed" title={source.embed_detail}>
|
||||
semantic failed — lexical search still works
|
||||
keyword search only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="knowledge-sub">
|
||||
{/* A title defaults to the filename without its extension, so two files
|
||||
differing only by type would otherwise look identical in the list. */}
|
||||
{source.original_filename && source.original_filename !== source.title && (
|
||||
<span>{source.original_filename} · </span>
|
||||
)}
|
||||
<span>imported {when(source.imported_at)}</span>
|
||||
<span> · {source.chunk_count} passage{source.chunk_count === 1 ? '' : 's'}</span>
|
||||
</p>
|
||||
|
||||
<div className="knowledge-controls">
|
||||
<label>
|
||||
Class{' '}
|
||||
<select value={source.classification}
|
||||
onChange={(e) => onChange(source, { classification: e.target.value })}>
|
||||
<span className="sr-only">Classification for {source.title}</span>
|
||||
<select
|
||||
value={source.classification}
|
||||
aria-label={`Classification for ${source.title}`}
|
||||
onChange={(e) => onChange(source, { classification: e.target.value })}
|
||||
>
|
||||
{CLASSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={source.enabled}
|
||||
onChange={(e) => onChange(source, { enabled: e.target.checked })} />
|
||||
{' '}Enabled
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source.enabled}
|
||||
onChange={(e) => onChange(source, { enabled: e.target.checked })}
|
||||
/>
|
||||
{' '}In use
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={source.visibility === 'hidden'}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source.visibility === 'hidden'}
|
||||
onChange={(e) => onChange(source, {
|
||||
visibility: e.target.checked ? 'hidden' : 'normal',
|
||||
})} />
|
||||
})}
|
||||
/>
|
||||
{' '}Narrator only
|
||||
</label>
|
||||
{/* Canon's alone. The server enforces it too — this only stops the
|
||||
control offering something that would be silently ignored. */}
|
||||
{source.classification === 'canon' && (
|
||||
<label>
|
||||
<input type="checkbox" checked={source.always_include}
|
||||
onChange={(e) => onChange(source, { always_include: e.target.checked })} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={source.always_include}
|
||||
onChange={(e) => onChange(source, { always_include: e.target.checked })}
|
||||
/>
|
||||
{' '}Always include
|
||||
</label>
|
||||
)}
|
||||
{confirming ? (
|
||||
<span className="knowledge-confirm">
|
||||
Delete “{source.title}”? It stops being used from now on. Turns that
|
||||
already used it keep their record of what they were given.{' '}
|
||||
<button className="danger" onClick={() => onDelete(source)}>Delete</button>
|
||||
<button onClick={() => setConfirming(false)}>Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<button className="danger" onClick={() => setConfirming(true)}>Delete</button>
|
||||
)}
|
||||
<button type="button" className="danger" onClick={() => setConfirming(true)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<SourceDetail advId={advId} source={source} onError={onError} />
|
||||
{open && <SourceDetail advId={advId} source={source} onError={onError} />}
|
||||
|
||||
{confirming && (
|
||||
<ConfirmDialog
|
||||
title={`Delete “${source.title}”?`}
|
||||
confirmLabel="Delete this source"
|
||||
destructive
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => { setConfirming(false); onDelete(source) }}
|
||||
>
|
||||
<p>
|
||||
It stops being used from now on, and the story will no longer draw
|
||||
on it.
|
||||
</p>
|
||||
<p>
|
||||
Turns that already used it are unchanged — each one keeps its own
|
||||
record of what the narrator was given.
|
||||
</p>
|
||||
<p className="dim">
|
||||
If you only want to stop using it for a while, turn off “In use”
|
||||
instead. That is reversible.
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** §22, in three cases rather than a boolean. */
|
||||
function SemanticStatus({ status }) {
|
||||
if (!status) return null
|
||||
const model = status.embedding_model
|
||||
if (status.semantic_enabled) {
|
||||
return (
|
||||
<p className="dim" data-testid="semantic-status" data-state="on">
|
||||
Searching by keyword and by meaning.
|
||||
{status.pending_embeddings > 0
|
||||
&& ` ${status.pending_embeddings} passage(s) are still being prepared.`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!model) {
|
||||
return (
|
||||
<p className="dim" data-testid="semantic-status" data-state="lexical-only">
|
||||
Searching by keyword. Setting a <em>model for meaning-based search</em> in
|
||||
Settings would let it search by meaning as well — keyword search alone is
|
||||
a supported setup, and it is what finds names and invented terms.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
// Configured but not calibrated: the M7 closeout case.
|
||||
return (
|
||||
<p className="notice-detail" data-testid="semantic-status" data-state="uncalibrated">
|
||||
Searching by keyword only. <code>{model}</code>, the model set for
|
||||
meaning-based search, has not been measured for this in this build, so it
|
||||
is not used to decide what is relevant — borrowing another model’s
|
||||
measurement would let unrelated material through. Keyword search is
|
||||
unaffected.
|
||||
{status.calibrated_models?.length > 0 && (
|
||||
<> Measured so far: {status.calibrated_models.join(', ')}.</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// A stable no-op for the `onError`-less case. Defined once, at module scope,
|
||||
// because an inline `onError || (() => {})` would hand every row a different
|
||||
// function on every render — which is the same identity problem `SourceDetail`
|
||||
// guards against, arriving from the other side.
|
||||
const NO_OP = () => {}
|
||||
|
||||
function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
function KnowledgePanel({ advId, refreshKey, onError, focusSourceId }) {
|
||||
const [sources, setSources] = useState(null)
|
||||
const [status, setStatus] = useState(null)
|
||||
const [file, setFile] = useState(null)
|
||||
@@ -214,6 +354,7 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [duplicate, setDuplicate] = useState(null)
|
||||
const [openId, setOpenId] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.listKnowledge(advId).then(setSources).catch(() => setSources([]))
|
||||
@@ -222,6 +363,11 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
|
||||
useEffect(() => { load() }, [load, refreshKey])
|
||||
|
||||
// Opened from a retrieved passage in the Context panel (§57).
|
||||
useEffect(() => {
|
||||
if (focusSourceId != null) setOpenId(focusSourceId)
|
||||
}, [focusSourceId])
|
||||
|
||||
const doImport = async (allowDuplicate = false) => {
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
@@ -236,9 +382,9 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
setFile(null)
|
||||
setTitle('')
|
||||
setDuplicate(null)
|
||||
// The input is uncontrolled (a file input cannot be controlled), so it is
|
||||
// cleared through the DOM. Without this, re-picking the same file after a
|
||||
// failed import fires no change event and the button does nothing.
|
||||
// A file input cannot be controlled, so it is cleared through the DOM.
|
||||
// Without this, re-picking the same file after a failed import fires no
|
||||
// change event and the button does nothing.
|
||||
const input = document.getElementById('knowledge-file')
|
||||
if (input) input.value = ''
|
||||
setNotice('Imported.')
|
||||
@@ -274,7 +420,7 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
setNotice(
|
||||
`Rebuilt ${out.chunks} passage${out.chunks === 1 ? '' : 's'} across `
|
||||
+ `${out.sources} source${out.sources === 1 ? '' : 's'}`
|
||||
+ (out.semantic ? `, embedded ${out.embedded}.` : '. Semantic index not configured.')
|
||||
+ (out.semantic ? `, ${out.embedded} searchable by meaning.` : '.'),
|
||||
)
|
||||
load()
|
||||
} catch (err) {
|
||||
@@ -285,97 +431,142 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
|
||||
return (
|
||||
<div className="knowledge-panel">
|
||||
<p className="dim">
|
||||
Local <code>.txt</code> and <code>.md</code> files this campaign can draw on.
|
||||
Nothing is uploaded anywhere, no link in a file is ever fetched, and text
|
||||
in a source is never treated as an instruction to the application.
|
||||
Local <code>.txt</code> and <code>.md</code> files this campaign can draw
|
||||
on. Nothing is uploaded anywhere, no link in a file is ever followed, and
|
||||
text in a file is never treated as an instruction to the application.
|
||||
</p>
|
||||
|
||||
<div className="knowledge-import">
|
||||
<input id="knowledge-file" type="file" accept=".txt,.md,text/plain,text/markdown"
|
||||
onChange={(e) => { setFile(e.target.files?.[0] || null); setDuplicate(null) }} />
|
||||
<label>
|
||||
Class{' '}
|
||||
<select value={classification} onChange={(e) => setClassification(e.target.value)}>
|
||||
{CLASSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="dim">{CLASSES.find((c) => c.value === classification)?.hint}</div>
|
||||
<input type="text" placeholder="Title (optional)" value={title}
|
||||
onChange={(e) => setTitle(e.target.value)} />
|
||||
<label>
|
||||
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
|
||||
{' '}Narrator only — the protagonist does not know this
|
||||
</label>
|
||||
<button className="primary" id="knowledge-import" disabled={!file || busy}
|
||||
onClick={() => doImport(false)}>
|
||||
{busy ? 'Working…' : 'Import file'}
|
||||
</button>
|
||||
</div>
|
||||
<details className="knowledge-import-block" open={sources?.length === 0}>
|
||||
<summary>Import a file</summary>
|
||||
<div className="knowledge-import">
|
||||
<div className="field">
|
||||
<label htmlFor="knowledge-file">Choose a file</label>
|
||||
<input
|
||||
id="knowledge-file"
|
||||
type="file"
|
||||
accept=".txt,.md,text/plain,text/markdown"
|
||||
onChange={(e) => { setFile(e.target.files?.[0] || null); setDuplicate(null) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset className="knowledge-class-choice">
|
||||
<legend>How should the story treat it?</legend>
|
||||
{CLASSES.map((c) => (
|
||||
<label key={c.value} className="class-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="knowledge-class"
|
||||
value={c.value}
|
||||
checked={classification === c.value}
|
||||
onChange={() => setClassification(c.value)}
|
||||
/>
|
||||
<span className={`class-tag class-${c.value}`}>{c.label}</span>
|
||||
<span className="class-choice-hint">{c.hint}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="knowledge-title">Title (optional)</label>
|
||||
<input
|
||||
id="knowledge-title"
|
||||
type="text"
|
||||
value={title}
|
||||
placeholder="Defaults to the filename"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="knowledge-hidden-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidden}
|
||||
onChange={(e) => setHidden(e.target.checked)}
|
||||
/>
|
||||
{' '}Narrator only — the narrator knows it, your character does not
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
id="knowledge-import"
|
||||
disabled={!file || busy}
|
||||
onClick={() => doImport(false)}
|
||||
>
|
||||
{busy ? 'Working…' : 'Import file'}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{notice && <div className="knowledge-notice" id="knowledge-notice">{notice}</div>}
|
||||
{duplicate && (
|
||||
<div className="knowledge-notice">
|
||||
Already imported as “{duplicate.title}” ({CLASS_LABEL[duplicate.classification]}).{' '}
|
||||
<button onClick={() => doImport(true)}>Import a second copy anyway</button>
|
||||
<button type="button" onClick={() => doImport(true)}>
|
||||
Import a second copy anyway
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="dim knowledge-status">
|
||||
{status.sources} source{status.sources === 1 ? '' : 's'},
|
||||
{' '}{status.enabled_sources} enabled ·{' '}
|
||||
{status.semantic_enabled
|
||||
? `semantic search on (${status.embedding_model})`
|
||||
+ (status.pending_embeddings
|
||||
? `, ${status.pending_embeddings} passage(s) still to embed`
|
||||
: '')
|
||||
: 'semantic search off — lexical search only, which is a supported setup'}
|
||||
{/* A configured but uncalibrated model is neither "on" nor simply
|
||||
"off": the reader chose a model and it is not being used for
|
||||
retrieval, so the reason has to be visible. */}
|
||||
{status.embedding_model && !status.semantic_calibrated && (
|
||||
<div className="dropped" data-testid="knowledge-uncalibrated">
|
||||
⚠ {status.semantic_note} Calibrated in this build:{' '}
|
||||
{(status.calibrated_models || []).join(', ')}.
|
||||
</div>
|
||||
)}
|
||||
<div className="knowledge-status">
|
||||
<SemanticStatus status={status} />
|
||||
{status.failed_index.length > 0 && (
|
||||
<div className="dropped">
|
||||
⚠ {status.failed_index.length} source(s) failed to index and cannot be retrieved.
|
||||
</div>
|
||||
<p className="notice-detail error">
|
||||
{status.failed_index.length} file(s) could not be indexed and cannot
|
||||
be found by search.
|
||||
</p>
|
||||
)}
|
||||
{status.failed_embedding.length > 0 && (
|
||||
<div className="dropped">
|
||||
⚠ {status.failed_embedding.length} source(s) failed to embed. Lexical
|
||||
retrieval still works for them; Rebuild indexes to retry.
|
||||
</div>
|
||||
<p className="notice-detail">
|
||||
{status.failed_embedding.length} file(s) could not be prepared for
|
||||
meaning-based search. Keyword search still finds them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Sources {sources && `(${sources.length})`}</h3>
|
||||
<span>
|
||||
<button onClick={reindex} disabled={busy} title="Rebuild passages and search indexes from the stored text. Story history is untouched.">
|
||||
Rebuild indexes
|
||||
</button>
|
||||
<button onClick={load} style={{ marginLeft: 6 }}>Refresh</button>
|
||||
</span>
|
||||
<div className="knowledge-list-head">
|
||||
<h3>In this campaign {sources && `(${sources.length})`}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reindex}
|
||||
disabled={busy}
|
||||
title="Rebuild passages and search indexes from the stored text. Your story is untouched."
|
||||
>
|
||||
Rebuild search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!sources && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
|
||||
{!sources && <p className="dim">Loading…</p>}
|
||||
{sources && sources.length === 0 && (
|
||||
<div className="empty" style={{ padding: '12px 0' }}>
|
||||
Nothing imported yet. Add a setting bible, character notes, a research
|
||||
file or a passage you want the prose to feel like.
|
||||
<div className="knowledge-empty">
|
||||
<p>Nothing imported yet.</p>
|
||||
<p className="dim">
|
||||
A setting bible, character notes, a research file, or a passage whose
|
||||
style you want the prose to feel like.
|
||||
</p>
|
||||
<ClassLegend />
|
||||
</div>
|
||||
)}
|
||||
{sources?.map((source) => (
|
||||
<SourceRow key={source.id} advId={advId} source={source}
|
||||
onChange={change} onDelete={remove} onError={onError || NO_OP} />
|
||||
))}
|
||||
{sources?.length > 0 && (
|
||||
<ul className="knowledge-list">
|
||||
{sources.map((source) => (
|
||||
<SourceRow
|
||||
key={source.id}
|
||||
advId={advId}
|
||||
source={source}
|
||||
open={openId === source.id}
|
||||
onToggle={() => setOpenId(openId === source.id ? null : source.id)}
|
||||
onChange={change}
|
||||
onDelete={remove}
|
||||
onError={onError || NO_OP}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { KnowledgePanel }
|
||||
export { KnowledgePanel, ClassLegend, SemanticStatus, CLASSES, CLASS_LABEL }
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// The memory bank panel: the entries retrieval can pull into a prompt.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { AutoTextarea } from '../../../components'
|
||||
|
||||
function MemoryRow({ memory, onChange, onDelete }) {
|
||||
const [editText, setEditText] = useState(null)
|
||||
|
||||
const save = () => {
|
||||
const text = editText.trim()
|
||||
setEditText(null)
|
||||
if (text && text !== memory.text) onChange({ text })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
|
||||
{editText !== null ? (
|
||||
<div className="action-edit" style={{ margin: 0 }}>
|
||||
<AutoTextarea autoFocus value={editText} rows={3}
|
||||
onChange={(e) => setEditText(e.target.value)} />
|
||||
<div className="edit-buttons">
|
||||
<button className="primary" onClick={save}>Save</button>
|
||||
<button onClick={() => setEditText(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="memory-text">{memory.text}</div>
|
||||
<div className="memory-meta">
|
||||
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
|
||||
{memory.forgotten && <span className="memory-badge">forgotten</span>}
|
||||
{!memory.embedded && !memory.forgotten && (
|
||||
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
|
||||
)}
|
||||
{memory.source_start != null && (
|
||||
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
|
||||
)}
|
||||
<span className="dim">used {memory.use_count}×</span>
|
||||
<span className="action-tools">
|
||||
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
|
||||
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
|
||||
{memory.forgotten && (
|
||||
<button title="Restore this memory"
|
||||
onClick={() => onChange({ forgotten: false })}>↩</button>
|
||||
)}
|
||||
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
|
||||
<button title="Delete" onClick={onDelete}>✕</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
|
||||
const [memories, setMemories] = useState(null)
|
||||
const [newText, setNewText] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
|
||||
}, [adventure.id])
|
||||
|
||||
// Summarization runs in the background after a turn, so also refresh shortly after.
|
||||
useEffect(() => {
|
||||
load()
|
||||
const timer = setTimeout(load, 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [load, refreshKey])
|
||||
|
||||
const setFlag = async (field, value) => {
|
||||
setAdventure({ ...adventure, [field]: value })
|
||||
await api.updateAdventure(adventure.id, { [field]: value })
|
||||
}
|
||||
|
||||
const change = async (memory, data) => {
|
||||
const updated = await api.updateMemory(adventure.id, memory.id, data)
|
||||
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
|
||||
}
|
||||
|
||||
const remove = async (memory) => {
|
||||
await api.deleteMemory(adventure.id, memory.id)
|
||||
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
|
||||
}
|
||||
|
||||
const add = async () => {
|
||||
const text = newText.trim()
|
||||
if (!text) return
|
||||
setNewText('')
|
||||
const memory = await api.createMemory(adventure.id, text)
|
||||
setMemories((prev) => [...prev, memory])
|
||||
}
|
||||
|
||||
const active = memories?.filter((m) => !m.forgotten) ?? []
|
||||
const forgotten = memories?.filter((m) => m.forgotten) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="script-attach">
|
||||
<input type="checkbox" checked={adventure.auto_summarize}
|
||||
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
|
||||
<span>Auto-summarization</span>
|
||||
<span className="dim">memories every 6 actions, Story Summary every 15</span>
|
||||
</label>
|
||||
<label className="script-attach">
|
||||
<input type="checkbox" checked={adventure.memory_bank_enabled}
|
||||
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
|
||||
<span>Memory Bank</span>
|
||||
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
|
||||
</label>
|
||||
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
|
||||
<button onClick={load}>Refresh</button>
|
||||
</div>
|
||||
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
|
||||
{memories && active.length === 0 && (
|
||||
<div className="empty" style={{ padding: '12px 0' }}>
|
||||
No memories yet. They are generated automatically as you play (from action 12
|
||||
onward), or add one below.
|
||||
</div>
|
||||
)}
|
||||
{active.map((m) => (
|
||||
<MemoryRow key={m.id} memory={m}
|
||||
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
|
||||
))}
|
||||
|
||||
<div className="input-bar" style={{ marginTop: 10 }}>
|
||||
<input type="text" value={newText} placeholder="Add a memory manually…"
|
||||
onChange={(e) => setNewText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
|
||||
<button onClick={add}>+ Add</button>
|
||||
</div>
|
||||
|
||||
{forgotten.length > 0 && (
|
||||
<>
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
|
||||
</div>
|
||||
{forgotten.map((m) => (
|
||||
<MemoryRow key={m.id} memory={m}
|
||||
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { MemoryPanel }
|
||||
@@ -1,156 +0,0 @@
|
||||
// The plot panel: the adventure's own copy of the scenario text and cards.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../../../components'
|
||||
import { useDebouncedSave } from '../../../hooks/useDebouncedSave'
|
||||
import { RefreshModal } from '../RefreshModal'
|
||||
|
||||
function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
|
||||
const toast = useToast()
|
||||
const [plan, setPlan] = useState(null) // non-null while the modal is open
|
||||
const [planning, setPlanning] = useState(false)
|
||||
const debounceSave = useDebouncedSave()
|
||||
|
||||
const setField = (field, value) => {
|
||||
setAdventure({ ...adventure, [field]: value })
|
||||
debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value }))
|
||||
}
|
||||
|
||||
const addCard = async () => {
|
||||
const card = await api.createStoryCard({ adventure_id: adventure.id })
|
||||
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] })
|
||||
}
|
||||
|
||||
const updateCard = (card) => {
|
||||
setAdventure({
|
||||
...adventure,
|
||||
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||
})
|
||||
debounceSave(`card-${card.id}`, () => {
|
||||
api.updateStoryCard(card.id, {
|
||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteCard = async (cardId) => {
|
||||
await api.deleteStoryCard(cardId)
|
||||
setAdventure({
|
||||
...adventure,
|
||||
story_cards: adventure.story_cards.filter((c) => c.id !== cardId),
|
||||
})
|
||||
}
|
||||
|
||||
const exportCards = async () => {
|
||||
const cards = await api.exportStoryCards({ adventure_id: adventure.id })
|
||||
downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`)
|
||||
}
|
||||
|
||||
const importCards = async () => {
|
||||
try {
|
||||
const parsed = await pickJSONFile()
|
||||
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
|
||||
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
|
||||
const created = await api.importStoryCards({ adventure_id: adventure.id, cards })
|
||||
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] })
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the server what a refresh would do, then let the player confirm it.
|
||||
const openRefresh = async () => {
|
||||
setPlanning(true)
|
||||
try {
|
||||
setPlan(await api.previewRefresh(adventure.id))
|
||||
} catch (err) {
|
||||
// 404 = the scenario was deleted or unshared; there's nothing to sync to.
|
||||
toast(err.message, 'error')
|
||||
} finally {
|
||||
setPlanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyRefresh = async (placeholders) => {
|
||||
try {
|
||||
const updated = await api.refreshFromScenario(adventure.id, placeholders)
|
||||
setAdventure(updated)
|
||||
setPlan(null)
|
||||
onWorldStateChanged?.()
|
||||
toast('Updated from scenario.')
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{adventure.scenario_id != null && (
|
||||
<div className="plot-source">
|
||||
<span className="dim">
|
||||
Copied from its scenario when the adventure began; later scenario edits don't
|
||||
reach it on their own.
|
||||
</span>
|
||||
<button className="linklike" onClick={openRefresh} disabled={planning}
|
||||
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
|
||||
{planning ? 'Checking…' : '⟳ Update from scenario'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
|
||||
)}
|
||||
|
||||
{/* Phase 18. The persona sits above the plot text because it is the one
|
||||
section that describes the player rather than the world, and because
|
||||
it is the first thing a reader looks for when they want to rename
|
||||
their character. It is edited only here: the world-state drawer shows
|
||||
the name as a heading but does not offer a second way to change it. */}
|
||||
<div className="persona-block">
|
||||
<div className="persona-row">
|
||||
<Field label="Character Name" value={adventure.persona_name}
|
||||
onChange={(v) => setField('persona_name', v)}
|
||||
maxLength={80} placeholder="Kaelen" />
|
||||
<Field label="Pronouns" value={adventure.persona_pronouns}
|
||||
onChange={(v) => setField('persona_pronouns', v)}
|
||||
maxLength={40} placeholder="they/them" />
|
||||
</div>
|
||||
<Field label="Character Description" value={adventure.persona_desc}
|
||||
onChange={(v) => setField('persona_desc', v)} textarea rows={3}
|
||||
placeholder="Who you are playing as. The AI never rewrites this — only you can." />
|
||||
</div>
|
||||
|
||||
<Field label="Plot Essentials (Memory)" value={adventure.memory}
|
||||
onChange={(v) => setField('memory', v)} textarea
|
||||
placeholder="Key facts the AI should always remember." />
|
||||
<Field label="Author's Note" value={adventure.authors_note}
|
||||
onChange={(v) => setField('authors_note', v)} textarea rows={2}
|
||||
placeholder="Style/theme guidance, injected near the end of context." />
|
||||
<Field label="AI Instructions" value={adventure.ai_instructions}
|
||||
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
|
||||
placeholder="Behavioral guidance for the model." />
|
||||
<Field label="Story Summary" value={adventure.story_summary}
|
||||
onChange={(v) => setField('story_summary', v)} textarea
|
||||
placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />
|
||||
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Story Cards</h3>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={exportCards} disabled={adventure.story_cards.length === 0}>Export</button>
|
||||
<button onClick={importCards}>Import</button>
|
||||
<button onClick={addCard}>+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
{adventure.story_cards.length === 0 && (
|
||||
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
|
||||
)}
|
||||
{adventure.story_cards.map((card) => (
|
||||
<StoryCardRow key={card.id} card={card}
|
||||
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { PlotPanel }
|
||||
@@ -0,0 +1,490 @@
|
||||
/* The panels: knowledge classification and status, the context inspector's
|
||||
* sections, Save Point dialogs, and the failure presentation.
|
||||
*
|
||||
* §30's remaining named list. These are rendering tests against the shapes the
|
||||
* real API returns — each fixture below is the shape observed from a running
|
||||
* server, not one invented to match the component.
|
||||
*/
|
||||
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../../../api'
|
||||
import { classifyError, KIND } from '../../../errors'
|
||||
import { FailureNotice } from '../FailureNotice'
|
||||
import { ContextPanel } from './ContextPanel'
|
||||
import { CampaignSettingsPanel } from './CampaignSettingsPanel'
|
||||
import { KnowledgePanel, SemanticStatus } from './KnowledgePanel'
|
||||
import { SavePointPanel } from './SavePointPanel'
|
||||
import { renderWith } from '../../../test/helpers'
|
||||
|
||||
const SOURCE = {
|
||||
id: 7,
|
||||
title: 'canon',
|
||||
original_filename: 'canon.md',
|
||||
classification: 'canon',
|
||||
enabled: true,
|
||||
visibility: 'normal',
|
||||
always_include: false,
|
||||
imported_at: '2026-09-06T10:00:00',
|
||||
byte_size: 2048,
|
||||
media_type: 'text/markdown',
|
||||
chunk_count: 4,
|
||||
embedded_count: 4,
|
||||
parser_version: 1,
|
||||
chunking_version: 1,
|
||||
content_hash: 'a'.repeat(64),
|
||||
index_state: 'ok',
|
||||
embed_state: 'ok',
|
||||
index_detail: '',
|
||||
embed_detail: '',
|
||||
}
|
||||
|
||||
const STATUS = {
|
||||
sources: 1,
|
||||
enabled_sources: 1,
|
||||
semantic_enabled: true,
|
||||
semantic_calibrated: true,
|
||||
embedding_model: 'nomic-embed-text',
|
||||
calibrated_models: ['nomic-embed-text'],
|
||||
semantic_note: '',
|
||||
pending_embeddings: 0,
|
||||
failed_index: [],
|
||||
failed_embedding: [],
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('knowledge classification (§48)', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'listKnowledge').mockResolvedValue([SOURCE])
|
||||
vi.spyOn(api, 'getKnowledgeStatus').mockResolvedValue(STATUS)
|
||||
})
|
||||
|
||||
it('shows each source with its class, filename and import date', async () => {
|
||||
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
|
||||
const row = document.querySelector('[data-source-id="7"]')
|
||||
expect(row).toHaveAttribute('data-classification', 'canon')
|
||||
// The row carries "Canon" twice — as the class tag and as the selected
|
||||
// option in the reclassify control — so this asks for the tag specifically.
|
||||
expect(row.querySelector('.class-tag')).toHaveTextContent('Canon')
|
||||
expect(within(row).getByText(/canon\.md/)).toBeInTheDocument()
|
||||
expect(within(row).getByText(/imported/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains what each class means rather than relying on an icon', async () => {
|
||||
vi.spyOn(api, 'listKnowledge').mockResolvedValue([])
|
||||
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
|
||||
// Present on the import form and again in the empty-state legend; the
|
||||
// assertion is that a reader meets the explanation, not that it is unique.
|
||||
expect(screen.getAllByText(/Authoritative truth for this campaign/).length)
|
||||
.toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/does not establish story truth/).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Creative and style influence only/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('marks a disabled source as not in use', async () => {
|
||||
vi.spyOn(api, 'listKnowledge').mockResolvedValue([{ ...SOURCE, enabled: false }])
|
||||
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByText('not in use')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a narrator-only source', async () => {
|
||||
vi.spyOn(api, 'listKnowledge').mockResolvedValue([{ ...SOURCE, visibility: 'hidden' }])
|
||||
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByText('narrator only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains what deleting does and does not do before doing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const remove = vi.spyOn(api, 'deleteKnowledgeSource').mockResolvedValue(null)
|
||||
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText(/Turns that already used it are unchanged/))
|
||||
.toBeInTheDocument()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Delete this source' }))
|
||||
expect(remove).toHaveBeenCalledWith('1', 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('semantic status (§22)', () => {
|
||||
it('says both kinds of search are on when they are', async () => {
|
||||
await renderWith(<SemanticStatus status={STATUS} />)
|
||||
const el = screen.getByTestId('semantic-status')
|
||||
expect(el).toHaveAttribute('data-state', 'on')
|
||||
expect(el).toHaveTextContent(/keyword and by meaning/)
|
||||
})
|
||||
|
||||
it('presents no embedding model as a supported setup, not a failure', async () => {
|
||||
await renderWith(<SemanticStatus status={{
|
||||
...STATUS, semantic_enabled: false, embedding_model: '',
|
||||
}} />)
|
||||
const el = screen.getByTestId('semantic-status')
|
||||
expect(el).toHaveAttribute('data-state', 'lexical-only')
|
||||
expect(el).toHaveTextContent(/supported setup/)
|
||||
// Named as the Settings field is named, not as the subsystem is named:
|
||||
// pointing a reader at an "embedding model" they will not find is worse
|
||||
// than saying nothing (§9 terminology audit).
|
||||
expect(el).toHaveTextContent(/model for meaning-based search/i)
|
||||
expect(el.textContent).not.toMatch(/\bembedding\b/i)
|
||||
})
|
||||
|
||||
it('explains an uncalibrated model instead of showing a mysterious failure', async () => {
|
||||
await renderWith(<SemanticStatus status={{
|
||||
...STATUS,
|
||||
semantic_enabled: false,
|
||||
semantic_calibrated: false,
|
||||
embedding_model: 'mxbai-embed-large',
|
||||
}} />)
|
||||
const el = screen.getByTestId('semantic-status')
|
||||
expect(el).toHaveAttribute('data-state', 'uncalibrated')
|
||||
expect(el).toHaveTextContent(/has not been measured/)
|
||||
expect(el).toHaveTextContent(/Keyword search is unaffected/)
|
||||
expect(el).toHaveTextContent(/meaning-based search/i)
|
||||
expect(el.textContent).not.toMatch(/\bembedding\b/i)
|
||||
})
|
||||
|
||||
it('exposes no threshold and no tuning control', async () => {
|
||||
const { container } = await renderWith(<SemanticStatus status={{
|
||||
...STATUS, semantic_enabled: false, semantic_calibrated: false,
|
||||
embedding_model: 'mxbai-embed-large',
|
||||
}} />)
|
||||
expect(container.textContent).not.toContain('0.58')
|
||||
expect(container.querySelector('input[type="range"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const REPORT = {
|
||||
tokens: { total: 1200, budget: 8000, output_reserve: 400 },
|
||||
history: { included: 4, total: 4, oldest_truncated: false },
|
||||
sections: [
|
||||
{ label: 'narrator', text: 'You are the narrator.', tokens: 60 },
|
||||
{ label: 'imported_canon', text: 'The abbey lies north.', tokens: 40 },
|
||||
],
|
||||
cards: [],
|
||||
settings: { model: 'qwen2.5:3b-instruct', temperature: 0.2, max_output_tokens: 400 },
|
||||
derived: [],
|
||||
memories: null,
|
||||
summary: null,
|
||||
knowledge: {
|
||||
generated: 4,
|
||||
rejected: 3,
|
||||
spent: 40,
|
||||
budget: 800,
|
||||
terms: ['abbey', 'crypt'],
|
||||
semantic_note: '',
|
||||
used: [{
|
||||
chunk_id: 11,
|
||||
source_id: 7,
|
||||
filename: 'canon.md',
|
||||
title: 'canon',
|
||||
classification: 'canon',
|
||||
visibility: 'normal',
|
||||
heading_path: 'Old Abbey',
|
||||
chunk_index: 0,
|
||||
mode: 'hybrid',
|
||||
cosine: 0.71,
|
||||
score: 0.8,
|
||||
prompt_tokens: 40,
|
||||
always_include: false,
|
||||
text: 'The Old Abbey lies five miles north of Westhaven.',
|
||||
}],
|
||||
suppressed: [],
|
||||
dropped: [],
|
||||
},
|
||||
}
|
||||
|
||||
describe('context inspector (§23, §55)', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
|
||||
})
|
||||
|
||||
it('renders the readable sections, not a raw prompt, by default', async () => {
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByTestId('ctx-tokens')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ctx-knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ctx-memories')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ctx-summary')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ctx-settings')).toBeInTheDocument()
|
||||
// The exact prompt is present but closed.
|
||||
expect(screen.getByTestId('ctx-raw')).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('shows the token budget with the reply reserve and the model limit', async () => {
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
const tokens = screen.getByTestId('ctx-tokens')
|
||||
expect(tokens).toHaveTextContent('1,200')
|
||||
expect(tokens).toHaveTextContent('400')
|
||||
expect(tokens).toHaveTextContent('8,000')
|
||||
})
|
||||
|
||||
it('shows a retrieved passage with its source, class, heading and score', async () => {
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
const row = document.querySelector('[data-chunk-id="11"]')
|
||||
expect(row).toHaveAttribute('data-classification', 'canon')
|
||||
expect(within(row).getByText('Canon')).toBeInTheDocument()
|
||||
expect(within(row).getByText('Old Abbey')).toBeInTheDocument()
|
||||
expect(within(row).getByText(/0\.71/)).toBeInTheDocument()
|
||||
expect(within(row).getByText(/The Old Abbey lies five miles north/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the source it came from when the filename is clicked (§57)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenSource = vi.fn()
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" onOpenSource={onOpenSource} />)
|
||||
await user.click(screen.getByRole('button', { name: 'canon.md' }))
|
||||
expect(onOpenSource).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('says plainly when nothing was relevant enough', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
|
||||
...REPORT,
|
||||
knowledge: { ...REPORT.knowledge, used: [], rejected: 4 },
|
||||
})
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByTestId('ctx-knowledge-none'))
|
||||
.toHaveTextContent(/none were close enough to this scene/)
|
||||
})
|
||||
|
||||
it('surfaces failing background work', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
|
||||
...REPORT,
|
||||
derived: [{ kind: 'summary', status: 'failed', failures: 3, detail: 'timeout' }],
|
||||
})
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByTestId('ctx-derived-failing')).toHaveTextContent(/summary/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('narrator-only passages (§19)', () => {
|
||||
const hiddenReport = {
|
||||
...REPORT,
|
||||
knowledge: {
|
||||
...REPORT.knowledge,
|
||||
used: [{
|
||||
...REPORT.knowledge.used[0],
|
||||
visibility: 'hidden',
|
||||
text: 'The silver key opens the abbey crypt.',
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
it('withholds the text until the reader asks for it', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.queryByText(/opens the abbey crypt/)).toBeNull()
|
||||
expect(screen.getByText(/Hidden — this passage is narrator-only/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns what turning it on will reveal', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByTestId('reveal-hidden'))
|
||||
.toHaveTextContent(/reveal secrets you marked for the narrator alone/)
|
||||
})
|
||||
|
||||
it('reveals it only on an explicit choice', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
await user.click(within(screen.getByTestId('reveal-hidden')).getByRole('checkbox'))
|
||||
expect(screen.getByText(/opens the abbey crypt/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('withholds the assembled prompt too, not just the passage row', async () => {
|
||||
// The passage rows hide the secret one by one; the assembled prompt at the
|
||||
// bottom contains the same text verbatim. A closed <details> still holds
|
||||
// its contents in the DOM, where find-in-page reaches them — so hiding it
|
||||
// visually would be the appearance of a guard rather than a guard. Found by
|
||||
// the sentinel browser test, which checked innerHTML rather than innerText.
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
|
||||
...hiddenReport,
|
||||
sections: [
|
||||
{ label: 'narrator', text: 'You are the narrator.', tokens: 60 },
|
||||
{ label: 'imported_canon', text: 'The silver key opens the abbey crypt.', tokens: 40 },
|
||||
],
|
||||
})
|
||||
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.getByTestId('ctx-raw-withheld')).toBeInTheDocument()
|
||||
// Not present anywhere in the markup, not merely invisible.
|
||||
expect(container.innerHTML).not.toContain('opens the abbey crypt')
|
||||
})
|
||||
|
||||
it('shows the assembled prompt once the reader reveals', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
|
||||
...hiddenReport,
|
||||
sections: [
|
||||
{ label: 'imported_canon', text: 'The silver key opens the abbey crypt.', tokens: 40 },
|
||||
],
|
||||
})
|
||||
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
await user.click(within(screen.getByTestId('reveal-hidden')).getByRole('checkbox'))
|
||||
expect(screen.queryByTestId('ctx-raw-withheld')).toBeNull()
|
||||
expect(container.innerHTML).toContain('opens the abbey crypt')
|
||||
})
|
||||
|
||||
it('leaves the assembled prompt readable when nothing is narrator-only', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
|
||||
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.queryByTestId('ctx-raw-withheld')).toBeNull()
|
||||
expect(container.innerHTML).toContain('The abbey lies north')
|
||||
})
|
||||
|
||||
it('offers no reveal control when nothing narrator-only was retrieved', async () => {
|
||||
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
|
||||
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
|
||||
expect(screen.queryByTestId('reveal-hidden')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save Points (§26, §27)', () => {
|
||||
const POINT = {
|
||||
id: 3, name: 'Before the abbey', note: '', turn: 12, on_path: true, resolved: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'listCheckpoints').mockResolvedValue([POINT])
|
||||
})
|
||||
|
||||
it('says that later story is kept before restoring', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Restore' }))
|
||||
expect(screen.getByText(/Everything you wrote after it is kept/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says that deleting a Save Point deletes no story', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(screen.getByText(/does not delete any of the story/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts in moments, not turns (§25)', async () => {
|
||||
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
|
||||
expect(screen.getByText(/Moment 12/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses no branch vocabulary', async () => {
|
||||
const { container } = await renderWith(
|
||||
<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />,
|
||||
)
|
||||
const text = container.textContent.toLowerCase()
|
||||
for (const word of ['branch', 'fork', 'node', 'head']) {
|
||||
expect(text).not.toContain(word)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure presentation (§12, §71)', () => {
|
||||
it('tells the five kinds apart', () => {
|
||||
expect(classifyError('No model configured — set one in Settings.').kind).toBe(KIND.MODEL)
|
||||
expect(classifyError('Could not connect to http://x — is the AI server running?').kind)
|
||||
.toBe(KIND.MODEL)
|
||||
expect(classifyError('The AI endpoint timed out.').kind).toBe(KIND.MODEL)
|
||||
expect(classifyError('state validation failed: unknown entity').kind).toBe(KIND.STATE)
|
||||
expect(classifyError('knowledge index rebuild failed').kind).toBe(KIND.KNOWLEDGE)
|
||||
expect(classifyError('database is locked').kind).toBe(KIND.SERVER)
|
||||
})
|
||||
|
||||
it('never reduces an unrecognised failure to "something went wrong"', () => {
|
||||
const f = classifyError('the model produced eleven hats')
|
||||
expect(f.kind).toBe(KIND.GENERATION)
|
||||
// The server's own words survive, and Retry is still offered.
|
||||
expect(f.detail).toBe('the model produced eleven hats')
|
||||
expect(f.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('reassures that nothing typed was lost (A05)', async () => {
|
||||
await renderWith(
|
||||
<FailureNotice
|
||||
failure={classifyError('The AI endpoint timed out.')}
|
||||
partial={null}
|
||||
onRetry={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('failure')).toHaveAttribute('data-kind', 'model')
|
||||
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels partial prose as not kept, rather than showing it as story', async () => {
|
||||
await renderWith(
|
||||
<FailureNotice
|
||||
failure={classifyError('boom')}
|
||||
partial="The tavern door swung"
|
||||
onRetry={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the raw detail available but folded away (§72)', async () => {
|
||||
await renderWith(
|
||||
<FailureNotice
|
||||
failure={classifyError('HTTP 500: kaboom')}
|
||||
partial={null}
|
||||
onRetry={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const details = screen.getByText('Show technical details').closest('details')
|
||||
expect(details).not.toHaveAttribute('open')
|
||||
expect(details).toHaveTextContent('HTTP 500: kaboom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('campaign canon after play (§6)', () => {
|
||||
const campaign = {
|
||||
id: 1,
|
||||
title: 'Continuity Test',
|
||||
ai_instructions: '',
|
||||
canon_rules: ['Resurrection is impossible.'],
|
||||
persona_name: 'Aldric',
|
||||
persona_desc: '',
|
||||
}
|
||||
|
||||
it('says nothing extra while the campaign has no story yet', async () => {
|
||||
await renderWith(
|
||||
<CampaignSettingsPanel
|
||||
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={0} />,
|
||||
)
|
||||
expect(screen.queryByTestId('canon-history-warning')).toBeNull()
|
||||
})
|
||||
|
||||
it('warns once a story exists, and says what the edit does not reach', async () => {
|
||||
// Canon is the highest authority in a campaign. Changing it after play is a
|
||||
// configuration change, not a retroactive one, and the reader has to be
|
||||
// told which — otherwise a post-play edit passes silently.
|
||||
await renderWith(
|
||||
<CampaignSettingsPanel
|
||||
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={12} />,
|
||||
)
|
||||
const warning = screen.getByTestId('canon-history-warning')
|
||||
expect(warning).toHaveTextContent('12 moments')
|
||||
expect(warning).toHaveTextContent(/from here\s*on/)
|
||||
expect(warning).toHaveTextContent(/already written stays exactly as it is/)
|
||||
// And it points at where the per-turn record can be seen.
|
||||
expect(warning).toHaveTextContent(/Inspect context/)
|
||||
})
|
||||
|
||||
it('edits the sentence list, not the stored document', async () => {
|
||||
const user = userEvent.setup()
|
||||
const update = vi.spyOn(api, 'updateAdventure').mockResolvedValue(campaign)
|
||||
await renderWith(
|
||||
<CampaignSettingsPanel
|
||||
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={3} />,
|
||||
)
|
||||
const box = screen.getByLabelText('Canon')
|
||||
await user.clear(box)
|
||||
await user.type(box, 'One rule.{Enter}Another rule.')
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||
expect(update).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
canon_rules: ['One rule.', 'Another rule.'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,395 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
import { useDebouncedSave } from '../hooks/useDebouncedSave'
|
||||
import ArtPicker from '../ArtPicker'
|
||||
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
|
||||
|
||||
export default function ScenarioEditor() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [scenario, setScenario] = useState(null)
|
||||
const [status, setStatus] = useState('')
|
||||
// Raw text buffer for the stat_schema JSON editor + a live parse error.
|
||||
const [schemaText, setSchemaText] = useState('')
|
||||
const [schemaError, setSchemaError] = useState('')
|
||||
// Last successfully-parsed schema (drives the form editor + preview).
|
||||
const [parsedSchema, setParsedSchema] = useState(null)
|
||||
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
|
||||
const toast = useToast()
|
||||
const debounceSave = useDebouncedSave()
|
||||
|
||||
useEffect(() => {
|
||||
api.getScenario(id).then((s) => {
|
||||
setScenario(s)
|
||||
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
|
||||
setParsedSchema(s.stat_schema || null)
|
||||
}).catch(() => navigate('/scenarios'))
|
||||
}, [id, navigate])
|
||||
|
||||
const setField = (field, value) => {
|
||||
const next = { ...scenario, [field]: value }
|
||||
setScenario(next)
|
||||
debounceSave(field, async () => {
|
||||
await api.updateScenario(id, { [field]: value })
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
// Several fields at once, in one PATCH — the art picker sets `image` and
|
||||
// `icon` together (choosing one clears the other, and they must not race).
|
||||
const setFields = (patch) => {
|
||||
setScenario((prev) => ({ ...prev, ...patch }))
|
||||
debounceSave('art', async () => {
|
||||
await api.updateScenario(id, patch)
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
// JSON view: typing only updates the text buffer; nothing is parsed or saved
|
||||
// until the user clicks Save (so a half-typed edit doesn't spam errors).
|
||||
const onJsonText = (text) => {
|
||||
setSchemaText(text)
|
||||
if (schemaError) setSchemaError('')
|
||||
}
|
||||
|
||||
// Parse the JSON box on demand: apply + save if valid, else show the error.
|
||||
const commitJson = () => {
|
||||
const trimmed = schemaText.trim()
|
||||
if (!trimmed) {
|
||||
setSchemaError('')
|
||||
setParsedSchema(null)
|
||||
saveSchema(null)
|
||||
return
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch (err) {
|
||||
setSchemaError(`Invalid JSON: ${err.message}`)
|
||||
return
|
||||
}
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
setSchemaError('The schema must be a JSON object (e.g. { "player": { … } }).')
|
||||
return
|
||||
}
|
||||
setSchemaError('')
|
||||
setParsedSchema(parsed)
|
||||
setSchemaText(JSON.stringify(parsed, null, 2)) // normalize / pretty-print
|
||||
saveSchema(parsed)
|
||||
}
|
||||
|
||||
const saveSchema = async (parsed) => {
|
||||
await api.updateScenario(id, { stat_schema: parsed })
|
||||
setScenario((s) => ({ ...s, stat_schema: parsed }))
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
}
|
||||
|
||||
// Edits from the form editor: keep the JSON text in sync and save. An empty
|
||||
// schema clears the RPG layer (null).
|
||||
const applySchema = (next) => {
|
||||
const cleaned = next && Object.keys(next).length ? next : null
|
||||
setParsedSchema(cleaned)
|
||||
setSchemaText(cleaned ? JSON.stringify(cleaned, null, 2) : '')
|
||||
setSchemaError('')
|
||||
debounceSave('stat_schema', () => saveSchema(cleaned))
|
||||
}
|
||||
|
||||
// NPCs live in the same stat_schema, but are edited in their own page
|
||||
// section — so this is just the `npcs` slice of the same save path.
|
||||
const setNpcs = (npcs) => {
|
||||
const next = { ...(parsedSchema || {}) }
|
||||
if (npcs && Object.keys(npcs).length) next.npcs = npcs
|
||||
else delete next.npcs
|
||||
applySchema(next)
|
||||
}
|
||||
|
||||
const addCard = async () => {
|
||||
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
||||
}
|
||||
|
||||
const updateCard = (card) => {
|
||||
setScenario({
|
||||
...scenario,
|
||||
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||
})
|
||||
debounceSave(`card-${card.id}`, () => {
|
||||
api.updateStoryCard(card.id, {
|
||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteCard = async (cardId) => {
|
||||
await api.deleteStoryCard(cardId)
|
||||
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
|
||||
}
|
||||
|
||||
const exportCards = async () => {
|
||||
const cards = await api.exportStoryCards({ scenario_id: id })
|
||||
downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`)
|
||||
}
|
||||
|
||||
const importCards = async () => {
|
||||
try {
|
||||
const parsed = await pickJSONFile()
|
||||
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
|
||||
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
|
||||
const created = await api.importStoryCards({ scenario_id: Number(id), cards })
|
||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const exportScenario = async () => {
|
||||
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
|
||||
}
|
||||
|
||||
const deleteScenario = async () => {
|
||||
if (!confirm('Delete this scenario permanently?')) return
|
||||
await api.deleteScenario(id)
|
||||
navigate('/scenarios')
|
||||
}
|
||||
|
||||
const play = async () => {
|
||||
const adv = await api.createAdventure({ scenario_id: Number(id) })
|
||||
navigate(`/play/${adv.id}`)
|
||||
}
|
||||
|
||||
if (!scenario) return null
|
||||
// Shared demo scenarios (Phase 8) are visible to everyone but owned by no
|
||||
// one; the backend rejects edits, so present them read-only.
|
||||
const readOnly = !!scenario.is_public
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}</h1>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
|
||||
<button onClick={exportScenario}>Export</button>
|
||||
{!readOnly && <button className="danger" onClick={deleteScenario}>Delete</button>}
|
||||
<button className="primary" onClick={play}>Play</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<div className="demo-banner">
|
||||
This is a shared demo scenario — it can’t be edited, but you can hit
|
||||
<strong> Play</strong> to start your own adventure from it, or <strong>Export</strong> and
|
||||
re-import it as your own copy.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset disabled={readOnly} style={{ border: 'none', padding: 0, margin: 0, minWidth: 0 }}>
|
||||
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
|
||||
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
|
||||
textarea placeholder="Shown in the scenario list; not sent to the AI." />
|
||||
<ArtPicker
|
||||
title={scenario.title}
|
||||
image={scenario.image}
|
||||
icon={scenario.icon}
|
||||
disabled={readOnly}
|
||||
onChange={setFields}
|
||||
/>
|
||||
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
|
||||
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
|
||||
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
|
||||
textarea placeholder="Key facts the AI should always remember." />
|
||||
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
|
||||
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
|
||||
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
|
||||
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
|
||||
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={exportCards} disabled={scenario.story_cards.length === 0}>Export</button>
|
||||
{!readOnly && <button onClick={importCards}>Import</button>}
|
||||
<button onClick={addCard}>+ Add Card</button>
|
||||
</div>
|
||||
</div>
|
||||
{scenario.story_cards.length === 0 && (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
|
||||
</div>
|
||||
)}
|
||||
{scenario.story_cards.map((card) => (
|
||||
<StoryCardRow key={card.id} card={card}
|
||||
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
|
||||
))}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
|
||||
<div className="panel-toggles">
|
||||
<button type="button" className={schemaView === 'form' ? 'active' : ''}
|
||||
onClick={() => setSchemaView('form')}>Editor</button>
|
||||
<button type="button" className={schemaView === 'json' ? 'active' : ''}
|
||||
onClick={() => setSchemaView('json')}>JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Optional, and no longer how the story is tracked. Every campaign now keeps
|
||||
genre-neutral narrative state — who exists, where they are, what they hold,
|
||||
what has been established — without any schema at all, and that is what the
|
||||
narrator is told. Numbers defined here are recorded alongside it for
|
||||
scenarios that were built on them. Leave blank unless you have one.
|
||||
</p>
|
||||
{schemaView === 'form' ? (
|
||||
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className="schema-editor"
|
||||
value={schemaText}
|
||||
onChange={(e) => onJsonText(e.target.value)}
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
|
||||
/>
|
||||
<div className="schema-json-actions">
|
||||
<button type="button" className="primary" onClick={commitJson}>Save JSON</button>
|
||||
{schemaError
|
||||
? <span className="schema-error">⚠ {schemaError}</span>
|
||||
: <span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||
Edits apply when you click Save.
|
||||
</span>}
|
||||
</div>
|
||||
<SchemaPreview schema={parsedSchema} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Cast & NPCs</h2>
|
||||
{schemaView === 'form' && (
|
||||
<button onClick={() => setNpcs(addNpc(parsedSchema?.npcs))}>+ Add NPC</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Characters the AI tracks by name. Each has its own trigger words, description and
|
||||
stats — a dragon’s <em>ferocity</em>, a ranger’s <em>trust</em> — addressed as
|
||||
<code> npc.<id>.<stat></code>. Adding one here also creates a story card
|
||||
for it when an adventure starts.
|
||||
</p>
|
||||
{schemaView === 'form' ? (
|
||||
<NpcEditor npcs={parsedSchema?.npcs || {}} onChange={setNpcs} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
NPCs are part of the schema JSON above — switch to <strong>Editor</strong> to edit
|
||||
them as cards.
|
||||
</div>
|
||||
)}
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Structured preview of a parsed stat_schema (read-only) ----
|
||||
|
||||
function statMeta(def) {
|
||||
const parts = []
|
||||
if (def.initial !== undefined) parts.push(`start ${def.initial}`)
|
||||
if (typeof def.min === 'number' && typeof def.max === 'number') parts.push(`${def.min}–${def.max}`)
|
||||
if (def.type === 'counter') parts.push('counter')
|
||||
if (typeof def.max_delta_per_turn === 'number') parts.push(`±${def.max_delta_per_turn}/turn`)
|
||||
if (def.cooldown) parts.push(`cooldown ${def.cooldown}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function StatDefRow({ name, def }) {
|
||||
if (!def || typeof def !== 'object') return null
|
||||
const bands = Array.isArray(def.bands)
|
||||
? def.bands.filter((b) => Array.isArray(b) && b.length === 3)
|
||||
.map((b) => `${b[0]}–${b[1]} ${b[2]}`).join(', ')
|
||||
: null
|
||||
return (
|
||||
<div className="schema-stat">
|
||||
<div className="schema-stat-head">
|
||||
<span className="schema-stat-name">{name}</span>
|
||||
<span className="schema-stat-meta">{statMeta(def)}</span>
|
||||
</div>
|
||||
{def.desc && <div className="schema-desc">{def.desc}</div>}
|
||||
{bands && <div className="schema-bands">bands: {bands}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatDefGroup({ title, defs }) {
|
||||
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div className="schema-group">
|
||||
<div className="schema-group-title">{title}</div>
|
||||
{entries.map(([n, d]) => <StatDefRow key={n} name={n} def={d} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchemaPreview({ schema }) {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null
|
||||
const world = schema.world || {}
|
||||
const player = schema.player || {}
|
||||
const npcs = schema.npcs || {}
|
||||
const flags = schema.flags || {}
|
||||
const milestones = schema.milestones || {}
|
||||
const count = [world, player, npcs, flags, milestones]
|
||||
.reduce((n, o) => n + Object.keys(o || {}).length, 0)
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<div className="schema-preview">
|
||||
<div className="schema-preview-title">Parsed structure</div>
|
||||
<StatDefGroup title="World" defs={world} />
|
||||
<StatDefGroup title="Player" defs={player} />
|
||||
{Object.entries(npcs).map(([npcId, npc]) => (
|
||||
npc && typeof npc === 'object' ? (
|
||||
<div key={npcId} className="schema-group">
|
||||
<div className="schema-group-title">
|
||||
{npc.name || npcId} <span className="schema-npc-id">npc.{npcId}</span>
|
||||
</div>
|
||||
{npc.keys && <div className="schema-desc">triggers: {npc.keys}</div>}
|
||||
{npc.desc && <div className="schema-desc">{npc.desc}</div>}
|
||||
{Object.entries(npc.stats || {}).map(([n, d]) => (
|
||||
<StatDefRow key={n} name={n} def={d} />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
{Object.keys(flags).length > 0 && (
|
||||
<div className="schema-group">
|
||||
<div className="schema-group-title">Flags</div>
|
||||
{Object.entries(flags).map(([fid, f]) => (
|
||||
<div key={fid} className="schema-stat">
|
||||
<div className="schema-stat-head">
|
||||
<span className="schema-stat-name">{fid}</span>
|
||||
<span className="schema-stat-meta">{f?.initial ? 'on by default' : 'off by default'}</span>
|
||||
</div>
|
||||
{f?.desc && <div className="schema-desc">{f.desc}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(milestones).length > 0 && (
|
||||
<div className="schema-group">
|
||||
<div className="schema-group-title">Milestones</div>
|
||||
{Object.entries(milestones).map(([mid, m]) => (
|
||||
<div key={mid} className="schema-stat">
|
||||
<div className="schema-stat-head">
|
||||
<span className="schema-stat-name">{mid}</span>
|
||||
</div>
|
||||
{m?.desc && <div className="schema-desc">{m.desc}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
CardSkeleton,
|
||||
extractPlaceholders,
|
||||
pickJSONFile,
|
||||
BeginAdventureModal,
|
||||
ScenarioArt,
|
||||
useToast,
|
||||
} from '../components'
|
||||
|
||||
function splitTags(tags, { isPublic = false } = {}) {
|
||||
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
|
||||
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
|
||||
}
|
||||
|
||||
export default function Scenarios() {
|
||||
const [scenarios, setScenarios] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState(null)
|
||||
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
||||
}, [])
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set()
|
||||
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
|
||||
return [...tags].sort()
|
||||
}, [scenarios])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!scenarios) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return scenarios.filter((sc) => {
|
||||
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
|
||||
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
}, [scenarios, search, tagFilter])
|
||||
|
||||
const createScenario = async () => {
|
||||
const scenario = await api.createScenario({ title: 'New Scenario' })
|
||||
navigate(`/scenarios/${scenario.id}`)
|
||||
}
|
||||
|
||||
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
|
||||
const adv = await api.createAdventure({
|
||||
scenario_id: scenarioId,
|
||||
// A blank adventure has no scenario to take a title from, so name it here
|
||||
// exactly as the button that starts it did before the modal existed.
|
||||
title: scenarioId ? null : 'Blank Adventure',
|
||||
placeholders,
|
||||
persona_name: persona.name || '',
|
||||
persona_pronouns: persona.pronouns || '',
|
||||
persona_desc: persona.desc || '',
|
||||
})
|
||||
navigate(`/play/${adv.id}`)
|
||||
}
|
||||
|
||||
const startAdventure = async (e, scenarioId) => {
|
||||
e.stopPropagation()
|
||||
const scenario = await api.getScenario(scenarioId)
|
||||
const names = extractPlaceholders(
|
||||
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
||||
// Cards can carry ${placeholders} in trigger keys too, not just entries.
|
||||
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
|
||||
)
|
||||
// Always open the modal, even with no placeholders: it is where the
|
||||
// player names their character.
|
||||
setPending({ scenario, names })
|
||||
}
|
||||
|
||||
// A blank adventure has no scenario, so there are no placeholders to collect,
|
||||
// but the player still names their character. `pending.scenario` is null for
|
||||
// this path, and `begin` is called with no scenario id.
|
||||
const startBlank = () => setPending({ scenario: null, names: [] })
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Scenarios</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={startBlank}>Blank Adventure</button>
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const { scenario, unmapped_keys } = await api.importScenario(bundle)
|
||||
if (unmapped_keys.length) {
|
||||
toast(`Imported. Ignored unknown fields: ${unmapped_keys.join(', ')}`)
|
||||
}
|
||||
navigate(`/scenarios/${scenario.id}`)
|
||||
} catch (err) { toast(err.message, 'error') }
|
||||
}}>Import</button>
|
||||
<button className="primary" onClick={createScenario}>+ New Scenario</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search scenarios…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{allTags.length > 0 && (
|
||||
<div className="tag-row">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag ${tagFilter === tag ? 'active' : ''}`}
|
||||
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible === null ? (
|
||||
<CardSkeleton count={6} />
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{scenarios.length === 0
|
||||
? 'No scenarios yet. Create one to define a reusable story template.'
|
||||
: 'No scenarios match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{visible.map((sc, i) => (
|
||||
<article
|
||||
key={sc.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${Math.min(i, 10) * 50}ms` }}
|
||||
onClick={() => navigate(`/scenarios/${sc.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
|
||||
<div className="card-headings">
|
||||
<h3>{sc.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="snippet">{sc.description || 'No description yet.'}</p>
|
||||
<footer className="card-foot">
|
||||
<div className="tag-cluster">
|
||||
{sc.is_public && (
|
||||
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
|
||||
)}
|
||||
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
|
||||
Play
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending && (
|
||||
<BeginAdventureModal
|
||||
title={pending.scenario ? pending.scenario.title : 'Blank Adventure'}
|
||||
names={pending.names}
|
||||
onCancel={() => setPending(null)}
|
||||
onSubmit={(answers) => {
|
||||
setPending(null)
|
||||
begin(pending.scenario ? pending.scenario.id : null, answers)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+287
-138
@@ -1,46 +1,87 @@
|
||||
/* Settings: where the storyteller gets its words from, and nothing else.
|
||||
*
|
||||
* `BROWSER-UX-SPEC.md` §43-45, §73, and §8/§24 of the M8 brief.
|
||||
*
|
||||
* ## The model is chosen from what is installed
|
||||
*
|
||||
* The inherited screen had `Model` as a free-text box. Typing a model name that
|
||||
* is not pulled produces a correct-looking configuration that fails on every
|
||||
* turn, and the connection test already knew the answer — it lists the
|
||||
* endpoint's models and even warned when the configured one was missing. So the
|
||||
* field is a picker over that listing, with the free-text box kept for the case
|
||||
* the listing is empty or the reader wants a model that is not pulled yet.
|
||||
*
|
||||
* Nothing is chosen automatically. An endpoint's first model may well be an
|
||||
* embedding model, which cannot narrate at all.
|
||||
*
|
||||
* ## No cloud provider anywhere
|
||||
*
|
||||
* §45. There is no provider selector, no sign-in and no API key field — the key
|
||||
* field was removed in M2 with the deployment it served, and ADR 011 already
|
||||
* refuses an endpoint that is not on this machine or this network. This screen
|
||||
* states that boundary rather than offering to cross it.
|
||||
*
|
||||
* ## Diagnostics are kept, and folded away
|
||||
*
|
||||
* The raw request/response log is genuinely useful when a local model is
|
||||
* misbehaving, and it is exactly the "developer console" §26 wants out of the
|
||||
* normal path. It stays, behind a disclosure, at the bottom, clearly marked.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useToast } from '../components'
|
||||
import { useModelStatus } from '../modelStatus'
|
||||
import { ModelSetupNotice } from '../ModelSetupNotice'
|
||||
|
||||
function DebugLog() {
|
||||
const [entries, setEntries] = useState(null)
|
||||
const [openId, setOpenId] = useState(null)
|
||||
|
||||
const load = () => api.getDebugRequests().then(setEntries).catch(() => setEntries([]))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!entries) return null
|
||||
return (
|
||||
<div className="debug-log">
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h3 style={{ margin: 0 }}>Recent AI requests</h3>
|
||||
<button onClick={load}>Refresh</button>
|
||||
<details
|
||||
className="advanced-block"
|
||||
onToggle={(e) => { if (e.currentTarget.open && entries === null) load() }}
|
||||
>
|
||||
<summary>Recent requests to the model</summary>
|
||||
<p className="field-hint">
|
||||
The exact request and response for each recent turn. Useful when a local
|
||||
model is answering strangely.
|
||||
</p>
|
||||
<div className="panel-actions">
|
||||
<button type="button" onClick={load}>Refresh</button>
|
||||
</div>
|
||||
{entries.length === 0 && (
|
||||
<div className="empty" style={{ padding: '16px 0' }}>
|
||||
No requests yet this session. Play a turn and refresh.
|
||||
</div>
|
||||
{entries === null && <p className="dim">Open to load.</p>}
|
||||
{entries?.length === 0 && (
|
||||
<p className="dim">No requests yet this session. Play a turn and refresh.</p>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
{entries?.map((e) => (
|
||||
<div key={e.id} className="debug-entry">
|
||||
<div className="debug-entry-head" onClick={() => setOpenId(openId === e.id ? null : e.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="debug-entry-head"
|
||||
aria-expanded={openId === e.id}
|
||||
onClick={() => setOpenId(openId === e.id ? null : e.id)}
|
||||
>
|
||||
<span className={`debug-status ${e.status}`}>{e.status}</span>
|
||||
<span>{e.model}</span>
|
||||
<span className="dim">{new Date(e.time).toLocaleTimeString()}</span>
|
||||
<span className="dim" style={{ marginLeft: 'auto' }}>{openId === e.id ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{openId === e.id && (
|
||||
<div className="debug-entry-body">
|
||||
<div className="dim">{e.url}</div>
|
||||
{e.error && <div className="test-error">{e.error}</div>}
|
||||
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Request</span></div>
|
||||
<p className="dim">{e.url}</p>
|
||||
{e.error && <p className="notice-detail error">{e.error}</p>}
|
||||
<h4>Request</h4>
|
||||
<pre>{JSON.stringify(e.request, null, 2)}</pre>
|
||||
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Response</span></div>
|
||||
<h4>Response</h4>
|
||||
<pre>{e.response || '(empty)'}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,158 +89,266 @@ export default function Settings() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [saved, setSaved] = useState('')
|
||||
const [customModel, setCustomModel] = useState(false)
|
||||
const toast = useToast()
|
||||
const { models, refresh: refreshStatus, status } = useModelStatus()
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(setSettings)
|
||||
}, [])
|
||||
|
||||
if (!settings) return null
|
||||
|
||||
const setField = (field, value) => setSettings({ ...settings, [field]: value })
|
||||
|
||||
const buildPayload = () => ({ ...settings })
|
||||
|
||||
const afterSave = async () => {
|
||||
const fresh = await api.getSettings()
|
||||
setSettings(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.updateSettings(buildPayload())
|
||||
await afterSave()
|
||||
setSaved('Settings saved')
|
||||
await api.updateSettings(settings)
|
||||
setSettings(await api.getSettings())
|
||||
await refreshStatus()
|
||||
setSaved('Saved')
|
||||
setTimeout(() => setSaved(''), 4000)
|
||||
} catch (err) {
|
||||
setSaved(`Save failed: ${err.message}`)
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
setTimeout(() => setSaved(''), 4000)
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setTestResult({ pending: true })
|
||||
try {
|
||||
await api.updateSettings(buildPayload())
|
||||
await afterSave()
|
||||
setTestResult(await api.testConnection())
|
||||
await api.updateSettings(settings)
|
||||
setSettings(await api.getSettings())
|
||||
const result = await api.testConnection()
|
||||
setTestResult(result)
|
||||
await refreshStatus()
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, detail: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings) return null
|
||||
// Show the picker when the endpoint told us what it has, unless the reader
|
||||
// has deliberately asked to type a name instead.
|
||||
const showPicker = models.length > 0 && !customModel
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 640 }}>
|
||||
<div className="page settings">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
|
||||
<span className="dim" role="status">{saved}</span>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Ollama endpoint</span>
|
||||
<input type="text" value={settings.endpoint_url}
|
||||
placeholder="http://127.0.0.1:11434/v1"
|
||||
onChange={(e) => setField('endpoint_url', e.target.value)} />
|
||||
<span className="label" style={{ marginTop: 4 }}>
|
||||
Ollama on this machine, or on a machine on your own network. A public
|
||||
Internet address is refused — your story never leaves your own
|
||||
infrastructure. An https:// endpoint whose certificate comes from your
|
||||
own CA works once that CA is installed on this machine.
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">Model</span>
|
||||
<input type="text" value={settings.model} placeholder="e.g. qwen2.5:3b-instruct"
|
||||
onChange={(e) => setField('model', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Temperature</span>
|
||||
<input type="number" step="0.1" min="0" max="2" value={settings.temperature}
|
||||
onChange={(e) => setField('temperature', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Max output tokens</span>
|
||||
<input type="number" value={settings.max_output_tokens}
|
||||
onChange={(e) => setField('max_output_tokens', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Context budget (tokens)</span>
|
||||
<input type="number" value={settings.context_token_budget}
|
||||
onChange={(e) => setField('context_token_budget', Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="label">Model timeout (seconds)</span>
|
||||
<input type="number" min="30" max="3600" value={settings.model_timeout_seconds}
|
||||
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))} />
|
||||
<span className="label" style={{ marginTop: 4 }}>
|
||||
How long to wait for a turn. Loading a model for the first time can
|
||||
take minutes on a machine without a GPU, while later turns take
|
||||
seconds. Raise this if turns time out only after Ollama has been idle.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">API mode</span>
|
||||
<select value={settings.api_mode} onChange={(e) => setField('api_mode', e.target.value)}>
|
||||
<option value="chat">Chat (/v1/chat/completions)</option>
|
||||
<option value="completion">Completion (/v1/completions)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="page-header" style={{ marginTop: 20 }}>
|
||||
<h3 style={{ margin: 0 }}>Memory system</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Summarization model</span>
|
||||
<input type="text" value={settings.summary_model}
|
||||
placeholder="Empty = use main model"
|
||||
onChange={(e) => setField('summary_model', e.target.value)} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Embedding model</span>
|
||||
<input type="text" value={settings.embedding_model}
|
||||
placeholder="e.g. nomic-embed-text (empty = memory bank off)"
|
||||
onChange={(e) => setField('embedding_model', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Memory bank capacity</span>
|
||||
<input type="number" min="1" value={settings.memory_bank_capacity}
|
||||
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Memories per turn (top-K)</span>
|
||||
<input type="number" min="1" value={settings.memory_top_k}
|
||||
onChange={(e) => setField('memory_top_k', Number(e.target.value))} />
|
||||
</label>
|
||||
{/* §73 / §24. Stated plainly, once, at the top. */}
|
||||
<div className="local-only" data-testid="local-only">
|
||||
<strong>Runtime: local only</strong>
|
||||
<p>
|
||||
Your campaigns, your imported files and every prompt stay on this
|
||||
machine. The one thing that leaves it is the request to the Ollama
|
||||
endpoint below — which must be on this machine or your own network;
|
||||
an address on the public internet is refused.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Narrator system prompt</span>
|
||||
<textarea rows={4} value={settings.narrator_prompt}
|
||||
onChange={(e) => setField('narrator_prompt', e.target.value)} />
|
||||
</label>
|
||||
{status !== 'ready' && <ModelSetupNotice compact />}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||
<button className="primary" onClick={save}>Save</button>
|
||||
<button onClick={test}>Test connection</button>
|
||||
</div>
|
||||
<section className="settings-section">
|
||||
<h2>The narrator</h2>
|
||||
|
||||
{testResult && (
|
||||
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
|
||||
{testResult.pending ? 'Testing…'
|
||||
: testResult.ok
|
||||
? (testResult.warning
|
||||
|| `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`)
|
||||
: testResult.detail}
|
||||
<div className="field">
|
||||
<label htmlFor="endpoint">Ollama endpoint</label>
|
||||
<input
|
||||
id="endpoint"
|
||||
type="text"
|
||||
value={settings.endpoint_url}
|
||||
placeholder="http://127.0.0.1:11434/v1"
|
||||
onChange={(e) => setField('endpoint_url', e.target.value)}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Ollama on this machine, or on a machine on your own network. An{' '}
|
||||
<code>https://</code> endpoint whose certificate comes from your own
|
||||
CA works once that CA is installed on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DebugLog />
|
||||
<div className="field">
|
||||
<label htmlFor="model">Narrator model</label>
|
||||
{showPicker ? (
|
||||
<select
|
||||
id="model"
|
||||
value={settings.model || ''}
|
||||
onChange={(e) => setField('model', e.target.value)}
|
||||
>
|
||||
<option value="">— choose a model —</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="model"
|
||||
type="text"
|
||||
value={settings.model || ''}
|
||||
placeholder="qwen2.5:3b-instruct"
|
||||
onChange={(e) => setField('model', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<p className="field-hint">
|
||||
{models.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
onClick={() => setCustomModel((c) => !c)}
|
||||
>
|
||||
{customModel ? 'Choose from installed models' : 'Type a model name instead'}
|
||||
</button>
|
||||
) : (
|
||||
'Test the connection to list the models installed on that endpoint.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="temperature">Temperature</label>
|
||||
<input
|
||||
id="temperature" type="number" step="0.1" min="0" max="2"
|
||||
value={settings.temperature}
|
||||
onChange={(e) => setField('temperature', Number(e.target.value))}
|
||||
/>
|
||||
<p className="field-hint">Higher is more surprising, lower more consistent.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="max-output">Longest reply</label>
|
||||
<input
|
||||
id="max-output" type="number"
|
||||
value={settings.max_output_tokens}
|
||||
onChange={(e) => setField('max_output_tokens', Number(e.target.value))}
|
||||
/>
|
||||
<p className="field-hint">In tokens. Roughly ¾ of a word each.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="ctx-budget">How much story to send</label>
|
||||
<input
|
||||
id="ctx-budget" type="number"
|
||||
value={settings.context_token_budget}
|
||||
onChange={(e) => setField('context_token_budget', Number(e.target.value))}
|
||||
/>
|
||||
<p className="field-hint">In tokens. Must fit your model’s window.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="timeout">How long to wait for a turn</label>
|
||||
<input
|
||||
id="timeout" type="number" min="30" max="3600"
|
||||
value={settings.model_timeout_seconds}
|
||||
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
In seconds. Loading a model for the first time can take minutes on a
|
||||
machine without a GPU, while later turns take seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="panel-actions">
|
||||
<button type="button" className="primary" onClick={save}>Save</button>
|
||||
<button type="button" onClick={test}>Test connection</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
className={`test-result ${testResult.ok ? 'ok' : 'bad'}`}
|
||||
role="status"
|
||||
data-testid="test-result"
|
||||
>
|
||||
{testResult.pending ? 'Testing…'
|
||||
: testResult.ok
|
||||
? (testResult.warning
|
||||
|| `Connected. ${testResult.models?.length || 0} model(s) installed.`)
|
||||
: testResult.detail}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h2>Memory and search</h2>
|
||||
<p className="field-hint">
|
||||
How the storyteller remembers a long campaign, and how it searches what
|
||||
you have imported.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="summary-model">Model for summarising</label>
|
||||
<input
|
||||
id="summary-model" type="text" value={settings.summary_model}
|
||||
placeholder="Empty — use the narrator model"
|
||||
onChange={(e) => setField('summary_model', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="embedding-model">Model for meaning-based search</label>
|
||||
<input
|
||||
id="embedding-model" type="text" value={settings.embedding_model}
|
||||
placeholder="nomic-embed-text"
|
||||
onChange={(e) => setField('embedding_model', e.target.value)}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Empty means keyword search only, which is a supported setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="bank-capacity">Memories kept</label>
|
||||
<input
|
||||
id="bank-capacity" type="number" min="1" value={settings.memory_bank_capacity}
|
||||
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bank-topk">Memories recalled per turn</label>
|
||||
<input
|
||||
id="bank-topk" type="number" min="1" value={settings.memory_top_k}
|
||||
onChange={(e) => setField('memory_top_k', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<button type="button" className="primary" onClick={save}>Save</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h2>Advanced</h2>
|
||||
<details className="advanced-block">
|
||||
<summary>Default narrator instructions</summary>
|
||||
<p className="field-hint">
|
||||
Used by every campaign that does not set its own. A campaign’s own
|
||||
instructions are in its Settings panel.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label className="sr-only" htmlFor="narrator-prompt">
|
||||
Default narrator instructions
|
||||
</label>
|
||||
<textarea
|
||||
id="narrator-prompt" rows={5} value={settings.narrator_prompt}
|
||||
onChange={(e) => setField('narrator_prompt', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="api-mode">How to talk to the model</label>
|
||||
<select
|
||||
id="api-mode" value={settings.api_mode}
|
||||
onChange={(e) => setField('api_mode', e.target.value)}
|
||||
>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="completion">Completion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<button type="button" className="primary" onClick={save}>Save</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<DebugLog />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Shared fixtures for the component tests.
|
||||
*
|
||||
* `renderWith` supplies the two contexts every screen needs — a router and the
|
||||
* model status — so a test can render one component rather than the whole app.
|
||||
* The model status is faked at the `api` boundary rather than by mocking the
|
||||
* hook, so the provider's own logic (which of the five states a given settings
|
||||
* row and connection result produce) is exercised by the tests that depend on
|
||||
* it instead of being assumed.
|
||||
*/
|
||||
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi } from 'vitest'
|
||||
import { ModelStatusProvider } from '../modelStatus'
|
||||
import { ToastHost } from '../components'
|
||||
|
||||
/** A settings row with the shape the real API returns. */
|
||||
export const SETTINGS = {
|
||||
endpoint_url: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen2.5:3b-instruct',
|
||||
api_mode: 'chat',
|
||||
temperature: 0.2,
|
||||
max_output_tokens: 400,
|
||||
context_token_budget: 8000,
|
||||
model_timeout_seconds: 600,
|
||||
summary_model: '',
|
||||
embedding_model: 'nomic-embed-text',
|
||||
memory_bank_capacity: 200,
|
||||
memory_top_k: 5,
|
||||
narrator_prompt: 'You are the narrator.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Points `api.getSettings` and `api.testConnection` at a given world.
|
||||
*
|
||||
* `models` defaults to a listing containing the configured model, which is the
|
||||
* ordinary "everything works" case.
|
||||
*/
|
||||
export function mockModelStatus(api, {
|
||||
settings = SETTINGS,
|
||||
ok = true,
|
||||
models = ['qwen2.5:3b-instruct', 'nomic-embed-text'],
|
||||
detail = '',
|
||||
warning = '',
|
||||
} = {}) {
|
||||
vi.spyOn(api, 'getSettings').mockResolvedValue(settings)
|
||||
vi.spyOn(api, 'testConnection').mockResolvedValue(
|
||||
ok ? { ok: true, models, ...(warning ? { warning } : {}) } : { ok: false, detail },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders inside the router and the model-status provider.
|
||||
*
|
||||
* Async because the provider fetches settings and runs a connection test on
|
||||
* mount; awaiting here lets those promises settle inside `act`, so a test sees
|
||||
* the resolved status rather than "checking" and React emits no warning about
|
||||
* an unwrapped update.
|
||||
*/
|
||||
export async function renderWith(ui, { route = '/' } = {}) {
|
||||
let view
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<ModelStatusProvider>
|
||||
<ToastHost>{ui}</ToastHost>
|
||||
</ModelStatusProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* An accepted narrator turn, in the shape the actions API returns.
|
||||
*
|
||||
* **`ActionOut` carries no `branch_id`, and this must not either.** A fixture
|
||||
* that supplies one models a payload the server never sends, and it hid a real
|
||||
* defect: the take pager compared `target.branch_id !== action.branch_id`,
|
||||
* which against the real payload is always `number !== undefined` and therefore
|
||||
* always true. With a `branch_id` in the fixture the comparison looked correct
|
||||
* and the tests passed against broken code.
|
||||
*/
|
||||
export function narratorAction(overrides = {}) {
|
||||
return {
|
||||
id: 2,
|
||||
type: 'ai',
|
||||
text: 'The tavern is warm and loud.',
|
||||
reasoning: '',
|
||||
world_changes: null,
|
||||
take_count: 1,
|
||||
take_index: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** An accepted player turn. */
|
||||
export function playerAction(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'do',
|
||||
text: 'I walk into the tavern.',
|
||||
reasoning: '',
|
||||
take_count: 1,
|
||||
take_index: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Test setup: jest-dom matchers, and the two browser APIs jsdom lacks that
|
||||
* this application uses.
|
||||
*
|
||||
* Both stubs are for things jsdom genuinely does not implement, not for
|
||||
* behaviour under test. `scrollTo` is called by the transcript's
|
||||
* bottom-pinning effect on every render, and `matchMedia` by nothing here yet
|
||||
* but by anything that later asks about reduced motion. Stubbing more than
|
||||
* this would start hiding real defects.
|
||||
*/
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
window.scrollTo = () => {}
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})
|
||||
}
|
||||
@@ -15,4 +15,20 @@ export default defineConfig({
|
||||
'/api': `http://localhost:${apiPort}`,
|
||||
},
|
||||
},
|
||||
// M8's component tests. `jsdom` rather than a real browser on purpose: these
|
||||
// check deterministic rendering and wiring — which control is disabled, which
|
||||
// words appear, what a click calls — and the real browser suite covers what
|
||||
// only a real browser can. One is not a substitute for the other, and running
|
||||
// the fast one in a browser would make it slow enough to stop being run.
|
||||
// The React plugin's JSX transform does not reach files Vitest loads, so the
|
||||
// automatic runtime is named here as well. Without it every `.jsx` test fails
|
||||
// with "React is not defined" — the classic runtime's implicit global.
|
||||
esbuild: { jsx: 'automatic' },
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/test/setup.js',
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -184,6 +184,15 @@ unless inherited UI makes them useful without complexity.
|
||||
|
||||
Optional helpers may exist.
|
||||
|
||||
### As implemented in M8
|
||||
|
||||
One field. The inherited `Do` / `Say` / `Story` selector is gone: 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. What survived from `Story` mode is the direction toggle in §13, which is
|
||||
deliberately not a fourth mode — it changes who is being spoken to, not what
|
||||
kind of action is taken.
|
||||
|
||||
## 13. Out-of-Character Direction
|
||||
|
||||
The user should have a way to provide story-direction instructions.
|
||||
@@ -468,6 +477,21 @@ Open Threads
|
||||
|
||||
Keep concise.
|
||||
|
||||
### As implemented in M8
|
||||
|
||||
The server groups the state and the panel renders the groups, so the section
|
||||
headings are whatever the campaign has established rather than a fixed list —
|
||||
a campaign with no items shows no Items heading. Nothing is rendered as JSON,
|
||||
and each row offers **Correct** and, for a fact, **That's wrong**.
|
||||
|
||||
**There is no hidden dimension in the narrative state**, so there is nothing
|
||||
here to reveal. A secret never enters authoritative state: it lives in a
|
||||
knowledge source marked narrator-only, which the narrator is told not to
|
||||
disclose. M7 verified against a real narrator that the secret stays out of the
|
||||
state document. §38's requirement is met by this panel simply not containing
|
||||
narrator-only information; the surface that must actively withhold it is the
|
||||
context inspector — see §57.
|
||||
|
||||
## 32. Current Scene Section
|
||||
|
||||
Show:
|
||||
@@ -544,19 +568,44 @@ Visual profile
|
||||
|
||||
Advanced data may be hidden behind expandable sections.
|
||||
|
||||
## 38. Hidden Narrator State
|
||||
## 38. Hidden Narrator Information / Spoilers
|
||||
|
||||
Some campaigns may contain secrets.
|
||||
Some campaigns contain secrets the narrator knows and the protagonist does not.
|
||||
|
||||
Normal player-facing state panel should not reveal narrator-only facts by default.
|
||||
The requirement is:
|
||||
|
||||
Provide optional advanced mode:
|
||||
- ordinary State and story surfaces must not expose narrator-only information;
|
||||
- an advanced inspection surface that can contain hidden Canon or narrator-only
|
||||
context must **withhold it by default**;
|
||||
- revealing it requires an explicit, clearly labelled user action;
|
||||
- the UI must warn that doing so may reveal campaign secrets;
|
||||
- withheld material must be **absent from the rendered DOM**, not merely
|
||||
visually collapsed — a closed `<details>`, a `hidden` attribute or a
|
||||
`display: none` rule still leaves the text findable by browser search, by the
|
||||
accessibility tree and by anyone reading the page source;
|
||||
- **no second hidden-state store or duplicate representation** may be introduced
|
||||
solely to give the UI something to toggle.
|
||||
|
||||
```text
|
||||
Show Hidden Story State
|
||||
```
|
||||
### Where the secret actually lives (ratified at M8)
|
||||
|
||||
with clear warning.
|
||||
This section originally described a `Show Hidden Story State` control on the
|
||||
state panel. That named the wrong surface, and M8's review ratified the
|
||||
correction.
|
||||
|
||||
Narrator-only material is not held in the narrative state. It is held in the
|
||||
knowledge/canon system, as a source marked narrator-only, and the narrator is
|
||||
instructed not to disclose it. A secret therefore never enters the state
|
||||
document at all — M7 verified that against a real narrator — so a toggle on the
|
||||
state panel would reveal nothing, and building a hidden-state dimension to give
|
||||
it something to reveal would create exactly the duplicate representation the
|
||||
requirement above forbids.
|
||||
|
||||
The surface that genuinely needs protecting is **advanced context inspection**,
|
||||
because a retrieved narrator-only passage is reachable there. That is where the
|
||||
default-withheld behaviour belongs, and where §57 records it as implemented.
|
||||
|
||||
The vocabulary for this is **hidden-information inspection** — not
|
||||
"hidden-state", which implies a state subsystem that does not exist.
|
||||
|
||||
## 39. Campaign Sidebar / Menu
|
||||
|
||||
@@ -824,6 +873,14 @@ Optional advanced tab:
|
||||
Rendered Prompt
|
||||
```
|
||||
|
||||
### As implemented in M8
|
||||
|
||||
The M7 panel opened on 11,996 characters of assembled prompt. The M8 panel opens
|
||||
on the same campaign at about 1,400: token usage, then what the narrator read,
|
||||
what the story remembered, the summary, how much history was sent, and what
|
||||
produced it — each collapsible. The assembled prompt is the last section and is
|
||||
closed.
|
||||
|
||||
## 56. Retrieved Memory Row
|
||||
|
||||
Example:
|
||||
@@ -864,6 +921,25 @@ answer rather than a silence.
|
||||
Click-to-open-source is not implemented; the Knowledge panel is one click away
|
||||
and lists the same file.
|
||||
|
||||
### As implemented in M8
|
||||
|
||||
Click-to-open-source **is** implemented: the row carries `source_id`, and the
|
||||
filename is a button that opens the Knowledge panel on that source rather than
|
||||
on the list.
|
||||
|
||||
The rows were also rewritten to read rather than to enumerate — `found by
|
||||
hybrid · closeness 0.71 · 40 tokens` in place of a run of raw scores — and the
|
||||
class tag is now the same component the Knowledge panel uses, so a class looks
|
||||
the same wherever a reader meets it.
|
||||
|
||||
**Narrator-only passage text is withheld by default.** This is where §38's
|
||||
hidden-information requirement actually lands: the only place a campaign's secrets are
|
||||
reachable from an ordinary screen is a retrieved passage that was marked
|
||||
narrator-only. The row shows the file, the class and the badge, and the text is
|
||||
replaced by *"Hidden — this passage is narrator-only"* until the reader ticks a
|
||||
control that says what it will reveal. Turning it on is a preference in that
|
||||
component and nothing else: it changes no retrieval, no prompt and no state.
|
||||
|
||||
The passage text is rendered as a text node in a `<pre>`, never as markup. That
|
||||
is where H06 and H07 are decided for imported content, and it is the reason a
|
||||
Markdown renderer was not added here for appearance.
|
||||
@@ -1432,7 +1508,7 @@ The final v1 browser interface must support:
|
||||
- collapsible state panel,
|
||||
- direct entity inspector,
|
||||
- token usage display,
|
||||
- hidden-state inspector,
|
||||
- spoiler-aware advanced inspection of hidden narrator information,
|
||||
- knowledge retrieval provenance,
|
||||
- clear local-only status.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Adventure Storyteller — Production Build Milestones
|
||||
|
||||
**Status:** In implementation. M1-M7 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04; M6 and M7: 2026-09-06, each of the last three after an independent review and a corrective pass); M8 — Finished v1 browser experience — next to brief
|
||||
**Status:** In implementation. M1-M8 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04; M6, M7 and M8: 2026-09-06, each of the last four after an independent review and a corrective pass). **M9 — Export, Backup, Recovery, and Migration Hardening — is next, and has not been started.**
|
||||
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
|
||||
|
||||
## 1. Purpose
|
||||
@@ -829,7 +829,7 @@ CORRECTIVE WORK REQUIRED*, a corrective pass that closed both blocking findings,
|
||||
and a closeout verification that resolved the calibration boundary the
|
||||
corrective pass had left as debt. Report, including the original findings, the
|
||||
corrective closeout and the closeout verification, all preserved in sequence:
|
||||
`reports/M7-IMPLEMENTATION-REPORT.md`.
|
||||
`archive/milestone-reports/M7-IMPLEMENTATION-REPORT.md`.
|
||||
|
||||
**M8 is authorized.**
|
||||
|
||||
@@ -976,6 +976,113 @@ Turn the adapted AI-DnD interface into the focused interactive-story workspace d
|
||||
|
||||
Normal story creation and play feels like a focused local storyteller rather than an RPG or developer console.
|
||||
|
||||
## Status: COMPLETE / ACCEPTED — 2026-09-06
|
||||
|
||||
Implemented on `m8-browser-ux` from the signed M7 commit `480414e`, verified in
|
||||
a real browser against a real local narrator, independently reviewed, and
|
||||
accepted at closeout on 2026-09-06. The independent review returned
|
||||
`M8 IMPLEMENTATION: PASS` subject to evidence and documentation cleanup, which
|
||||
the closeout completed: the build-evidence classification in the report's §P and
|
||||
finding 14's resolution.
|
||||
|
||||
`planning/reports/M8-IMPLEMENTATION-REPORT.md` records what was built, what was
|
||||
measured and every finding, including the seven product defects verification
|
||||
found, the five harness defects, and the evidence runs that were discarded.
|
||||
|
||||
**Closeout evidence**
|
||||
|
||||
- **Real-browser verification against a real local narrator** — 157 checks
|
||||
across six suites, zero failures, all from one frozen production build
|
||||
(`index-Ii-lARp9.js`): acceptance 57/57, hidden-information sentinel 21/21,
|
||||
A05 failed generation 23/23, security/offline 27/27, genuine process restart
|
||||
12/12, migration against an M7-built database 17/17.
|
||||
- **A frontend test foundation, which the project had never had** — 132 tests
|
||||
across 10 files, running in about six seconds. Backend: 950 passed, 14
|
||||
skipped, 0 failed. Lint, production build and Docker build clean.
|
||||
- **Reader-facing terminology audit: 0 hits** across 17 normal-play components
|
||||
and both advanced surfaces.
|
||||
- **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 M8's.
|
||||
- **`BROWSER-UX-SPEC.md` §38 clarified and ratified** — hidden narrator
|
||||
information is withheld at the surface that can actually expose it, and is
|
||||
absent from the DOM rather than collapsed in it. No hidden-state subsystem was
|
||||
invented, and none is required.
|
||||
- **Carry-forward:** the four M9 handoff questions in the report's §U
|
||||
(portability scope, historical context-snapshot provenance, legacy story
|
||||
cards, context-window portability), and finding 14's context-window ceiling,
|
||||
whose long-campaign release validation stays with **M11**.
|
||||
|
||||
**M9 is next and has not been started.**
|
||||
|
||||
**What M8 delivered, beyond the scope list above:**
|
||||
|
||||
- **One entry point, and everything else inside a campaign.** The navigation was
|
||||
`Home · Adventures · Scenarios · Settings · AI Chat`; it is now the campaign
|
||||
library and Settings, with State, Knowledge, Context, Save Points and campaign
|
||||
Settings reachable from the story screen's own panel.
|
||||
- **A campaign is created from a form, not from a template.** The scenario
|
||||
gallery and its editor — a JSON stat-schema form, a story-card table and an
|
||||
art picker — are gone from the browser. Setup asks for a name and offers
|
||||
genre, tone, voice, length, protagonist, opening scene and canon, none of it
|
||||
required and none of it genre-specific.
|
||||
- **`campaign_canon` reached the browser.** It 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 resolution path for a blank model.** `Settings.model` could be empty with
|
||||
nothing saying so until a turn failed. The header now reports Ollama's state
|
||||
in five cases, and an unconfigured or missing model offers the models actually
|
||||
installed on the endpoint. Nothing is chosen automatically: an endpoint's
|
||||
first model may be an embedding model, which cannot narrate.
|
||||
- **Failures have §71's taxonomy** rather than one toast, and a failed turn
|
||||
leaves the reader's words in the box (A05).
|
||||
- **Safe Markdown for story prose.** Headings, emphasis, lists, blockquotes and
|
||||
code, built as React elements from parsed text — no `dangerouslySetInnerHTML`
|
||||
anywhere. A remote image is a placeholder, and a `javascript:` URL never
|
||||
becomes an href.
|
||||
- **A frontend test suite**, which the project had never had.
|
||||
|
||||
**Two defects found by driving the product, and fixed here:**
|
||||
|
||||
- **`> You I enter the tavern.`** AI Dungeon's player-input convention prefixes
|
||||
`> You `, which was right for the Do mode's bare verb phrase and wrong for
|
||||
§12's one natural-language field. It reached the transcript, the replayed
|
||||
history and therefore the narration, where a small model imitated it. Now the
|
||||
prefix is added only when the reader has not already written a subject.
|
||||
- **A stale `.input-bar { display: flex }`** in `play.css` overrode the new
|
||||
composer layout, because that sheet is imported after the new one. Found in
|
||||
the first browser pass, not by reading the CSS.
|
||||
|
||||
**Debt carried forward, deliberately:**
|
||||
|
||||
- **A deployment's context ceiling may be far below the app's budget —
|
||||
RESOLVED operationally, no code change.** Ollama defaults to a 4,096-token
|
||||
input window when it sees no VRAM; `Settings.context_token_budget` defaults to
|
||||
16,384. The OpenAI-compatible endpoint the app speaks accepts `num_ctx` and
|
||||
silently ignores it, and reloads the model at its own default, so priming over
|
||||
the native API does not help either. The remedy, verified end to end through
|
||||
the app's own path, is a **derived model** carrying `PARAMETER num_ctx`,
|
||||
created over `/api/create` and selected in Settings — no shell access on the
|
||||
Ollama host, no application change, and it appears in the model picker
|
||||
automatically. `DEVELOPMENT.md` carries the procedure under *"The context
|
||||
window your Ollama actually enforces"*. **Nothing was truncated in M8** — the
|
||||
largest assembled prompt across every suite was 2,498 tokens. This is now an
|
||||
**operational/deployment note, not an open defect**: M11 should confirm the
|
||||
window on the deployment it certifies against before its 100-turn run, and M9
|
||||
should note that an imported long campaign reaches a small ceiling
|
||||
immediately. A Settings-screen warning that reads the real window and compares
|
||||
it to the budget remains an unowned usability improvement.
|
||||
- **Story cards are no longer editable in the browser.** The backend keeps them
|
||||
and the bundle still carries them; the M7 knowledge library supersedes them
|
||||
for v1, and showing both would offer two unrelated systems for "things the
|
||||
narrator should know".
|
||||
- **The RPG world state is read-only.** It still appears in the context
|
||||
inspector for a campaign that has one; the editing drawer is gone (§18).
|
||||
- **Copy is per-message only.** §77's "copy the whole transcript" and §78's
|
||||
story search are not built.
|
||||
- **No discarded-history recovery screen** (§63) — explicitly future work.
|
||||
- **Tablet is usable, not tuned.** Desktop was the target; the narrow-screen
|
||||
sheet is inherited and was not redesigned.
|
||||
|
||||
---
|
||||
|
||||
# M9 — Export, Backup, Recovery, and Migration Hardening
|
||||
|
||||
@@ -82,16 +82,16 @@ in `planning/archive/decisions/`.
|
||||
One file, and it changes as development progresses:
|
||||
|
||||
```text
|
||||
planning/reports/M7-IMPLEMENTATION-REPORT.md
|
||||
planning/reports/M8-IMPLEMENTATION-REPORT.md
|
||||
```
|
||||
|
||||
M4 is the most recently completed milestone, and M5 is the next to be briefed.
|
||||
This report is M4's review *and* its closeout record: its §W holds the corrective
|
||||
work, the process-boundary automation, and the real-browser verification that
|
||||
closed the condition M3 and M4 both carried.
|
||||
M8 is the most recently completed milestone, and M9 is the next to be briefed.
|
||||
This report is M8's implementation account *and* its closeout record: its §P
|
||||
classifies the browser evidence by build, its §S holds every finding, and its §V
|
||||
records the acceptance.
|
||||
|
||||
**Replace it, do not accumulate.** When M5's report lands, remove this one from
|
||||
the project Sources and upload M5's instead. The repository does the same thing:
|
||||
**Replace it, do not accumulate.** When M9's report lands, remove this one from
|
||||
the project Sources and upload M9's instead. The repository does the same thing:
|
||||
`planning/reports/` holds the current milestone's report and
|
||||
`planning/archive/milestone-reports/` holds the rest.
|
||||
|
||||
|
||||
+55
-22
@@ -9,9 +9,18 @@ independent review found a real defect and a corrective pass fixed it.
|
||||
|
||||
**M7 — First-Class Imported Knowledge Library — is complete** (2026-09-06),
|
||||
after an independent review, a corrective pass and a closeout verification. Its
|
||||
report keeps all three in sequence: `reports/M7-IMPLEMENTATION-REPORT.md`.
|
||||
**Next: M8 — Browser UX Completion for v1 Story Operations.** Its brief has not
|
||||
been written yet, and writing it is the current action.
|
||||
report keeps all three in sequence, and is now in
|
||||
`archive/milestone-reports/M7-IMPLEMENTATION-REPORT.md`.
|
||||
|
||||
**M8 — Browser UX Completion for v1 Story Operations — is complete and
|
||||
accepted** (2026-09-06), after an independent review and a closeout pass.
|
||||
`reports/M8-IMPLEMENTATION-REPORT.md` is the implementer's account and now
|
||||
carries the closeout: the build-evidence classification in its §P, finding 14's
|
||||
operational resolution, and the acceptance record in its §V. The M8 tree is
|
||||
staged and awaits the repository owner's signed commit.
|
||||
|
||||
**Next: M9 — Export, Backup, Recovery, and Migration Hardening.** It has not
|
||||
been started.
|
||||
|
||||
**Package version:** see `VERSION.md`, which records what each revision changed
|
||||
and why.
|
||||
@@ -101,8 +110,9 @@ Two standing qualifications:
|
||||
10. `BROWSER-UX-SPEC.md`
|
||||
11. `V1-ACCEPTANCE-TESTS.md`
|
||||
12. `DECISIONS/` — all of them; they are short.
|
||||
13. `reports/M7-IMPLEMENTATION-REPORT.md`, for what the last accepted milestone
|
||||
actually left behind. Nothing in `planning/archive/` unless sent there.
|
||||
13. `reports/M8-IMPLEMENTATION-REPORT.md`, for what the most recent milestone
|
||||
actually left behind — reading it as a claim to check, not a record, until
|
||||
it is reviewed. Nothing in `planning/archive/` unless sent there.
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
@@ -133,15 +143,20 @@ work until Phase 0 closes — which Phase 0 satisfied on 2026-09-01. It is in
|
||||
`reports/` holds the report for the milestone most recently completed, because
|
||||
that is the one the next milestone's planning has to consult:
|
||||
|
||||
- `reports/M7-IMPLEMENTATION-REPORT.md` — M7's independent review, its
|
||||
corrective closeout and its closeout verification, in that order and none
|
||||
overwriting another. It is the longest report in the package because M7 is the
|
||||
milestone whose first implementation was most wrong, and the sequence is the
|
||||
point: what was claimed, what was measured, what that forced.
|
||||
- `reports/M8-IMPLEMENTATION-REPORT.md` — the M8 implementation, its baseline
|
||||
UX measurement, and the browser evidence for every acceptance test it claims.
|
||||
Written by the implementer for an independent reviewer, and completed at
|
||||
closeout after that review accepted the milestone: it is a set of claims with
|
||||
the measurements attached **and** the record of the acceptance. Its §U carries
|
||||
the M9 handoff — the four questions the next brief has to decide.
|
||||
|
||||
Completed earlier milestones are in `archive/milestone-reports/`, which M6's
|
||||
report joined at M7's closeout: a milestone report is useful during the
|
||||
immediate next milestone and historical afterwards. M1-M6 are all there,
|
||||
**It stays here until M9's report replaces it.** A milestone report is useful
|
||||
during the immediately following milestone; M8's is not archived merely
|
||||
because M8 is accepted.
|
||||
|
||||
Completed earlier milestones are in `archive/milestone-reports/`, which M7's
|
||||
report joined when M8's was written: a milestone report is useful during the
|
||||
immediate next milestone and historical afterwards. M1-M7 are all there,
|
||||
unedited.
|
||||
|
||||
## The decision this package rests on
|
||||
@@ -258,24 +273,42 @@ Milestone M6 COMPLETE (2026-09-06)
|
||||
|
|
||||
v
|
||||
Milestone M7 COMPLETE (2026-09-06)
|
||||
first-class imported knowledge reports/M7-IMPLEMENTATION-REPORT.md
|
||||
first-class imported knowledge archive/milestone-reports/M7-*.md
|
||||
library review + corrective + closeout, in sequence
|
||||
|
|
||||
v
|
||||
M8-M11, one at a time see BUILD-MILESTONES.md
|
||||
Milestone M8 COMPLETE / ACCEPTED (2026-09-06)
|
||||
browser UX completion for v1 reports/M8-IMPLEMENTATION-REPORT.md
|
||||
story operations review + closeout, in sequence
|
||||
|
|
||||
v
|
||||
Milestone M9 NEXT — not started
|
||||
export, backup, recovery, see BUILD-MILESTONES.md
|
||||
migration hardening
|
||||
|
|
||||
v
|
||||
M10-M11, one at a time see BUILD-MILESTONES.md
|
||||
```
|
||||
|
||||
## Stop Rule
|
||||
|
||||
**One milestone at a time. Do not begin a milestone before its brief exists.**
|
||||
|
||||
**No M8 brief has been prepared.** Writing one is the current action, informed by
|
||||
the M7 report and by the debt `BUILD-MILESTONES.md` records against M7 — in
|
||||
particular that the knowledge panel and the Insights knowledge rows are
|
||||
functional rather than designed, that a "nothing was relevant enough" result and
|
||||
an uncalibrated-embedding-model warning are both surfaced plainly and want a
|
||||
considered treatment, and that `BROWSER-UX-SPEC.md` §47's import preview and
|
||||
§52's retrieval-usage count are not built.
|
||||
**No M9 brief has been prepared.** Writing one is the current action, informed
|
||||
by the M8 report and by the debt `BUILD-MILESTONES.md` records against M8 — in
|
||||
particular that the campaign bundle still carries no context snapshots, so an
|
||||
imported campaign has no historical prompt provenance; that story cards survive
|
||||
in the backend and the bundle with no browser surface, and M9 should decide
|
||||
deliberately whether the bundle keeps carrying them; and that a deployment whose
|
||||
Ollama enforces a small context window truncates an imported long campaign
|
||||
immediately unless the `DEVELOPMENT.md` procedure or a matching
|
||||
`context_token_budget` is applied.
|
||||
|
||||
**M8's own carried debt** is recorded under M8 in `BUILD-MILESTONES.md`: story
|
||||
cards have no browser editor, the RPG world state is read-only, copy is
|
||||
per-message only, there is no discarded-history recovery screen, and the tablet
|
||||
layout is usable but untuned. Each names the milestone that owns it; none is an
|
||||
open M8 condition.
|
||||
|
||||
The M6 retrieval debt this milestone was warned about is partly addressed and
|
||||
partly still open. Imported material does **not** compete with story memory for
|
||||
|
||||
@@ -888,6 +888,83 @@ the source, because an imported file has no lineage: the query is built from
|
||||
`adventures.narrative_state`, which head movement repoints. Nothing reads the
|
||||
uncapped action table.
|
||||
|
||||
## 13.4 The browser is a presentation layer, and M8 kept it one
|
||||
|
||||
M8 rebuilt the interface without moving a decision into it. Three rules made
|
||||
that hold, and each replaced a tempting shortcut:
|
||||
|
||||
- **Server-authoritative availability.** Whether Undo and Redo have anywhere to
|
||||
go is the server's answer, carried on every window it returns. Neither is
|
||||
derivable in the browser: Undo can reach past the top of the loaded page, and
|
||||
Redo depends on a retained future the transcript is never sent. A React
|
||||
component that computed either would be wrong exactly when it mattered.
|
||||
- **Reading is not deciding.** Stepping between alternate takes tells the server
|
||||
nothing. The decision is made by writing below one, and that is the only
|
||||
moment `after_id` is sent. This is why the take pager can be offered on every
|
||||
turn without any of them becoming a commitment.
|
||||
- **UI state stays UI state** (§92 of `BROWSER-UX-SPEC.md`). Which panel is
|
||||
open, whether narrator-only text is revealed, whether a source's detail is
|
||||
expanded — none of it is written anywhere, and none of it changes what is
|
||||
retrieved, what is prompted, or what the story believes.
|
||||
|
||||
### Failure has a taxonomy, not a toast
|
||||
|
||||
`frontend/src/errors.js` sorts a failure into the five kinds §71 names — model,
|
||||
generation, state, knowledge, server — because each one has a different thing to
|
||||
*do* about it. It reads the message the server actually sent, which is a
|
||||
coupling to backend strings, so it is written as a fallback ladder rather than a
|
||||
lookup: an unrecognised message still gets a kind, still shows the server's own
|
||||
words, and still offers Retry. Nothing is hidden when the match misses.
|
||||
|
||||
### Markup is never produced from input
|
||||
|
||||
`frontend/src/markdown.jsx` renders narrator prose, and it is where H06 and H07
|
||||
are decided for rendered text. The safety is structural rather than filtered:
|
||||
every node it returns is a React element built from parsed text, the text only
|
||||
ever becomes a React child, and there is no `dangerouslySetInnerHTML` in the
|
||||
file. A sanitizer is not needed to make markup safe if markup is never produced.
|
||||
|
||||
Link schemes are checked with the URL parser rather than a pattern, because the
|
||||
bypasses are all in the parsing — `java\tscript:`, `JaVaScript:` and
|
||||
`%6a%61vascript:` are one URL to a browser and three strings to a regex.
|
||||
|
||||
The knowledge and context panels deliberately do **not** use this renderer.
|
||||
They exist to show a reader exactly what is in their file, and rendering is the
|
||||
opposite of that; imported text goes into a `<pre>` as a text node.
|
||||
|
||||
### Campaign canon is configuration, and its provenance is per turn
|
||||
|
||||
M8 gave `campaign_canon` an API for the first time (`canon_rules`), which raised
|
||||
a question the column had never had to answer: what happens when a reader edits
|
||||
the campaign's highest authority *after* a story exists?
|
||||
|
||||
Measured, against a real server with real turns:
|
||||
|
||||
- **Every turn already played keeps the canon it was actually given.** The canon
|
||||
section is part of that turn's stored context snapshot, so a historical turn
|
||||
inspected after the edit still shows the old rule and not the new one. This is
|
||||
M7's principle — provenance is the rendered text, not a foreign key — doing
|
||||
the work without being asked.
|
||||
- **The next turn is told the new canon**, which is the point of editing it.
|
||||
- **Nothing recorded is rewritten**: accepted story text, the narrative state
|
||||
document and the state audit log are all byte-identical across the edit.
|
||||
- **The edit itself is not audited**, because canon is *configuration*. It sits
|
||||
with `ai_instructions` and the narrator prompt, none of which are audited
|
||||
either, and unlike a manual state correction it asserts nothing about the
|
||||
story — it changes what the narrator is told from that point on.
|
||||
|
||||
So M5's `StateEvent` log is deliberately **not** extended to cover it. That log
|
||||
audits accepted changes to narrative state; canon is not narrative state, and
|
||||
routing a configuration change through it would create a second representation
|
||||
of canon — the same duplication `BROWSER-UX-SPEC.md` §38 forbids for hidden
|
||||
information, arrived at from a different direction.
|
||||
|
||||
What M8 added instead is at the editing surface: once a campaign has moments,
|
||||
the canon editor says that the change applies from here on, that everything
|
||||
already written stays as it is, and where the per-turn record can be seen. The
|
||||
guarantee is not "the edit is logged" but "the edit cannot be mistaken for a
|
||||
retroactive one, and every turn can prove what it was told".
|
||||
|
||||
## 14. Prompt and Provenance Inspection
|
||||
|
||||
Preserve and extend AI-DnD's Insights/context-snapshot capability.
|
||||
|
||||
@@ -18,7 +18,7 @@ I05, H06-H09)
|
||||
> return nothing.** A query unrelated to every imported source must retrieve no
|
||||
> chunks at all, and G05-G07 are only meaningful alongside that negative
|
||||
> control — without it they can all pass while retrieval is unconditional.
|
||||
> `planning/reports/M7-IMPLEMENTATION-REPORT.md` §I records how that was missed
|
||||
> `archive/milestone-reports/M7-IMPLEMENTATION-REPORT.md` §I records how that was missed
|
||||
> the first time.
|
||||
|
||||
> **Browser-level verification (M4 closeout, 2026-09-03).** The browser smoke
|
||||
@@ -411,6 +411,19 @@ speaks plain HTTP, and it will hide exactly this class of defect.
|
||||
|
||||
# B. Basic Story Interaction
|
||||
|
||||
> **M8 — browser evidence.** B01-B04 were exercised through the production
|
||||
> build in a real Firefox, against a real local narrator, with the Continuity
|
||||
> Test fixture. The M8 implementation report records each narration and what was
|
||||
> asserted about it. Two things are worth carrying here because they changed the
|
||||
> product:
|
||||
>
|
||||
> - **B01/B02 no longer need a mode.** The Do/Say/Story selector is gone; both
|
||||
> are typed into one field, and the narrator reads quoted text as speech
|
||||
> without being told which kind of turn it is.
|
||||
> - **B04 revealed a formatting defect.** AI Dungeon's `> You ` prefix, applied
|
||||
> to §11's "I enter the tavern", produced `> You I enter the tavern.` in the
|
||||
> transcript and in the replayed history. Corrected for first-person input.
|
||||
|
||||
## B01 — Natural Language Action
|
||||
|
||||
**Priority:** REQUIRED FOR V1
|
||||
@@ -627,6 +640,17 @@ Catch semantically valid-looking state proposals that do not represent the accep
|
||||
|
||||
# D. Undo, Redo, Retry, and Checkpoints
|
||||
|
||||
> **M8 — browser evidence.** The D series was established at the API level in
|
||||
> M3-M5. M8 owed the browser workflow, and D01-D14 were re-exercised end to end
|
||||
> through the production build in a real Firefox: Undo and Redo from the story
|
||||
> controls, alternate takes through the pager, editing through the transcript's
|
||||
> own controls with the explanation dialogs, and Save Points through their panel
|
||||
> — including a **genuine process restart** between creating a Save Point and
|
||||
> restoring it. Per-test evidence is in the M8 implementation report.
|
||||
>
|
||||
> None of these is marked PASS because the underlying API passed earlier. The
|
||||
> browser workflow is the thing M8 was asked to demonstrate.
|
||||
|
||||
## D01 — Undo One Turn
|
||||
|
||||
**Priority:** REQUIRED FOR V1
|
||||
|
||||
+143
-2
@@ -1,8 +1,149 @@
|
||||
# Planning Package Version
|
||||
|
||||
- **Package:** Adventure Storyteller Planning Package v3.0
|
||||
- **Package:** Adventure Storyteller Planning Package v3.3
|
||||
- **Revision date:** 2026-09-06
|
||||
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M7 implemented and accepted**; M8 is next to brief.
|
||||
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M8 implemented and accepted** (M8 closed out 2026-09-06). M9 is next and has not been started.
|
||||
|
||||
## v3.3 — M8 Closeout (2026-09-06)
|
||||
|
||||
M8 is **complete and accepted**. The independent review returned
|
||||
`M8 IMPLEMENTATION: PASS` subject to evidence and documentation cleanup; this
|
||||
revision is that cleanup. No product requirement changed and no application code
|
||||
changed in it.
|
||||
|
||||
**What M8 leaves the package with**
|
||||
|
||||
- **The browser is now the intended v1 storyteller surface**, not an adapted
|
||||
AI-DnD one. One entry point, one story screen, one natural-language field;
|
||||
State, Knowledge, Context, Save Points and campaign Settings one layer deeper
|
||||
behind panels that start closed. Branch, fork, node, head and depth appear
|
||||
nowhere a reader can see them — audited in source and in the live DOM, 0 hits.
|
||||
- **Automated frontend testing exists for the first time** — 132 tests across 10
|
||||
files. The project had none before M8.
|
||||
- **Hidden narrator information is withheld at the surface that can actually
|
||||
expose it** — advanced context inspection — rather than through a fictitious
|
||||
separate hidden-state subsystem. Withheld text is absent from the DOM, not
|
||||
collapsed inside it.
|
||||
- **Final acceptance evidence:** 950 backend passed / 14 skipped / 0 failed; 132
|
||||
frontend passed; lint, production build and Docker build clean; **157 browser
|
||||
checks across six suites, zero failures**, on one frozen build; no schema
|
||||
change, proved against an M7-built database.
|
||||
- **M9 — Export, Backup, Recovery, and Migration Hardening — is next.** Its four
|
||||
handoff questions are in the M8 report's §U.
|
||||
|
||||
**What the closeout settled**
|
||||
|
||||
- **The build-evidence contradiction.** The M8 report named two different
|
||||
frontend bundles as the artifact behind its acceptance evidence. The saved run
|
||||
logs settle it: `index-Ii-lARp9.js`, built 18:53:02 from the staged tree, is
|
||||
the one **final frozen** artifact behind all 157 browser checks.
|
||||
`index-C6E5Uvtu.js` is **superseded** — it predates finding 7's fix and its
|
||||
acceptance suite ended 54/55 on exactly that defect. The report's §P now sets
|
||||
the two side by side, and §B, §Q and §S agree with it.
|
||||
- **Finding 14 — resolved, operationally, with no application change.** Ollama's
|
||||
OpenAI-compatible endpoint ignores `num_ctx` and reloads the model at its own
|
||||
default, so the window cannot be set per request from where this application
|
||||
stands. A **derived model** created over `/api/create` carries the parameter,
|
||||
is honoured through the application's own OpenAI-compatible path, and appears
|
||||
in `/v1/models` — so the existing Settings model picker finds it with no code
|
||||
change. Measured end to end. It is now an operational note in
|
||||
`DEVELOPMENT.md`, not an unresolved M11 blocker.
|
||||
- **`BROWSER-UX-SPEC.md` §38 — ratified.** The change from *"Show Hidden Story
|
||||
State"* to the requirement on **hidden narrator information / spoilers** is
|
||||
accepted as a **requirement clarification aligned with the implemented
|
||||
architecture**, not a weakening: ordinary Story and State surfaces expose no
|
||||
narrator-only information; the advanced Context and Knowledge surfaces that
|
||||
could withhold it by default; revealing it takes an explicit, warned action;
|
||||
withheld material is **absent from the DOM**, not merely collapsed; and no
|
||||
duplicate hidden-state subsystem is required to satisfy obsolete UI wording.
|
||||
Verified by the sentinel suite, 21/21.
|
||||
|
||||
**Documents changed**
|
||||
|
||||
- `reports/M8-IMPLEMENTATION-REPORT.md` — §A, §B, §D, §P, §Q, §S (findings 13
|
||||
and 14), §T, §U and §V. §U gains the **M9 handoff**: complete campaign
|
||||
portability, historical prompt/context provenance in the bundle, legacy story
|
||||
cards, and context-window portability. Verification figures unchanged.
|
||||
- `BROWSER-UX-SPEC.md` §38 — one requirement added: withheld material must be
|
||||
**absent from the rendered DOM**, not merely visually collapsed. A closed
|
||||
`<details>` is still findable by browser search and by the accessibility tree,
|
||||
which is how finding 3 leaked.
|
||||
- `PROJECT-SOURCES.md`, `V1-ACCEPTANCE-TESTS.md` — three pointers still aimed at
|
||||
`reports/M7-IMPLEMENTATION-REPORT.md`, which M8's rotation moved to the
|
||||
archive.
|
||||
- `BUILD-MILESTONES.md` — M8 marked **COMPLETE / ACCEPTED**, M9 marked next and
|
||||
not started, finding 14's carried-forward entry reworded as resolved.
|
||||
- `README.md` (planning) — M8 complete and accepted; next is M9.
|
||||
- `VERSION.md` — this entry.
|
||||
- `DEVELOPMENT.md` — the derived-model procedure (carried from the finding-14
|
||||
investigation).
|
||||
|
||||
**Status discipline.** M8 is implemented, verified, reviewed and accepted. It is
|
||||
**not committed**: the tree is staged for the repository owner's signature. M9
|
||||
has not been started.
|
||||
|
||||
---
|
||||
|
||||
## v3.2 — M8 Final Verification Pass (2026-09-06)
|
||||
|
||||
The corrective, verification and reporting pass over M8. At the time of this
|
||||
revision M8 was **implemented and verified, not accepted**; v3.3 records the
|
||||
review outcome and the acceptance.
|
||||
|
||||
**Documents changed in this pass**
|
||||
|
||||
- `BROWSER-UX-SPEC.md` §38 — **requirement clarification.** Rewritten from
|
||||
"Hidden Narrator State" with a `Show Hidden Story State` toggle to
|
||||
"Hidden Narrator Information / Spoilers". The protection required is
|
||||
unchanged and, if anything, stated more strictly; what changed is that it
|
||||
no longer names a state subsystem that does not exist, and it now forbids
|
||||
inventing one. §100's checklist entry follows it.
|
||||
- `TECHNICAL-DESIGN.md` — **implementation fact.** Campaign canon is
|
||||
configuration; its provenance is the per-turn context snapshot, measured
|
||||
rather than assumed.
|
||||
- `V1-ACCEPTANCE-TESTS.md`, `BUILD-MILESTONES.md`, `README.md`,
|
||||
`DEVELOPMENT.md` — implementation facts and final counts.
|
||||
|
||||
**No product requirement was weakened.** The §38 change is the only one that
|
||||
touches a requirement's wording, and it tightens it.
|
||||
|
||||
---
|
||||
|
||||
## v3.1 — M8 Implementation (2026-09-06)
|
||||
|
||||
M8 turned the adapted AI-DnD interface into the interactive-story workspace
|
||||
`BROWSER-UX-SPEC.md` describes. Not yet accepted — the report is written for an
|
||||
independent reviewer.
|
||||
|
||||
**Documents changed**
|
||||
|
||||
- `BROWSER-UX-SPEC.md` — four "As implemented in M8" notes (§12 one input, §31
|
||||
the state panel and why there is no hidden-state toggle there, §55 the context
|
||||
inspector's default view, §57 click-through and the narrator-only guard). No
|
||||
requirement was altered.
|
||||
- `TECHNICAL-DESIGN.md` — new §13.4, recording that the browser stayed a
|
||||
presentation layer, the failure taxonomy, and why markup is never produced
|
||||
from input.
|
||||
- `BUILD-MILESTONES.md` — M8's outcome and the debt it carries forward.
|
||||
- `V1-ACCEPTANCE-TESTS.md` — B01-B04 and D01-D14 recorded as browser evidence.
|
||||
- `README.md`, `DEVELOPMENT.md` — one input rather than four modes, the context
|
||||
inspector, the component test suite, and the removal of "no frontend tests"
|
||||
from the inherited-debt list.
|
||||
|
||||
**What M8 established that the plan did not already say**
|
||||
|
||||
- The narrative state has **no hidden dimension**, so §38's `Show Hidden Story
|
||||
State` has nothing to reveal there. A campaign's secrets live in narrator-only
|
||||
knowledge sources, and the only ordinary screen that can surface one is the
|
||||
context inspector — which is where the guard was built.
|
||||
- `campaign_canon` had no API. It is the highest authority in a campaign, read
|
||||
by both the prompt builder and the state validator since M5, and until M8 a
|
||||
fixture had to write it with SQL.
|
||||
- AI Dungeon's `> You {text}` player-input convention is incompatible with one
|
||||
natural-language field: it produced `> You I enter the tavern.`, which a small
|
||||
model then imitates. Corrected for first-person input.
|
||||
|
||||
---
|
||||
|
||||
## v3.0 — M7 Closeout (2026-09-06)
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ document sends you here for a specific piece of historical evidence.
|
||||
|
||||
### `milestone-reports/` — the completed milestones
|
||||
|
||||
One report per milestone that has been accepted, unedited. M6's joined them at
|
||||
M7's closeout, following the convention that a milestone report is useful during
|
||||
the immediately following milestone and historical afterwards.
|
||||
One report per milestone that has been accepted, unedited. M7's joined them
|
||||
when M8's report was written, following the convention that a milestone report
|
||||
is useful during the immediately following milestone and historical afterwards.
|
||||
|
||||
### `phase0/` — why AI-DnD was selected
|
||||
|
||||
@@ -48,7 +48,9 @@ Git history.
|
||||
|
||||
`M1-BASELINE-REPORT.md`, `M1-IMPLEMENTATION-REPORT.md`,
|
||||
`M2-BASELINE-REPORT.md`, `M2-IMPLEMENTATION-REPORT.md`,
|
||||
`M3-IMPLEMENTATION-REPORT.md`.
|
||||
`M3-IMPLEMENTATION-REPORT.md`, `M4-IMPLEMENTATION-REPORT.md`,
|
||||
`M5-IMPLEMENTATION-REPORT.md`, `M6-IMPLEMENTATION-REPORT.md`,
|
||||
`M7-IMPLEMENTATION-REPORT.md`.
|
||||
|
||||
M3's report is both its review and its primary evidence record; no separate M3
|
||||
baseline report was produced. It arrived here when M4's report landed.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user