From e28d6794c9aa2fc057220a807a65329ffc9dd654 Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 09:31:34 +0100
Subject: [PATCH 01/10] docs(rq-b9zi): mockup
Co-Authored-By: Claude Opus 5.5
---
docs/ui/rq-room-name-title.html | 37 +++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 docs/ui/rq-room-name-title.html
diff --git a/docs/ui/rq-room-name-title.html b/docs/ui/rq-room-name-title.html
new file mode 100644
index 000000000..a45b0015b
--- /dev/null
+++ b/docs/ui/rq-room-name-title.html
@@ -0,0 +1,37 @@
+Room name title
The room's name: fades in, then settles into the corner
As C on entering: the name fades in over the middle of the picture and stays about three seconds. Then, instead of vanishing, it fades out of the middle and a small copy stays in the top-left corner, written on the picture as in B. So you get the moment of arrival and can still glance up and see where you are.
Entering a room you have seen before
0 s — you go in
You are inElior's Cottage
Elior's Cottage
Picture changes; marks faint.
0.6 s
You are inElior's Cottage
Elior's Cottage
The name has faded in.
3.5 s
You are inElior's Cottage
Elior's Cottage
The middle fades out, the corner fades in.
5 s and on
You are inElior's Cottage
Elior's Cottage
Small name in the corner for as long as you stay.
Laptop, after it has settled
You are inElior's Cottage
Elior's Cottage
Grandmother
Hearth
Lantern
Shelves
Books
Table
Bread
→ Village Square
☰⌖◷
Actions ▴
Settled. The same corner name as B, a little smaller.
Entering a room for the first time
As C: the name heads the arrival words. When you click past the arrival, the small name appears in the corner.
Phone
You are inElior's Cottage
You step inside. Grandmother looks up from the hearth and smiles.
Actions ▴
0.6 s. The name over the picture.
Elior's Cottage
⟲ Story so far
You step inside. Grandmother looks up from the hearth and smiles.
Actions ▴
Settled. Small name left, “⟲ Story so far” right, as in B.
Agreed details
The middle title reads “YOU ARE IN” over the room's name. The settled corner shows the name alone.
The title plays on every walk into a room, first visit or not, and when a saved game is loaded. On a first visit the name heads the arrival words and settles when they are done.
The title never stands in the way. A press on something in the picture works as usual and settles the title at once, and so does any key or a press on empty picture.
Timing: fades in over about 0.6 s, stays about 3 s, then crosses into the corner over about 1.5 s. Marks wait at a quarter strength while the middle title is up. With “reduce motion” on, there are no fades.
No one and nothing here is listed by name any more; they are reached on the picture, which a player can also Tab through. The corner name is not a button. Marks step out from under it as they do today for the card.
Phone: the corner name on the left, “⟲ Story so far” on the right. A long name such as “Submerged Antechamber” gives way with an ellipsis there; the middle title wraps onto two lines if it must.
The name is read aloud only as part of the story, as today.
Today, for comparison
You are inElior's CottageGrandmotherLanternBread
Grandmother
Hearth
Lantern
Shelves
Books
Table
Bread
→ Village Square
☰⌖◷
Actions ▴
Today. The card lists Grandmother, the lantern and the bread as buttons, a second time over the marks on the picture.
\ No newline at end of file
From 073e37e651cc449cb9d5be258aa92e1bec55e6bd Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 09:43:59 +0100
Subject: [PATCH 02/10] feat(rq-b9zi): the room card names the room alone, on
the picture
Co-Authored-By: Claude Opus 5.5
---
.../lantern/LanternLayout.phone.test.ts | 118 +--------
.../lantern/LanternLayout.scene.test.ts | 21 +-
.../components/lantern/LanternLayout.test.ts | 23 +-
web/src/components/lantern/LanternLayout.vue | 68 +-----
web/src/components/lantern/RoomPlate.test.ts | 88 ++-----
web/src/components/lantern/RoomPlate.vue | 231 ++++--------------
web/src/lantern/lantern.css | 4 +
7 files changed, 95 insertions(+), 458 deletions(-)
diff --git a/web/src/components/lantern/LanternLayout.phone.test.ts b/web/src/components/lantern/LanternLayout.phone.test.ts
index 195fd6a62..e535245d7 100644
--- a/web/src/components/lantern/LanternLayout.phone.test.ts
+++ b/web/src/components/lantern/LanternLayout.phone.test.ts
@@ -46,7 +46,8 @@ describe('LanternLayout: the stage on a phone', () => {
it('plays the room on its picture, with the plate and a folded bar', () => {
expect(w.find('.lq-stage-picture').exists()).toBe(true)
- expect(w.get('.lq-plate').text()).toContain('You are in')
+ expect(w.get('.lq-plate h2').text()).toBe(useGameStore().roomName)
+ expect(w.get('.lq-plate').text()).not.toContain('You are in')
expect(w.find('.lq-dock').exists()).toBe(true)
expect(w.get('.lq-sheet').isVisible()).toBe(false)
})
@@ -115,12 +116,9 @@ describe('LanternLayout: the stage on a phone', () => {
expect(w.find('.lq-story-drawer').exists()).toBe(false)
})
- it('reaches the chips before "⟲ Story so far" with Tab', () => {
- const plate = w.get('.lq-plate').element
- const order = [...plate.querySelectorAll('button')].map((b) =>
- b.classList.contains('lq-story-so-far') ? 'story' : 'chip',
- )
- expect(order.at(-1)).toBe('story')
+ it('has "⟲ Story so far" as the only button on the plate: no one and nothing is listed', () => {
+ const buttons = w.get('.lq-plate').findAll('button')
+ expect(buttons.map((b) => b.text())).toEqual(['⟲ Story so far'])
})
it('keeps the stage as it was left when another tab is visited', async () => {
@@ -131,112 +129,6 @@ describe('LanternLayout: the stage on a phone', () => {
})
})
- describe('upright, with little room for a conversation', () => {
- const WORDS =
- '[character_name]Grandmother[/character_name]\n[dialogue]There you are.[/dialogue]'
-
- /** The column's height changes: the observers the layout set are told. */
- const observers: (() => void)[] = []
-
- beforeEach(async () => {
- observers.length = 0
- vi.stubGlobal(
- 'ResizeObserver',
- class {
- constructor(callback: () => void) {
- observers.push(callback)
- }
- observe(): void {}
- unobserve(): void {}
- disconnect(): void {}
- },
- )
- await mountOn('upright')
- })
-
- /** A turn answered with Grandmother's words, addressed to her. */
- async function grandmotherSpeaks(): Promise {
- const game = useGameStore()
- game.turns.push({
- id: (game.turns.at(-1)?.id ?? 0) + 1,
- command: 'talk to grandmother',
- text: WORDS,
- room: 'Cottage',
- description: null,
- gained: [],
- lost: [],
- arrival: null,
- addressed: 'Grandmother',
- })
- await flushPromises()
- }
-
- /** The column has `height` px of room between the plate and the Hide. */
- async function roomIs(height: number): Promise {
- Object.defineProperty(w.get('.lq-story-wrap').element, 'clientHeight', {
- configurable: true,
- value: height,
- })
- observers.forEach((notify) => notify())
- await flushPromises()
- }
-
- const plate = () => w.get('.lq-phone-plate')
-
- it('stays open when the conversation has room', async () => {
- await grandmotherSpeaks()
- await roomIs(226)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('folds when the room is too small, and stays folded as the room grows and lines come', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- expect(plate().classes()).toContain('lq-plate-folded')
- await roomIs(140)
- await grandmotherSpeaks()
- await roomIs(58)
- expect(plate().classes()).toContain('lq-plate-folded')
- })
-
- it('does not fold without a conversation on the picture', async () => {
- await roomIs(58)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('opens again on Escape, which hides the words', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- useGameStore().dismissTitle()
- useGameStore().showModal = false
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
- await flushPromises()
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('opens again when the narration is hidden, and does not fold again while hidden', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- await w.get('.lq-words-hide').trigger('click')
- await flushPromises()
- expect(plate().classes()).not.toContain('lq-plate-folded')
- await roomIs(58)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('keeps the names in the same tab order when folded', async () => {
- await grandmotherSpeaks()
- const before = plate()
- .findAll('button')
- .map((b) => b.text())
- await roomIs(58)
- const after = plate()
- .findAll('button')
- .map((b) => b.text())
- expect(after).toEqual(before)
- })
- })
-
describe('a phone held upright', () => {
beforeEach(() => mountOn('turn'))
diff --git a/web/src/components/lantern/LanternLayout.scene.test.ts b/web/src/components/lantern/LanternLayout.scene.test.ts
index c29b5b654..dbcd31d1e 100644
--- a/web/src/components/lantern/LanternLayout.scene.test.ts
+++ b/web/src/components/lantern/LanternLayout.scene.test.ts
@@ -287,6 +287,21 @@ describe('LanternLayout: change of scene', () => {
expect(useArrivalStore().active).toBe(true)
})
+ /**
+ * Press a button in a held panel (since the room card lists nothing, the
+ * stage has no such button until the map opens): one stands in the stage.
+ */
+ async function pressHeldPanel(): Promise {
+ const panel = document.createElement('div')
+ panel.setAttribute('data-panel', '')
+ const button = document.createElement('button')
+ panel.append(button)
+ w.get('.lq-stage').element.append(panel)
+ button.click()
+ panel.remove()
+ await w.vm.$nextTick()
+ }
+
/** Submit `talk to voice` and return the turn and the index of its waking paragraph. */
async function answerVoice(
game: ReturnType,
@@ -313,7 +328,7 @@ describe('LanternLayout: change of scene', () => {
await answerVoice(game)
// Over the dream, the look is still on the left.
expect(looked()).toBe(true)
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
await w.vm.$nextTick()
expect(looked()).toBe(false)
})
@@ -342,7 +357,7 @@ describe('LanternLayout: change of scene', () => {
const narration = useNarrationStore()
const turn = await answerVoice(game)
expect(game.panelsHeld).toBe(true)
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
expect(scene.turn).toBeNull()
expect(game.musicRoomKey).toBe('EliorsCottage')
expect(game.roomName).toBe("Elior's Cottage")
@@ -387,7 +402,7 @@ describe('LanternLayout: change of scene', () => {
expect(story()).not.toContain('You answer it')
noEmptyCard()
// A press brings the cottage up at once.
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
await w.vm.$nextTick()
expect(scene.turn).toBeNull()
noEmptyCard()
diff --git a/web/src/components/lantern/LanternLayout.test.ts b/web/src/components/lantern/LanternLayout.test.ts
index c5e0942fa..d31ab088b 100644
--- a/web/src/components/lantern/LanternLayout.test.ts
+++ b/web/src/components/lantern/LanternLayout.test.ts
@@ -568,7 +568,7 @@ describe('LanternLayout', () => {
})
})
- it('opens the menu from a Here chip in the laptop room card', async () => {
+ it('lists no one and nothing in the laptop room card', async () => {
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({
@@ -579,25 +579,12 @@ describe('LanternLayout', () => {
)
wrapper = mount(LanternLayout, { attachTo: document.body })
const game = useGameStore()
- game.acceptInput = true
game.items = ["Elior's Journal"]
+ game.characters = ['grandmother']
await flushPromises()
- const submit = vi.spyOn(game, 'submitCommand').mockImplementation(() => {})
-
- await wrapper
- .get('[aria-label="Current location"] button.lq-here-item')
- .trigger('click')
- const items = wrapper.findAll('[role="menuitem"]')
- expect(items.map((m) => m.text())).toEqual([
- 'Take',
- 'Use',
- 'Open',
- 'Close',
- 'Examine',
- ])
- await items[2].trigger('click')
-
- expect(submit).toHaveBeenCalledWith("open elior's journal")
+ const plate = wrapper.get('[aria-label="Current location"]')
+ expect(plate.findAll('button')).toHaveLength(0)
+ expect(plate.text()).not.toContain('Journal')
vi.unstubAllGlobals()
})
diff --git a/web/src/components/lantern/LanternLayout.vue b/web/src/components/lantern/LanternLayout.vue
index 7c8e1dd5a..3f9cfbd87 100644
--- a/web/src/components/lantern/LanternLayout.vue
+++ b/web/src/components/lantern/LanternLayout.vue
@@ -94,8 +94,8 @@ import type { SaveSlot } from '@/types/bridge'
/**
* Shell of the Lantern UI (the only UI; old `?ui=` links open it too): header, story (one
* StoryTurn per turn) and the action composer. On laptops (≥1024 × 560) the
- * room is played on its picture, which fills the play area: a plate (room
- * and who and what is here) top-left, the buttons Bag, Spells, Quests, Story
+ * room is played on its picture, which fills the play area: the room's name
+ * top-left (`RoomPlate`; who and what is here are marked on the picture), the buttons Bag, Spells, Quests, Story
* so far and Map top-right (the journal and the story open as a drawer from
* the right, the map beneath its button), the narrator's words up the left
* side and the actions as one row along the bottom.
@@ -1060,49 +1060,6 @@ onMounted(() => {
}
})
-/** The least height (px) the column needs to show the newest line's speaker and first line whole. */
-const MIN_SPEECH_ROOM = 120
-/**
- * The phone's room plate is folded to one row. It folds when a conversation is
- * on the picture under an open column of words and the column's room
- * (its height) is under `MIN_SPEECH_ROOM`; then it stays
- * folded, so it does not fold and open line by line, until the conversation
- * is cleared or the narration is hidden. Nothing is saved.
- */
-const plateFolded = ref(false)
-
-/** The column's room as last measured (px). */
-const speechRoom = ref(Number.POSITIVE_INFINITY)
-let columnResize: ResizeObserver | undefined
-watch(
- paperEl,
- (el) => {
- columnResize?.disconnect()
- if (!el || typeof ResizeObserver === 'undefined') return
- columnResize = new ResizeObserver(() => {
- speechRoom.value = el.clientHeight
- })
- columnResize.observe(el)
- },
- { flush: 'post' },
-)
-onUnmounted(() => columnResize?.disconnect())
-
-watch(
- () =>
- [
- speech.visible.length > 0,
- paper.visible.value && !paperAway.value,
- phoneStage.value && section.value === 'story',
- speechRoom.value < MIN_SPEECH_ROOM,
- ] as const,
- ([talking, paperOpen, onPicture, cramped]) => {
- if (!talking || !paperOpen || !onPicture) plateFolded.value = false
- else if (cramped) plateFolded.value = true
- },
- { flush: 'post' },
-)
-
/** The laptop's room plate, under which the narration cards stop. */
const widePlate = ref | null>(null)
/** Where the cards' column ends at the top: the plate's foot and a gap (px from the stage's top). */
@@ -2131,8 +2088,8 @@ watch(
ref="widePlate"
class="lq-stage-plate"
:compact="sideways"
+ :bare="!pictured"
data-panel
- @click="onNameClick"
/>
{
game.items = ['old notice', 'bucket']
})
- it('says "You are in" over the room name', () => {
+ it('names the room alone, as the current location', () => {
const w = mount(RoomPlate)
- expect(w.text()).toContain('You are in')
expect(w.get('h2').text()).toBe('Village Square')
+ expect(w.attributes('aria-label')).toBe('Current location')
+ expect(w.text()).not.toContain('You are in')
})
- it('lists who and what is here, characters blue and items green', () => {
- const chips = mount(RoomPlate).findAll('.lq-here-chip')
- expect(chips.map((c) => c.text())).toEqual([
- 'Villager',
- 'Old Notice',
- 'Bucket',
- ])
- expect(chips[0].classes()).toContain('lq-here-character')
- expect(chips[1].classes()).toContain('lq-here-item')
- })
-
- it('carries what the name menu needs on each chip', () => {
- const chip = mount(RoomPlate).get('.lq-here-chip')
- expect(chip.attributes('data-name-kind')).toBe('character')
- expect(chip.attributes('data-name')).toBe('villager')
- expect(chip.attributes('aria-haspopup')).toBe('menu')
- })
-
- it('keeps chips of one name apart', () => {
- useGameStore().items = ['lantern', 'lantern', 'lantern']
- useGameStore().characters = []
- expect(mount(RoomPlate).findAll('.lq-here-chip')).toHaveLength(3)
+ it('lists no one and nothing that is here: the picture marks them', () => {
+ const w = mount(RoomPlate)
+ expect(w.find('.lq-here').exists()).toBe(false)
+ expect(w.find('[data-name-kind]').exists()).toBe(false)
+ expect(w.text()).not.toContain('Villager')
})
- it('shows only the name when nothing is here', () => {
- const game = useGameStore()
- game.characters = []
- game.items = []
+ it('shows while nothing is over the picture, and steps away while something is', async () => {
const w = mount(RoomPlate)
- expect(w.find('.lq-here').exists()).toBe(false)
- expect(w.get('h2').text()).toBe('Village Square')
+ expect(w.classes()).not.toContain('lq-plate-away')
+ await w.setProps({ away: true })
+ expect(w.classes()).toContain('lq-plate-away')
})
it('has no Story so far button unless the phone stage asks for it', () => {
expect(mount(RoomPlate).find('.lq-story-so-far').exists()).toBe(false)
})
- it('shows "⟲ Story so far" in the corner on the phone stage, and says when it is pressed', async () => {
+ it('shows "⟲ Story so far" beside the name on the phone stage, and says when it is pressed', async () => {
const w = mount(RoomPlate, { props: { storyButton: true } })
expect(w.get('.lq-story-so-far').text()).toBe('⟲ Story so far')
await w.get('.lq-story-so-far').trigger('click')
expect(w.emitted('story')).toHaveLength(1)
})
- describe('folded', () => {
- it('eases in once when it opens again from folded', async () => {
- const w = mount(RoomPlate, { props: { folded: true } })
- expect(w.classes()).not.toContain('lq-plate-opening')
- await w.setProps({ folded: false })
- expect(w.classes()).toContain('lq-plate-opening')
- await w.trigger('animationend', { animationName: 'lq-plate-fold' })
- expect(w.classes()).not.toContain('lq-plate-opening')
- })
-
- it('is open (with its label) unless folded', () => {
- const w = mount(RoomPlate, { props: { storyButton: true } })
- expect(w.classes()).not.toContain('lq-plate-folded')
- })
-
- it('folds to one row, keeping every name and the button in the open order', () => {
- const open = mount(RoomPlate, { props: { storyButton: true } })
- const folded = mount(RoomPlate, {
- props: { storyButton: true, folded: true },
- })
- expect(folded.classes()).toContain('lq-plate-folded')
- const order = (w: typeof open): string[] =>
- w.findAll('h2, .lq-here-chip, .lq-story-so-far').map((e) => e.text())
- expect(order(folded)).toEqual(order(open))
- expect(folded.get('.lq-label').attributes('aria-hidden')).toBe('true')
- })
- })
-
- describe('compact', () => {
- it('is one row: the label hidden, the name beside the chips in the open order', () => {
- const open = mount(RoomPlate)
- const compact = mount(RoomPlate, { props: { compact: true } })
- expect(compact.classes()).toContain('lq-plate-compact')
- expect(open.classes()).not.toContain('lq-plate-compact')
- expect(compact.get('.lq-label').attributes('aria-hidden')).toBe('true')
- const order = (w: typeof open): string[] =>
- w.findAll('h2, .lq-here-chip').map((e) => e.text())
- expect(order(compact)).toEqual(order(open))
- })
+ it('is smaller on a phone held sideways (compact)', () => {
+ expect(mount(RoomPlate, { props: { compact: true } }).classes()).toContain(
+ 'lq-plate-compact',
+ )
+ expect(mount(RoomPlate).classes()).not.toContain('lq-plate-compact')
})
})
diff --git a/web/src/components/lantern/RoomPlate.vue b/web/src/components/lantern/RoomPlate.vue
index a00774047..a55f903ef 100644
--- a/web/src/components/lantern/RoomPlate.vue
+++ b/web/src/components/lantern/RoomPlate.vue
@@ -1,66 +1,30 @@
+
+
+
+
+ You are in
+ {{ title.name }}
+
+
+
+
+
+
diff --git a/web/src/stores/useRoomTitleStore.ts b/web/src/stores/useRoomTitleStore.ts
index 3b2993af7..942f3729d 100644
--- a/web/src/stores/useRoomTitleStore.ts
+++ b/web/src/stores/useRoomTitleStore.ts
@@ -63,5 +63,5 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
{ immediate: true },
)
- return { name, staged, settle }
+ return { name, staged, greet, settle }
})
From 4e122b7a8dcc85dcd6b78648b888463f17a77d1d Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 09:49:41 +0100
Subject: [PATCH 05/10] feat(rq-b9zi): a first visit's arrival is headed by
"You are in" and the room
Co-Authored-By: Claude Opus 5.5
---
.../lantern/ArrivalOnPicture.test.ts | 24 ++++++++++++
.../components/lantern/ArrivalOnPicture.vue | 37 ++++++++++++++++++-
web/src/lantern/stageArrival.test.ts | 1 +
web/src/lantern/stageArrival.ts | 11 +++++-
4 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/web/src/components/lantern/ArrivalOnPicture.test.ts b/web/src/components/lantern/ArrivalOnPicture.test.ts
index 7aed08541..5f557bc8c 100644
--- a/web/src/components/lantern/ArrivalOnPicture.test.ts
+++ b/web/src/components/lantern/ArrivalOnPicture.test.ts
@@ -82,6 +82,30 @@ describe('ArrivalOnPicture', () => {
expect(w.classes()).toContain('lq-on')
})
+ it('heads the description with "You are in" and the room, as the room\'s title would', async () => {
+ const w = mount(ArrivalOnPicture, { props: { pictured: true } })
+ useArrivalStore().show(
+ 1,
+ [{ ...description, room: 'Village Square' }],
+ false,
+ )
+ await w.vm.$nextTick()
+ const head = w.get('[data-testid="arrival-room"]')
+ expect(head.get('.lq-room-kicker').text()).toBe('You are in')
+ expect(head.get('.lq-room-head-name').text()).toBe('Village Square')
+ })
+
+ it('heads what happens on arrival with nothing', async () => {
+ const w = mount(ArrivalOnPicture, { props: { pictured: true } })
+ useArrivalStore().show(
+ 1,
+ [events, { ...description, room: 'Square' }],
+ false,
+ )
+ await w.vm.$nextTick()
+ expect(w.find('[data-testid="arrival-room"]').exists()).toBe(false)
+ })
+
it('tells what happens in italics, and goes on to the description on a click', async () => {
const w = mount(ArrivalOnPicture, { props: { pictured: true } })
useArrivalStore().show(1, [events, description], false)
diff --git a/web/src/components/lantern/ArrivalOnPicture.vue b/web/src/components/lantern/ArrivalOnPicture.vue
index 54148b686..8e5dc922b 100644
--- a/web/src/components/lantern/ArrivalOnPicture.vue
+++ b/web/src/components/lantern/ArrivalOnPicture.vue
@@ -20,7 +20,8 @@ import { useSettingsStore } from '@/stores/useSettingsStore'
* The arrival told on the middle of the room's picture, like a film title:
* light serif text with a soft dark glow, no paper, at most 760 px wide. First
* what happens as the player arrives (in italics), then the room's
- * description, the spoken word lit while the narrator tells it. Under it a
+ * description under a heading ("You are in" and the room's name, lit as
+ * the narrator says it), the spoken word lit while the narrator tells it. Under it a
* small dark pill holds the hint and, while nobody speaks the part, a thin bar
* that empties over its reading time. A click anywhere on the picture goes on
* (`useArrivalStore`).
@@ -69,7 +70,7 @@ const { html, mark } = useReadAlong(() => arrival.turnId ?? 0, root)
const twin = ref(null)
/** The pill under the text. */
const pill = ref(null)
-/** The head of a piece of quest news (seal, heading, name), above the text. */
+/** The head above the text: a piece of quest news (seal, heading, name), or a first visit's "You are in" and the room. */
const head = ref(null)
/** The quest news of the part up, or null. */
const news = computed(() => arrival.part?.news ?? null)
@@ -366,6 +367,21 @@ onBeforeUnmount(() => {
>
+
+
You are in
+
+
+
+
+
{
.lq-arrival-stage .lq-news-kicker.lq-news-kicker-spell {
color: var(--lq-spell);
}
+/* A first visit's heading: "You are in" and the room, as the room's title has them. */
+.lq-arrival-stage .lq-news-kicker.lq-room-kicker {
+ color: inherit;
+ font-size: 12px;
+ font-weight: 400;
+ letter-spacing: 0.3em;
+}
+.lq-room-head-rule {
+ width: 120px;
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgb(255 230 180 / 0.8),
+ transparent
+ );
+}
.lq-news-name {
margin: 0;
font-family: var(--lq-font-display);
diff --git a/web/src/lantern/stageArrival.test.ts b/web/src/lantern/stageArrival.test.ts
index 55d9f76bf..49dda43b4 100644
--- a/web/src/lantern/stageArrival.test.ts
+++ b/web/src/lantern/stageArrival.test.ts
@@ -90,6 +90,7 @@ describe('arrivalParts', () => {
kind: 'description',
lines: [{ unit: 0, markup: room.description, wordBase: 1 }],
voiced: true,
+ room: 'x',
},
])
})
diff --git a/web/src/lantern/stageArrival.ts b/web/src/lantern/stageArrival.ts
index 289bf9ec9..75de6425a 100644
--- a/web/src/lantern/stageArrival.ts
+++ b/web/src/lantern/stageArrival.ts
@@ -43,6 +43,8 @@ export interface ArrivalPart {
lines: ArrivalLine[]
/** Whether the narrator speaks any of it (it has a recording). */
voiced: boolean
+ /** For `description`: the room's name (the first part of its narration unit), its heading on the picture. */
+ room?: string
/** The first part of an arrival whose talk is told on the sides first (`sideBlocks`): it waits until those words are told. */
afterWords?: true
}
@@ -177,8 +179,13 @@ export function arrivalParts(
wordBase: narrationWords(name).length,
},
]
- // The unit's first part is the room's name, which the plate shows.
- parts.push({ kind: 'description', lines, voiced: spoken(lines, 1) })
+ // The unit's first part is the room's name: the description's heading, not its voice.
+ parts.push({
+ kind: 'description',
+ lines,
+ voiced: spoken(lines, 1),
+ room: name,
+ })
}
if (aside > 0 && parts.length) parts[0].afterWords = true
return parts
From 1b8296ca322ec6bfe6ea25fe5924f3493865ee56 Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 10:18:51 +0100
Subject: [PATCH 06/10] test(rq-b9zi): end-to-end room title; specs follow the
one-row plate
Co-Authored-By: Claude Opus 5.5
---
web/e2e/dreamEnding.spec.ts | 4 +-
web/e2e/lantern.spec.ts | 2 +-
web/e2e/phoneSideways.spec.ts | 20 +++--
web/e2e/questNews.spec.ts | 17 ++--
web/e2e/roomTitle.spec.ts | 88 +++++++++++++++++++
.../components/lantern/ArrivalOnPicture.vue | 31 +++++--
web/src/components/lantern/LanternLayout.vue | 8 +-
web/src/components/lantern/RoomPlate.vue | 6 +-
web/src/components/lantern/RoomTitle.vue | 3 +-
9 files changed, 154 insertions(+), 25 deletions(-)
create mode 100644 web/e2e/roomTitle.spec.ts
diff --git a/web/e2e/dreamEnding.spec.ts b/web/e2e/dreamEnding.spec.ts
index 31197edc4..8b070f566 100644
--- a/web/e2e/dreamEnding.spec.ts
+++ b/web/e2e/dreamEnding.spec.ts
@@ -50,7 +50,9 @@ for (const command of ['talk to voice', 'say who are you to voice']) {
const middle = page.getByTestId('arrival-on-picture')
await expect(middle).toContainText('A modest cottage')
- await expect(middle).not.toContainText('You are in')
+ await expect(middle.locator('.lq-arrival-text')).not.toContainText(
+ 'You are in',
+ )
await expect(middle).not.toContainText('The whisper comes once more')
await expect(middle).not.toContainText('let them rest')
diff --git a/web/e2e/lantern.spec.ts b/web/e2e/lantern.spec.ts
index 9a1780b11..327857c81 100644
--- a/web/e2e/lantern.spec.ts
+++ b/web/e2e/lantern.spec.ts
@@ -562,8 +562,8 @@ test.describe('Lantern UI (the default)', () => {
})
const plate = page.getByRole('region', { name: 'Current location' })
- await expect(plate).toContainText('You are in')
await expect(plate).toContainText('Village Square')
+ await expect(plate).not.toContainText('You are in')
for (const name of [/Quests · 0/, /Story so far/])
await expect(page.getByRole('button', { name })).toBeVisible()
// The Bag and Spells are the belt of pictures at the foot of the picture.
diff --git a/web/e2e/phoneSideways.spec.ts b/web/e2e/phoneSideways.spec.ts
index 1965aebc9..ca3a12b5f 100644
--- a/web/e2e/phoneSideways.spec.ts
+++ b/web/e2e/phoneSideways.spec.ts
@@ -40,7 +40,7 @@ for (const size of [
await expect(page.locator('.lq-nav-bottom')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Actions ▴' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Hint' })).toBeVisible()
- // One compact row, however many chips: it never covers the words.
+ // One compact row: it never covers the words.
const plate = await page.locator('.lq-stage-plate').boundingBox()
expect(plate?.height ?? 999).toBeLessThan(60)
// And it ends before the first button on the right.
@@ -126,19 +126,23 @@ for (const size of [
})
}
-test('a name too long for the plate gives way so the chips keep a share', async ({
+test('a name too long for the corner stays one row and ends before the buttons', async ({
page,
}): Promise => {
await page.setViewportSize({ width: 568, height: 320 })
await start(page)
await lightDots(page, 'Submerged Antechamber')
+ const name = page.locator('.lq-stage-plate .lq-room-name')
+ await expect(name).toHaveText('Submerged Antechamber')
await expect
- .poll(() =>
- page
- .locator('.lq-stage-plate .lq-here')
- .evaluate((el) => el.getBoundingClientRect().width),
- )
- .toBeGreaterThanOrEqual(48)
+ .poll(async (): Promise => {
+ const plate = await page.locator('.lq-stage-plate').boundingBox()
+ const buttons = await page.locator('.lq-stage-buttons').boundingBox()
+ return (buttons?.x ?? 0) - ((plate?.x ?? 0) + (plate?.width ?? 999))
+ })
+ .toBeGreaterThanOrEqual(0)
+ const row = await name.boundingBox()
+ expect(row?.height ?? 999).toBeLessThan(30)
})
test('a tablet held upright keeps the tall stage, with no turn page', async ({
diff --git a/web/e2e/questNews.spec.ts b/web/e2e/questNews.spec.ts
index 612c688df..680894fd1 100644
--- a/web/e2e/questNews.spec.ts
+++ b/web/e2e/questNews.spec.ts
@@ -152,9 +152,11 @@ test.describe('quest news told in the middle of the picture', () => {
})
}
+ // Heights at which a page holds the short need alone, under the one-row
+ // room plate (about 94 px lower than under the old room card).
for (const [width, height] of [
- [850, 840],
- [390, 720],
+ [850, 740],
+ [390, 620],
]) {
test(`keeps one left edge for a checklist split over pages at ${width} wide`, async ({
page,
@@ -189,10 +191,13 @@ test.describe('quest news told in the middle of the picture', () => {
if ((await list.first().locator('li').count()) === 1) alone = first
const bounds = await list.first().boundingBox({ timeout: 1500 })
lefts[first] = Math.round(bounds?.x ?? -1)
- // The last click ends the news and takes the card away.
- await middle(page)
- .click({ position: { x: 20, y: 20 }, timeout: 1500 })
- .catch(() => undefined)
+ // Enter, with no control focused, goes on a page; the last ends the
+ // news and takes the card away.
+ await page.evaluate(() =>
+ (document.activeElement as HTMLElement | null)?.blur(),
+ )
+ await page.keyboard.press('Enter')
+ await page.waitForTimeout(300)
}
expect(Object.keys(lefts).length).toBeGreaterThan(1)
// A page holds the short need alone: the case that shrank the line.
diff --git a/web/e2e/roomTitle.spec.ts b/web/e2e/roomTitle.spec.ts
new file mode 100644
index 000000000..e9af1bfa6
--- /dev/null
+++ b/web/e2e/roomTitle.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * The room's title (rq-b9zi): walking back into a room tells "You are in" and
+ * its name over the middle of the picture, which then settles as the room's
+ * name alone in the corner; any key settles it at once. The room card lists
+ * no one and nothing.
+ */
+import { test, expect, type Page } from '@playwright/test'
+
+interface VueHost {
+ __vue_app__: {
+ config: {
+ globalProperties: {
+ $pinia: { state: { value: { game: Record } } }
+ }
+ }
+ }
+}
+
+/** Open the real game past its title page, play Act I's script, and leave the command box open. */
+async function playedAct(page: Page): Promise {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await page.goto('/')
+ await page.waitForFunction(() => {
+ const app = (document.querySelector('#app') as unknown as VueHost)
+ .__vue_app__
+ return app && !app.config.globalProperties.$pinia.state.value.game.loading
+ })
+ await page.evaluate(() => {
+ const app = (document.querySelector('#app') as unknown as VueHost)
+ .__vue_app__
+ const store = app.config.globalProperties.$pinia.state.value.game
+ store.showTitle = false
+ store.showModal = false
+ store.modalChapter = null
+ store.modalQuest = null
+ store.modalQueue = []
+ store.acceptInput = true
+ })
+ await page
+ .getByRole('region', { name: 'Actions' })
+ .getByRole('button', { name: 'Type', exact: true })
+ .click()
+ await command(page, 'debug act1')
+ await expect(page.getByText("Debug script 'act1' played")).toBeVisible()
+}
+
+/** Type `text` in the command box and send it. */
+async function command(page: Page, text: string): Promise {
+ const input = page.getByLabel('Command', { exact: true })
+ await input.fill(text)
+ await input.press('Enter')
+}
+
+/** Walk back west into a room already visited, and bring its picture up. */
+async function walkBack(page: Page): Promise {
+ await command(page, 'go west')
+ await expect(page.locator('.lq-stage-picture.lq-scene-idle')).toHaveCount(1)
+}
+
+test('walking back into a room tells its name over the picture, then in the corner', async ({
+ page,
+}): Promise => {
+ await playedAct(page)
+ await walkBack(page)
+ const title = page.getByTestId('room-title')
+ await expect(title).toBeVisible()
+ await expect(title).toContainText('You are in')
+ const name = (await title.locator('.lq-room-title-name').textContent()) ?? ''
+ expect(name.length).toBeGreaterThan(0)
+ const corner = page.locator('.lq-stage-plate .lq-room-name')
+ await expect(corner).toHaveText(name)
+ await expect(corner).toHaveCSS('opacity', '0')
+ // It settles by itself: the title goes and the corner's name comes back.
+ await expect(title).toHaveCount(0, { timeout: 8000 })
+ await expect(corner).toHaveCSS('opacity', '1', { timeout: 4000 })
+ await expect(
+ page.getByRole('region', { name: 'Current location' }),
+ ).not.toContainText('You are in')
+})
+
+test('any key settles the title at once', async ({ page }): Promise => {
+ await playedAct(page)
+ await walkBack(page)
+ const title = page.getByTestId('room-title')
+ await expect(title).toBeVisible()
+ await page.keyboard.press('Shift')
+ await expect(title).toHaveCount(0, { timeout: 2500 })
+})
diff --git a/web/src/components/lantern/ArrivalOnPicture.vue b/web/src/components/lantern/ArrivalOnPicture.vue
index 8e5dc922b..ff78b08ab 100644
--- a/web/src/components/lantern/ArrivalOnPicture.vue
+++ b/web/src/components/lantern/ArrivalOnPicture.vue
@@ -370,13 +370,13 @@ onBeforeUnmount(() => {
+
+
Date: Thu, 12 Nov 2026 10:42:21 +0100
Subject: [PATCH 07/10] fix(rq-b9zi): a load greets its room once the picture
is up; news leaves the corner name
Answers the review: the title store waits for any changed room (or a fresh
stage) until its picture is up, so a load and a quick walk back are greeted;
the corner name steps away only for an arrival that tells a room; the
sideways 2 px trim is the news head's alone; e2e presses the priest through
the title and pages quest news by waiting on the page change.
Co-Authored-By: Claude Opus 5.5
---
.cerebro/cerebro | 2 +-
web/e2e/questNews.spec.ts | 6 +-
web/e2e/roomTitle.spec.ts | 36 ++++++++++-
.../components/lantern/ArrivalOnPicture.vue | 4 +-
.../lantern/LanternLayout.title.test.ts | 33 +++++++++++
web/src/components/lantern/LanternLayout.vue | 6 +-
web/src/stores/useArrivalStore.test.ts | 14 +++++
web/src/stores/useArrivalStore.ts | 7 +++
web/src/stores/useRoomTitleStore.test.ts | 23 ++++++++
web/src/stores/useRoomTitleStore.ts | 59 +++++++++++++------
10 files changed, 165 insertions(+), 25 deletions(-)
diff --git a/.cerebro/cerebro b/.cerebro/cerebro
index 23458d8b8..9d7c51216 160000
--- a/.cerebro/cerebro
+++ b/.cerebro/cerebro
@@ -1 +1 @@
-Subproject commit 23458d8b892d3674e76f8a7890305c9259d542bf
+Subproject commit 9d7c51216a75f6c7b8a1854066a7793474c42485
diff --git a/web/e2e/questNews.spec.ts b/web/e2e/questNews.spec.ts
index 680894fd1..f381c7ee3 100644
--- a/web/e2e/questNews.spec.ts
+++ b/web/e2e/questNews.spec.ts
@@ -197,7 +197,11 @@ test.describe('quest news told in the middle of the picture', () => {
(document.activeElement as HTMLElement | null)?.blur(),
)
await page.keyboard.press('Enter')
- await page.waitForTimeout(300)
+ await expect
+ .poll(async () =>
+ (await list.count()) === 0 ? '' : list.first().innerText(),
+ )
+ .not.toBe(first)
}
expect(Object.keys(lefts).length).toBeGreaterThan(1)
// A page holds the short need alone: the case that shrank the line.
diff --git a/web/e2e/roomTitle.spec.ts b/web/e2e/roomTitle.spec.ts
index e9af1bfa6..1009a213e 100644
--- a/web/e2e/roomTitle.spec.ts
+++ b/web/e2e/roomTitle.spec.ts
@@ -1,8 +1,8 @@
/**
* The room's title (rq-b9zi): walking back into a room tells "You are in" and
* its name over the middle of the picture, which then settles as the room's
- * name alone in the corner; any key settles it at once. The room card lists
- * no one and nothing.
+ * name alone in the corner; any key settles it at once, and a press on a
+ * thing in the picture goes through it. The room card lists no one and nothing.
*/
import { test, expect, type Page } from '@playwright/test'
@@ -78,6 +78,38 @@ test('walking back into a room tells its name over the picture, then in the corn
).not.toContainText('You are in')
})
+test('a press on someone in the picture goes through the title, opens their ring and settles it', async ({
+ page,
+}): Promise => {
+ // The studio has placed the priest on the chapel's picture.
+ await page.route('**/images/generated/manifest.json', async (route) => {
+ const response = await route.fetch()
+ const manifest = (await response.json()) as Record<
+ string,
+ Record
+ >
+ manifest['act1-village-chapel'] = {
+ ...manifest['act1-village-chapel'],
+ areas: { priest: { x: 0.36, y: 0.5, width: 0.1, height: 0.3 } },
+ }
+ await route.fulfill({ json: manifest })
+ })
+ await playedAct(page)
+ await walkBack(page)
+ const title = page.getByTestId('room-title')
+ await expect(title).toHaveText(/Village Chapel/)
+ // The press lands where the priest is; the picture drifts, so the mouse
+ // goes there rather than waiting for the button to stand still.
+ const priest = await page.locator('[data-area-press="priest"]').boundingBox()
+ if (!priest) throw new Error('the priest is not placed')
+ await page.mouse.click(
+ priest.x + priest.width / 2,
+ priest.y + priest.height / 2,
+ )
+ await expect(page.getByRole('menu')).toBeVisible()
+ await expect(title).toHaveCount(0, { timeout: 2500 })
+})
+
test('any key settles the title at once', async ({ page }): Promise => {
await playedAct(page)
await walkBack(page)
diff --git a/web/src/components/lantern/ArrivalOnPicture.vue b/web/src/components/lantern/ArrivalOnPicture.vue
index ff78b08ab..f835b517d 100644
--- a/web/src/components/lantern/ArrivalOnPicture.vue
+++ b/web/src/components/lantern/ArrivalOnPicture.vue
@@ -119,7 +119,9 @@ function room(): number {
const style = getComputedStyle(stage)
const padding =
(parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0)
- const trim = head.value && props.sideways ? SIDEWAYS_TITLE_TRIM : 0
+ // Only a news head gives the 2 px back on a sideways phone; the room's heading does not.
+ const trim =
+ head.value && piece.value && props.sideways ? SIDEWAYS_TITLE_TRIM : 0
const gaps = (head.value ? 2 * CARD_GAP : CARD_GAP) - trim
return (
stage.clientHeight -
diff --git a/web/src/components/lantern/LanternLayout.title.test.ts b/web/src/components/lantern/LanternLayout.title.test.ts
index 0adf95ecb..1b4c2e8b5 100644
--- a/web/src/components/lantern/LanternLayout.title.test.ts
+++ b/web/src/components/lantern/LanternLayout.title.test.ts
@@ -116,6 +116,39 @@ describe('LanternLayout: the room title', () => {
expect(plate().classes()).not.toContain('lq-plate-away')
})
+ it('greets the room when a game is loaded in it, once its picture is up', async () => {
+ await stage()
+ const game = useGameStore()
+ // A loaded story is not narrated again (as restoreStory leaves it).
+ game.turns = game.turns.map((turn) => ({ ...turn, narrate: undefined }))
+ game.loadNumber++
+ await w.vm.$nextTick()
+ await w.vm.$nextTick()
+ const loading = w.find('img.lq-loading')
+ if (loading.exists()) await loading.trigger('load')
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBe('Hall')
+ expect(title().text()).toContain('Hall')
+ })
+
+ it('keeps the corner name while the picture tells no room (news, what happens)', async () => {
+ await stage()
+ useArrivalStore().show(
+ useGameStore().turns.at(-1)!.id,
+ [
+ {
+ kind: 'events',
+ lines: [{ unit: 0, markup: 'A bell.' }],
+ voiced: false,
+ },
+ ],
+ false,
+ )
+ await w.vm.$nextTick()
+ expect(useArrivalStore().active).toBe(true)
+ expect(plate().classes()).not.toContain('lq-plate-away')
+ })
+
it('has no title on the text page', async () => {
await stage(false)
expect(useRoomTitleStore().staged).toBe(false)
diff --git a/web/src/components/lantern/LanternLayout.vue b/web/src/components/lantern/LanternLayout.vue
index c8453aea8..02791aec6 100644
--- a/web/src/components/lantern/LanternLayout.vue
+++ b/web/src/components/lantern/LanternLayout.vue
@@ -279,7 +279,7 @@ const stageKey = computed(
() => `${game.gameNumber}-${game.loadNumber}-${game.musicActName}`,
)
watch(stageKey, () => {
- roomTitle.settle()
+ roomTitle.reset()
drawerTab.value = null
speech.clear()
scene.reset()
@@ -294,8 +294,8 @@ watch(
},
{ immediate: true },
)
-/** Whether the room's name in the corner steps away: its title, or an arrival, is told over the picture. */
-const plateAway = computed(() => roomTitle.name !== null || arrival.active)
+/** Whether the room's name in the corner steps away: its title, or a first visit's arrival, is told over the picture (news alone leaves it). */
+const plateAway = computed(() => roomTitle.name !== null || arrival.tellsRoom)
/** Any key or press settles the room's title into the corner at once; the key or press still does what it does. */
function settleRoomTitle(): void {
if (roomTitle.name !== null) roomTitle.settle()
diff --git a/web/src/stores/useArrivalStore.test.ts b/web/src/stores/useArrivalStore.test.ts
index 79a3226c0..5ae520853 100644
--- a/web/src/stores/useArrivalStore.test.ts
+++ b/web/src/stores/useArrivalStore.test.ts
@@ -94,6 +94,20 @@ describe('useArrivalStore', () => {
})
afterEach(() => vi.useRealTimers())
+ it('tells a room while an arrival with a description is up, from its first part on', () => {
+ expect(arrival.tellsRoom).toBe(false)
+ arrival.show(1, parts(true, false), false)
+ expect(arrival.tellsRoom).toBe(true)
+ arrival.clear()
+ expect(arrival.tellsRoom).toBe(false)
+ })
+
+ it('tells no room for what happens alone', () => {
+ arrival.show(1, parts(true, false).slice(0, 1), false)
+ expect(arrival.active).toBe(true)
+ expect(arrival.tellsRoom).toBe(false)
+ })
+
describe('when nobody narrates it', () => {
it('stays for its reading time, shown by the bar, then clears', () => {
arrival.show(1, parts(false, false), false)
diff --git a/web/src/stores/useArrivalStore.ts b/web/src/stores/useArrivalStore.ts
index 57f01dcc4..8bf620be2 100644
--- a/web/src/stores/useArrivalStore.ts
+++ b/web/src/stores/useArrivalStore.ts
@@ -73,6 +73,12 @@ export const useArrivalStore = defineStore('arrival', () => {
/** Whether an arrival is on the picture. */
const active = computed(() => !!current.value?.shown)
+ /** Whether the arrival on the picture tells a room (it has a description), not news alone. */
+ const tellsRoom = computed(
+ () =>
+ active.value &&
+ !!current.value?.parts.some((p) => p.kind === 'description'),
+ )
/** The turn whose arrival is up, or null. */
const turnId = computed(() => current.value?.turnId ?? null)
/** The part up now. */
@@ -412,6 +418,7 @@ export const useArrivalStore = defineStore('arrival', () => {
return {
active,
+ tellsRoom,
turnId,
part,
page,
diff --git a/web/src/stores/useRoomTitleStore.test.ts b/web/src/stores/useRoomTitleStore.test.ts
index 9a690b2ab..a83b5b0c7 100644
--- a/web/src/stores/useRoomTitleStore.test.ts
+++ b/web/src/stores/useRoomTitleStore.test.ts
@@ -84,6 +84,29 @@ describe('useRoomTitleStore', () => {
expect(title.name).toBe('Village Square')
})
+ it('greets a room walked back into before the change of scene to another was up', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ scene.phase = 'in'
+ await enter('Village Well', moveTurn(3, false))
+ await enter('Village Square', moveTurn(4, false))
+ scene.phase = 'idle'
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('greets the room again once a fresh stage is up (a load), not over the old picture', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ title.reset()
+ scene.reset()
+ await nextTick()
+ expect(title.name).toBeNull()
+ scene.settled('square.png')
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
it('waits for a change of scene to bring the picture up', async () => {
scene.phase = 'in'
await enter('Village Square', moveTurn(2, false))
diff --git a/web/src/stores/useRoomTitleStore.ts b/web/src/stores/useRoomTitleStore.ts
index 942f3729d..cc65d934a 100644
--- a/web/src/stores/useRoomTitleStore.ts
+++ b/web/src/stores/useRoomTitleStore.ts
@@ -15,7 +15,8 @@ export const ROOM_TITLE_MS = 3600
* covers it and its picture is up (after a change of scene). A first visit is
* not greeted here: its arrival is told on the picture with the name as its
* heading. The name switches as the narrator reaches the room (the game
- * store's held `roomName`), so the title follows the voice. Nothing is saved.
+ * store's held `roomName`), so the title follows the voice; walking on before
+ * a change of scene is up greets only the room the player ends in. Nothing is saved.
*/
export const useRoomTitleStore = defineStore('roomTitle', () => {
const game = useGameStore()
@@ -25,8 +26,8 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
const name = ref(null)
/** Whether the room is played on its picture (the layout says): no title on the text page. */
const staged = ref(false)
- /** The arrival last greeted (or let pass): the game, the load and the room. */
- let greeted = ''
+ /** The room changed (or a game was started or loaded) and has not been greeted or let pass yet. */
+ let pending = false
let timer: ReturnType | undefined
/** Put the title up for `room`, settling after `ROOM_TITLE_MS`. */
@@ -43,25 +44,49 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
name.value = null
}
+ /** The stage starts afresh (a new game, a load, a new act): settle, and greet the room once its picture is up again. */
+ function reset(): void {
+ settle()
+ pending = true
+ }
+
+ /** Whether a greeting can be shown now: a room, no chapter page, and its picture up after any change of scene. */
+ function ready(): boolean {
+ return (
+ game.roomName !== '' &&
+ !game.showModal &&
+ scene.phase === 'idle' &&
+ scene.shown !== null
+ )
+ }
+
+ /** Greet the room waiting for it, or let it pass: on the text page, or on a first visit, whose arrival is headed by the name. */
+ function consider(): void {
+ if (!pending || !ready()) return
+ pending = false
+ const latest = game.turns.at(-1)
+ if (!staged.value || (latest && arrivesOnPicture(latest))) settle()
+ else greet(game.roomName)
+ }
+
+ watch(
+ () => [game.gameNumber, game.loadNumber, game.roomName] as const,
+ () => {
+ pending = true
+ consider()
+ },
+ { immediate: true },
+ )
watch(
() =>
[
- `${game.gameNumber}-${game.loadNumber}-${game.roomName}`,
- game.roomName !== '' &&
- !game.showModal &&
- scene.phase === 'idle' &&
- scene.shown !== null,
+ game.showModal,
+ scene.phase,
+ scene.shown !== null,
staged.value,
] as const,
- ([key, ready, onStage]) => {
- if (!ready || key === greeted) return
- greeted = key
- const latest = game.turns.at(-1)
- if (!onStage || (latest && arrivesOnPicture(latest))) settle()
- else greet(game.roomName)
- },
- { immediate: true },
+ consider,
)
- return { name, staged, greet, settle }
+ return { name, staged, greet, settle, reset }
})
From b6d27331b8dd06ccfd291325cc3c110c7c2c692b Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 11:57:56 +0100
Subject: [PATCH 08/10] fix(rq-b9zi): a load into another room is greeted over
its own picture
The stage settles the old picture again at once after a reset; the title
store now waits out that picture when the room has another, and greets
over the new one (or the backdrop when it has none). Restores main's
cerebro submodule pointer.
Co-Authored-By: Claude Opus 5.5
---
.../lantern/LanternLayout.title.test.ts | 15 +++++++++++++++
web/src/stores/useRoomTitleStore.test.ts | 15 +++++++++++++++
web/src/stores/useRoomTitleStore.ts | 18 ++++++++++++++++--
3 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/web/src/components/lantern/LanternLayout.title.test.ts b/web/src/components/lantern/LanternLayout.title.test.ts
index 1b4c2e8b5..4347df2f0 100644
--- a/web/src/components/lantern/LanternLayout.title.test.ts
+++ b/web/src/components/lantern/LanternLayout.title.test.ts
@@ -131,6 +131,21 @@ describe('LanternLayout: the room title', () => {
expect(title().text()).toContain('Hall')
})
+ it('greets a room loaded from another only once its own picture is up, never over the old one', async () => {
+ await stage()
+ const game = useGameStore()
+ game.turns = game.turns.map((turn) => ({ ...turn, narrate: undefined }))
+ game.musicRoomKey = 'Garden'
+ game.roomName = 'Garden'
+ game.loadNumber++
+ await w.vm.$nextTick()
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBeNull()
+ await w.get('img.lq-loading').trigger('load')
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBe('Garden')
+ })
+
it('keeps the corner name while the picture tells no room (news, what happens)', async () => {
await stage()
useArrivalStore().show(
diff --git a/web/src/stores/useRoomTitleStore.test.ts b/web/src/stores/useRoomTitleStore.test.ts
index a83b5b0c7..cd884de8b 100644
--- a/web/src/stores/useRoomTitleStore.test.ts
+++ b/web/src/stores/useRoomTitleStore.test.ts
@@ -107,6 +107,21 @@ describe('useRoomTitleStore', () => {
expect(title.name).toBe('Village Square')
})
+ it('after a load into another room, waits out the old picture and greets over the new one, or the backdrop when it has none', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.musicRoomKey = 'VillageWell'
+ title.reset()
+ scene.reset()
+ scene.settled('cottage.png')
+ await enter('Village Well', moveTurn(3, false))
+ expect(title.name).toBeNull()
+ scene.reset()
+ scene.settled('')
+ await nextTick()
+ expect(title.name).toBe('Village Well')
+ })
+
it('waits for a change of scene to bring the picture up', async () => {
scene.phase = 'in'
await enter('Village Square', moveTurn(2, false))
diff --git a/web/src/stores/useRoomTitleStore.ts b/web/src/stores/useRoomTitleStore.ts
index cc65d934a..ea1d00529 100644
--- a/web/src/stores/useRoomTitleStore.ts
+++ b/web/src/stores/useRoomTitleStore.ts
@@ -44,14 +44,27 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
name.value = null
}
- /** The stage starts afresh (a new game, a load, a new act): settle, and greet the room once its picture is up again. */
+ /**
+ * The picture that was up when the stage last started afresh, while it may
+ * still be the one settled: the stage settles it again at once, before the
+ * new room's picture has begun to load. Null once another has settled.
+ */
+ let stale: string | null = null
+
+ /** The stage starts afresh (a new game, a load, a new act): settle, and greet the room once its own picture is up. */
function reset(): void {
settle()
pending = true
+ stale = scene.shown
}
/** Whether a greeting can be shown now: a room, no chapter page, and its picture up after any change of scene. */
function ready(): boolean {
+ if (stale !== null && scene.shown !== null && scene.shown !== stale)
+ stale = null
+ // The old picture settled again after a load into a room with another one.
+ if (stale !== null && scene.shown === stale && game.roomPicture !== stale)
+ return false
return (
game.roomName !== '' &&
!game.showModal &&
@@ -82,7 +95,8 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
[
game.showModal,
scene.phase,
- scene.shown !== null,
+ scene.shown,
+ game.roomPicture,
staged.value,
] as const,
consider,
From 6f0ea0c95ca1bf335b3ad13f7f40e1e05ae23258 Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 11:58:06 +0100
Subject: [PATCH 09/10] chore(rq-b9zi): keep main's cerebro submodule pointer
Co-Authored-By: Claude Opus 5.5
---
.cerebro/cerebro | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.cerebro/cerebro b/.cerebro/cerebro
index 9d7c51216..23458d8b8 160000
--- a/.cerebro/cerebro
+++ b/.cerebro/cerebro
@@ -1 +1 @@
-Subproject commit 9d7c51216a75f6c7b8a1854066a7793474c42485
+Subproject commit 23458d8b892d3674e76f8a7890305c9259d542bf
From 6ff8d3c40dad9297b72eb75bdff4979369e94fd4 Mon Sep 17 00:00:00 2001
From: Henrik Kurelid
Date: Thu, 12 Nov 2026 12:11:49 +0100
Subject: [PATCH 10/10] fix(rq-b9zi): a room with no painted picture is greeted
after a load
The paper backdrop is never held as the stale picture: it is what such a
room settles on again, and it shows no other room.
Co-Authored-By: Claude Opus 5.5
---
web/src/stores/useRoomTitleStore.test.ts | 30 ++++++++++++++++++++++++
web/src/stores/useRoomTitleStore.ts | 7 ++++--
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/web/src/stores/useRoomTitleStore.test.ts b/web/src/stores/useRoomTitleStore.test.ts
index cd884de8b..bd866c4f2 100644
--- a/web/src/stores/useRoomTitleStore.test.ts
+++ b/web/src/stores/useRoomTitleStore.test.ts
@@ -122,6 +122,36 @@ describe('useRoomTitleStore', () => {
expect(title.name).toBe('Village Well')
})
+ it('greets a load in a room with no painted picture, the backdrop up before and after', async () => {
+ // The room has a picture's address, but nothing is painted there.
+ game.musicActName = 'Act1'
+ game.musicRoomKey = 'VillageSquare'
+ scene.reset()
+ scene.settled('')
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ title.reset()
+ scene.reset()
+ scene.settled('')
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('greets a load from one unpainted room into another', async () => {
+ game.musicActName = 'Act1'
+ game.musicRoomKey = 'VillageSquare'
+ scene.reset()
+ scene.settled('')
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.musicRoomKey = 'VillageWell'
+ title.reset()
+ scene.reset()
+ scene.settled('')
+ await enter('Village Well', moveTurn(3, false))
+ expect(title.name).toBe('Village Well')
+ })
+
it('waits for a change of scene to bring the picture up', async () => {
scene.phase = 'in'
await enter('Village Square', moveTurn(2, false))
diff --git a/web/src/stores/useRoomTitleStore.ts b/web/src/stores/useRoomTitleStore.ts
index ea1d00529..d72f3a604 100644
--- a/web/src/stores/useRoomTitleStore.ts
+++ b/web/src/stores/useRoomTitleStore.ts
@@ -47,7 +47,8 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
/**
* The picture that was up when the stage last started afresh, while it may
* still be the one settled: the stage settles it again at once, before the
- * new room's picture has begun to load. Null once another has settled.
+ * new room's picture has begun to load. Null once another has settled, and
+ * never the backdrop ('').
*/
let stale: string | null = null
@@ -55,7 +56,9 @@ export const useRoomTitleStore = defineStore('roomTitle', () => {
function reset(): void {
settle()
pending = true
- stale = scene.shown
+ // The paper backdrop is never stale: it is what a room without a painted
+ // picture settles on again, and it shows no other room.
+ stale = scene.shown || null
}
/** Whether a greeting can be shown now: a room, no chapter page, and its picture up after any change of scene. */