Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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