diff --git a/CHANGELOG.md b/CHANGELOG.md index 4697353..fa01c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Configurable passage transitions with outgoing phase support + - Four transition types: `none` (instant), `fade` (incoming only), `fade-through` (fade out, optional pause, fade in), `crossfade` (simultaneous overlap) + - `Story.setTransition(config)` sets a persistent default transition + - `Story.setNextTransition(config)` sets a one-shot transition for the next navigation only + - Per-passage transition via tags: `[transition:crossfade duration:600 pause:200]` + - Priority chain: passage tags > one-shot > persistent default > built-in default + - CSS custom properties (`--passage-in-duration`, `--passage-out-duration`, `--passage-pause`) for author styling + - `data-transition` attribute on `.passage` for CSS targeting per type + - `prefers-reduced-motion` support - `:storyready` DOM event dispatched after Spindle finishes loading and rendering - Escaped braces (`\{`, `\}`) to display literal `{` and `}` characters in passage text - String-aware expression transformer that preserves `$var`/`_var`/`@var` sigils inside string literals and template literal text, while still transforming code and `${…}` interpolations @@ -18,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Comprehensive e2e test suite covering edge cases (nested macros, widget locals, computed reactivity, timed/repeat macros, form inputs, and more) - Unit tests for expression transformer, interpolation engine, option-utils, and tokenizer +### Changed + +- Default passage transition changed from incoming-only fade to `fade-through` (300ms fade out, 50ms pause, 300ms fade in). Use `Story.setTransition({ type: 'fade' })` to restore the old behavior. +- Passage animation easing changed from `ease-in` to `ease` +- `.passage` element is now wrapped in a `.passage-container` div + ### Fixed - Allow array method/property access (e.g. `$inventory.push`, `$journal.find`) in story variable validation diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 13743cb..3089d44 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -103,6 +103,7 @@ export default defineConfig({ items: [ { text: 'Saves', link: '/saves' }, { text: 'Settings', link: '/settings' }, + { text: 'Transitions', link: '/transitions' }, { text: 'Story API', link: '/story-api' }, ], }, diff --git a/docs/story-api.md b/docs/story-api.md index fd2699c..7fde1d5 100644 --- a/docs/story-api.md +++ b/docs/story-api.md @@ -74,6 +74,35 @@ Remove a named watcher. {/do} ``` +### `Story.setTransition(config)` + +Set the default transition used for all passage navigations. Pass `null` to revert to the built-in default (`fade-through`, 300ms, 50ms pause). + +| Property | Type | Default | Description | +| ---------- | --------- | ------- | ------------------------------------------------------- | +| `type` | `string` | — | `'none'`, `'fade'`, `'fade-through'`, `'crossfade'` | +| `duration` | `number?` | `300` | Animation duration in milliseconds | +| `pause` | `number?` | `50` | Pause between outgoing and incoming (fade-through only) | + +``` +{do} + Story.setTransition({ type: 'crossfade', duration: 600 }); + Story.setTransition({ type: 'none' }); // disable transitions + Story.setTransition(null); // revert to default +{/do} +``` + +### `Story.setNextTransition(config)` + +Set a one-shot transition for the next navigation only. Consumed automatically when any navigation occurs — even if passage tags override the visual result. + +``` +{do} + Story.setNextTransition({ type: 'none' }); // next navigation is instant + Story.goto("DroneAttack"); +{/do} +``` + ### `Story.save()` Perform a quick save. diff --git a/docs/superpowers/plans/2026-03-18-passage-transitions.md b/docs/superpowers/plans/2026-03-18-passage-transitions.md new file mode 100644 index 0000000..eb264d7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-passage-transitions.md @@ -0,0 +1,1282 @@ +# Passage Transitions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add configurable passage transitions with outgoing phase support, replacing the hardcoded fade-in with a state machine that supports `none`, `fade`, `fade-through`, and `crossfade` transition types. + +**Architecture:** A new `src/transition.ts` module owns the `TransitionConfig` type, tag parsing, and resolution chain. The store gains two new fields (`transitionConfig`, `nextTransition`) and three actions. PassageDisplay gets a `useRef`-based state machine that gates rendering via a local `displayedPassage`, creates DOM snapshots for outgoing phases, and orchestrates CSS animations. The Passage component receives a `data-transition` prop. + +**Tech Stack:** Preact, Zustand (with Immer), CSS animations, Vitest (happy-dom) + +**Spec:** `docs/superpowers/specs/2026-03-18-passage-transitions-design.md` + +--- + +## File Structure + +| File | Responsibility | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/transition.ts` (new) | `TransitionConfig` type, `TransitionType`, `BUILT_IN_DEFAULT`, `resolveTransitionFromTags()`, `fillDefaults()`, `resolveTransition()` | +| `src/store.ts` (modify) | Add `transitionConfig`, `nextTransition` state fields + `setTransition`, `setNextTransition`, `consumeNextTransition` actions | +| `src/story-api.ts` (modify) | Expose `Story.setTransition()` and `Story.setNextTransition()` on the public API | +| `src/components/macros/PassageDisplay.tsx` (modify) | State machine, render gating via `displayedPassage`, snapshot mechanism, passage container wrapper | +| `src/components/Passage.tsx` (modify) | Accept and render `data-transition` prop on the `.passage` div | +| `src/styles.css` (modify) | `passage-fade-out` keyframes, `.passage-snapshot` rules, CSS custom properties, `data-transition` selectors, `.passage-container` + crossfade grid, reduced motion | +| `test/unit/transition.test.ts` (new) | Unit tests for `resolveTransitionFromTags`, `fillDefaults`, `resolveTransition` | +| `test/unit/store-transition.test.ts` (new) | Unit tests for store transition actions | +| `test/unit/story-api-transition.test.ts` (new) | Unit tests for `Story.setTransition()` / `Story.setNextTransition()` | +| `test/dom/passage-display-transition.test.tsx` (new) | DOM tests for PassageDisplay state machine, snapshot behavior, CSS custom property application | + +--- + +## Task 1: Transition Module — Types and Resolution Logic + +**Files:** + +- Create: `src/transition.ts` +- Create: `test/unit/transition.test.ts` + +This task builds the pure data layer — no Preact, no store, no DOM. + +- [ ] **Step 1: Write the failing tests for `resolveTransitionFromTags`** + +Create `test/unit/transition.test.ts`: + +```typescript +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; +import { + resolveTransitionFromTags, + resolveTransition, + fillDefaults, + BUILT_IN_DEFAULT, + type TransitionConfig, +} from '../../src/transition'; + +describe('resolveTransitionFromTags', () => { + it('returns null when no transition: tag present', () => { + expect(resolveTransitionFromTags(['widget', 'nobr'])).toBeNull(); + expect(resolveTransitionFromTags([])).toBeNull(); + }); + + it('parses transition type from tag', () => { + expect(resolveTransitionFromTags(['transition:crossfade'])).toEqual({ + type: 'crossfade', + }); + }); + + it('parses duration and pause tags', () => { + const tags = ['transition:fade-through', 'duration:600', 'pause:200']; + expect(resolveTransitionFromTags(tags)).toEqual({ + type: 'fade-through', + duration: 600, + pause: 200, + }); + }); + + it('ignores duration/pause without transition: tag', () => { + expect(resolveTransitionFromTags(['duration:600', 'pause:200'])).toBeNull(); + }); + + it('returns null and warns for invalid type', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveTransitionFromTags(['transition:sparkle'])).toBeNull(); + expect(spy).toHaveBeenCalledWith('Unknown transition type: "sparkle"'); + spy.mockRestore(); + }); + + it('ignores NaN duration values', () => { + const tags = ['transition:fade', 'duration:abc']; + expect(resolveTransitionFromTags(tags)).toEqual({ type: 'fade' }); + }); + + it('treats empty string after colon as invalid (Number("") === 0)', () => { + // Empty value after colon parses as 0 via Number(''), which is valid + const tags = ['transition:fade', 'pause:']; + expect(resolveTransitionFromTags(tags)).toEqual({ type: 'fade', pause: 0 }); + }); +}); + +describe('fillDefaults', () => { + it('fills missing duration and pause from built-in default', () => { + expect(fillDefaults({ type: 'crossfade' })).toEqual({ + type: 'crossfade', + duration: 300, + pause: 50, + }); + }); + + it('preserves explicitly set values', () => { + expect( + fillDefaults({ type: 'fade-through', duration: 600, pause: 0 }), + ).toEqual({ + type: 'fade-through', + duration: 600, + pause: 0, + }); + }); +}); + +describe('resolveTransition', () => { + it('uses tags when present (highest priority)', () => { + const result = resolveTransition( + ['transition:none'], + { type: 'crossfade', duration: 600 }, + { type: 'fade' }, + ); + expect(result.type).toBe('none'); + }); + + it('uses nextTransition when no tags', () => { + const result = resolveTransition( + [], + { type: 'crossfade', duration: 600 }, + { type: 'fade' }, + ); + expect(result).toEqual({ type: 'crossfade', duration: 600, pause: 50 }); + }); + + it('uses storeDefault when no tags and no nextTransition', () => { + const result = resolveTransition([], null, { type: 'fade' }); + expect(result).toEqual({ type: 'fade', duration: 300, pause: 50 }); + }); + + it('uses built-in default when nothing configured', () => { + const result = resolveTransition([], null, null); + expect(result).toEqual(BUILT_IN_DEFAULT); + }); + + it('fills defaults from built-in, not from lower priority levels', () => { + // Tag says crossfade but no duration — should get built-in 300, not storeDefault's 600 + const result = resolveTransition(['transition:crossfade'], null, { + type: 'fade', + duration: 600, + }); + expect(result.duration).toBe(300); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/transition.test.ts` +Expected: FAIL — module `../../src/transition` does not exist. + +- [ ] **Step 3: Implement `src/transition.ts`** + +```typescript +export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade'; + +export interface TransitionConfig { + type: TransitionType; + duration?: number; + pause?: number; +} + +export type ResolvedTransition = Required; + +const TRANSITION_TYPES = new Set([ + 'none', + 'fade', + 'fade-through', + 'crossfade', +]); + +export const BUILT_IN_DEFAULT: ResolvedTransition = { + type: 'fade-through', + duration: 300, + pause: 50, +}; + +export function resolveTransitionFromTags( + tags: string[], +): TransitionConfig | null { + const typeTag = tags.find((t) => t.startsWith('transition:')); + if (!typeTag) return null; + + const rawType = typeTag.slice('transition:'.length); + if (!TRANSITION_TYPES.has(rawType as TransitionType)) { + console.warn(`Unknown transition type: "${rawType}"`); + return null; + } + + const config: TransitionConfig = { type: rawType as TransitionType }; + + for (const tag of tags) { + if (tag.startsWith('duration:')) { + const n = Number(tag.slice('duration:'.length)); + if (!Number.isNaN(n)) config.duration = n; + } else if (tag.startsWith('pause:')) { + const n = Number(tag.slice('pause:'.length)); + if (!Number.isNaN(n)) config.pause = n; + } + } + + return config; +} + +export function fillDefaults(partial: TransitionConfig): ResolvedTransition { + return { + type: partial.type, + duration: partial.duration ?? BUILT_IN_DEFAULT.duration, + pause: partial.pause ?? BUILT_IN_DEFAULT.pause, + }; +} + +export function resolveTransition( + targetTags: string[], + nextTransition: TransitionConfig | null, + storeDefault: TransitionConfig | null, +): ResolvedTransition { + const fromTags = resolveTransitionFromTags(targetTags); + if (fromTags) return fillDefaults(fromTags); + if (nextTransition) return fillDefaults(nextTransition); + if (storeDefault) return fillDefaults(storeDefault); + return { ...BUILT_IN_DEFAULT }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/transition.test.ts` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/transition.ts test/unit/transition.test.ts +git commit -m "feat(transition): add TransitionConfig types and resolution logic" +``` + +--- + +## Task 2: Store — Transition State and Actions + +**Files:** + +- Modify: `src/store.ts` +- Create: `test/unit/store-transition.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/store-transition.test.ts`: + +```typescript +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content = ''): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +describe('store transition state', () => { + beforeEach(() => { + useStoryStore.setState({ + storyData: null, + currentPassage: '', + variables: {}, + variableDefaults: {}, + temporary: {}, + history: [], + historyIndex: -1, + visitCounts: {}, + renderCounts: {}, + transitionConfig: null, + nextTransition: null, + }); + }); + + it('starts with null transition fields', () => { + const state = useStoryStore.getState(); + expect(state.transitionConfig).toBeNull(); + expect(state.nextTransition).toBeNull(); + }); + + describe('setTransition', () => { + it('sets persistent default', () => { + useStoryStore + .getState() + .setTransition({ type: 'crossfade', duration: 600 }); + expect(useStoryStore.getState().transitionConfig).toEqual({ + type: 'crossfade', + duration: 600, + }); + }); + + it('clears with null', () => { + useStoryStore.getState().setTransition({ type: 'none' }); + useStoryStore.getState().setTransition(null); + expect(useStoryStore.getState().transitionConfig).toBeNull(); + }); + }); + + describe('setNextTransition', () => { + it('sets one-shot transition', () => { + useStoryStore.getState().setNextTransition({ type: 'none' }); + expect(useStoryStore.getState().nextTransition).toEqual({ type: 'none' }); + }); + + it('clears with null', () => { + useStoryStore.getState().setNextTransition({ type: 'none' }); + useStoryStore.getState().setNextTransition(null); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + }); + + describe('consumeNextTransition', () => { + it('returns and clears nextTransition', () => { + useStoryStore.getState().setNextTransition({ type: 'crossfade' }); + const consumed = useStoryStore.getState().consumeNextTransition(); + expect(consumed).toEqual({ type: 'crossfade' }); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + + it('returns null when nothing set', () => { + expect(useStoryStore.getState().consumeNextTransition()).toBeNull(); + }); + }); + + describe('transition fields are not saved/loaded', () => { + it('getSavePayload does not include transition fields', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + useStoryStore.getState().setTransition({ type: 'crossfade' }); + useStoryStore.getState().setNextTransition({ type: 'none' }); + + const payload = useStoryStore.getState().getSavePayload(); + expect(payload).not.toHaveProperty('transitionConfig'); + expect(payload).not.toHaveProperty('nextTransition'); + }); + + it('loadFromPayload does not overwrite transition fields', () => { + const story = makeStoryData([ + makePassage(1, 'Start'), + makePassage(2, 'Room'), + ]); + useStoryStore.getState().init(story); + useStoryStore.getState().setTransition({ type: 'crossfade' }); + + // Navigate to create a save-worthy state + useStoryStore.getState().navigate('Room'); + const payload = useStoryStore.getState().getSavePayload(); + + // Load should not clear our transition config + useStoryStore.getState().loadFromPayload(payload); + expect(useStoryStore.getState().transitionConfig).toEqual({ + type: 'crossfade', + }); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store-transition.test.ts` +Expected: FAIL — `setTransition`, `setNextTransition`, `consumeNextTransition` do not exist on store. + +- [ ] **Step 3: Add transition fields and actions to `src/store.ts`** + +Add to the `StoryState` interface (after `loadError: string | null`): + +```typescript +transitionConfig: TransitionConfig | null; +nextTransition: TransitionConfig | null; + +setTransition: (config: TransitionConfig | null) => void; +setNextTransition: (config: TransitionConfig | null) => void; +consumeNextTransition: () => TransitionConfig | null; +``` + +Add import at the top of `src/store.ts`: + +```typescript +import type { TransitionConfig } from './transition'; +``` + +Add initial values in the store creator (after `loadError: null`): + +```typescript +transitionConfig: null, +nextTransition: null, +``` + +Add action implementations (after the `getHistoryVariables` action): + +```typescript +setTransition: (config: TransitionConfig | null) => { + set((state) => { + state.transitionConfig = config as TransitionConfig | null; + }); +}, + +setNextTransition: (config: TransitionConfig | null) => { + set((state) => { + state.nextTransition = config as TransitionConfig | null; + }); +}, + +consumeNextTransition: (): TransitionConfig | null => { + const current = get().nextTransition; + if (current !== null) { + set((state) => { + state.nextTransition = null; + }); + } + return current; +}, +``` + +Also update the `beforeEach` in `test/unit/store.test.ts` to include the new fields so existing tests don't break: + +```typescript +transitionConfig: null, +nextTransition: null, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/store-transition.test.ts test/unit/store.test.ts` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/store.ts test/unit/store-transition.test.ts test/unit/store.test.ts +git commit -m "feat(store): add transition config state and actions" +``` + +--- + +## Task 3: Story API — Expose Transition Methods + +**Files:** + +- Modify: `src/story-api.ts` +- Create: `test/unit/story-api-transition.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/story-api-transition.test.ts`: + +```typescript +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content = ''): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +let Story: any; + +describe('Story.setTransition / setNextTransition', () => { + beforeEach(async () => { + useStoryStore + .getState() + .init(makeStoryData([makePassage(1, 'Start', 'Hello')])); + const mod = await import('../../src/story-api'); + mod.installStoryAPI(); + Story = (globalThis as any).window?.Story ?? (globalThis as any).Story; + }); + + it('Story.setTransition sets persistent default in store', () => { + Story.setTransition({ type: 'crossfade', duration: 600 }); + expect(useStoryStore.getState().transitionConfig).toEqual({ + type: 'crossfade', + duration: 600, + }); + }); + + it('Story.setTransition(null) clears it', () => { + Story.setTransition({ type: 'none' }); + Story.setTransition(null); + expect(useStoryStore.getState().transitionConfig).toBeNull(); + }); + + it('Story.setNextTransition sets one-shot in store', () => { + Story.setNextTransition({ type: 'none' }); + expect(useStoryStore.getState().nextTransition).toEqual({ type: 'none' }); + }); + + it('Story.setNextTransition(null) clears it', () => { + Story.setNextTransition({ type: 'fade' }); + Story.setNextTransition(null); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/story-api-transition.test.ts` +Expected: FAIL — `Story.setTransition` is not a function. + +- [ ] **Step 3: Add methods to `src/story-api.ts`** + +Add import at the top: + +```typescript +import type { TransitionConfig } from './transition'; +``` + +Add to the `StoryAPI` interface (after `unwatch`): + +```typescript +setTransition(config: TransitionConfig | null): void; +setNextTransition(config: TransitionConfig | null): void; +``` + +Add implementations in `createStoryAPI()` (after the `unwatch` method): + +```typescript +setTransition(config: TransitionConfig | null): void { + useStoryStore.getState().setTransition(config); +}, + +setNextTransition(config: TransitionConfig | null): void { + useStoryStore.getState().setNextTransition(config); +}, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/story-api-transition.test.ts` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/story-api.ts test/unit/story-api-transition.test.ts +git commit -m "feat(api): expose Story.setTransition and Story.setNextTransition" +``` + +--- + +## Task 4: CSS — Transition Styles + +**Files:** + +- Modify: `src/styles.css` + +No test file — CSS is validated visually and through the DOM tests in Task 6. + +- [ ] **Step 1: Replace the hardcoded `.passage` animation and add new styles** + +In `src/styles.css`, replace the existing `.passage` and `@keyframes passage-fade-in` rules (lines 32–45) with the full transition CSS. Note: the easing changes from `ease-in` to `ease` to match the spec (this is intentional — `ease` provides a more natural feel for bidirectional transitions): + +```css +/* Passage transitions */ + +.passage-container { + position: relative; +} + +.passage-container--crossfading { + display: grid; +} + +.passage-container--crossfading > * { + grid-area: 1 / 1; +} + +.passage { + animation: passage-fade-in var(--passage-in-duration, 0.3s) ease; +} + +.passage[data-transition='none'] { + animation: none; +} + +.passage-snapshot { + animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards; + pointer-events: none; + user-select: none; +} + +@keyframes passage-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes passage-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +@media (prefers-reduced-motion: reduce) { + .passage, + .passage-snapshot { + animation-duration: 0.01s !important; + } +} +``` + +- [ ] **Step 2: Verify build still succeeds** + +Run: `npx tsc --noEmit` +Expected: No errors (CSS changes don't affect TypeScript, but good to verify nothing broke). + +- [ ] **Step 3: Commit** + +```bash +git add src/styles.css +git commit -m "feat(css): add passage transition styles, keyframes, and reduced motion" +``` + +--- + +## Task 5: Passage Component — `data-transition` Prop + +**Files:** + +- Modify: `src/components/Passage.tsx` + +- [ ] **Step 1: Add `dataTransition` prop to Passage component** + +In `src/components/Passage.tsx`, update the `PassageProps` interface and the component: + +Change the interface (line 15-17): + +```typescript +interface PassageProps { + passage: PassageData; + dataTransition?: string; +} +``` + +Change the function signature (line 26): + +```typescript +export function Passage({ passage, dataTransition }: PassageProps) { +``` + +Change the `.passage` div (line 91-95) to include `data-transition`: + +```tsx +
+``` + +- [ ] **Step 2: Verify existing tests still pass** + +Run: `npx vitest run test/dom/macros.test.tsx test/dom/render.test.tsx` +Expected: All PASS — the prop is optional, so existing call sites (which don't pass it) still work. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/Passage.tsx +git commit -m "feat(passage): add data-transition prop support" +``` + +--- + +## Task 6: PassageDisplay — State Machine and Transition Orchestration + +This is the core task. It transforms PassageDisplay from a simple render-through component to a transition orchestrator. + +**Design note:** The spec mentions `animationend` events with `setTimeout` fallback. This implementation uses `setTimeout` only for simplicity and reliability — `animationend` doesn't fire for zero-duration animations in some browsers, and happy-dom doesn't support CSS animations. If authors override keyframe durations longer than the configured `duration` ms, the snapshot may be removed before the CSS animation finishes. This is an acceptable V1 trade-off; `animationend` support can be layered on later. + +**Files:** + +- Modify: `src/components/macros/PassageDisplay.tsx` +- Create: `test/dom/passage-display-transition.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `test/dom/passage-display-transition.test.tsx`. These tests validate the state machine behavior through the DOM — checking what classes appear, what `data-transition` values are set, and whether snapshots are created. + +```tsx +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render } from 'preact'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage as PassageData } from '../../src/parser'; +// Import PassageDisplay to register the {passage} macro +import '../../src/components/macros/PassageDisplay'; +import { Passage } from '../../src/components/Passage'; + +function makePassage( + pid: number, + name: string, + content: string, + tags: string[] = [], +): PassageData { + return { pid, name, tags, metadata: {}, content }; +} + +function makeStoryData(passages: PassageData[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +describe('PassageDisplay transitions', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + vi.useFakeTimers(); + }); + + afterEach(() => { + render(null, container); + document.body.removeChild(container); + vi.useRealTimers(); + useStoryStore.setState({ + storyData: null, + currentPassage: '', + variables: {}, + variableDefaults: {}, + temporary: {}, + history: [], + historyIndex: -1, + visitCounts: {}, + renderCounts: {}, + transitionConfig: null, + nextTransition: null, + }); + }); + + it('renders the current passage with a .passage-container wrapper', () => { + const story = makeStoryData([makePassage(1, 'Start', 'Hello world')]); + useStoryStore.getState().init(story); + + // Render the Passage component directly to test structure + const passage = story.passages.get('Start')!; + render( + , + container, + ); + + const passageEl = container.querySelector('.passage'); + expect(passageEl).not.toBeNull(); + expect(passageEl!.getAttribute('data-transition')).toBe('fade-through'); + }); + + it('sets data-transition="none" which removes animation via CSS', () => { + const story = makeStoryData([makePassage(1, 'Start', 'Hello')]); + useStoryStore.getState().init(story); + + const passage = story.passages.get('Start')!; + render( + , + container, + ); + + const passageEl = container.querySelector('.passage'); + expect(passageEl!.getAttribute('data-transition')).toBe('none'); + }); + + describe('consumeNextTransition integration', () => { + it('is consumed on navigation regardless of tags', () => { + const story = makeStoryData([ + makePassage(1, 'Start', 'start'), + makePassage(2, 'Tagged', 'tagged', ['transition:none']), + ]); + useStoryStore.getState().init(story); + + // Set a one-shot + useStoryStore.getState().setNextTransition({ type: 'crossfade' }); + + // Navigate to a tagged passage — tags override, but one-shot should be consumed + useStoryStore.getState().navigate('Tagged'); + + // nextTransition should be consumed (cleared) + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + }); + + describe('fade-through state machine timing', () => { + it('creates a snapshot during outgoing phase and removes it after transition completes', () => { + const story = makeStoryData([ + makePassage(1, 'Start', 'First passage'), + makePassage(2, 'Room', 'Second passage'), + ]); + useStoryStore.getState().init(story); + + // Render the PassageDisplay macro (via getMacro or direct render) + // For this test we test the store + snapshot logic at the integration level + const { getMacro } = require('../../src/registry'); + const macro = getMacro('passage'); + // Note: Full PassageDisplay render tests require Preact rendering the macro. + // If the macro cannot be rendered directly here, this test validates the + // store-level behavior and the snapshot cleanup logic is verified manually. + + // Navigate — this triggers the transition + useStoryStore.getState().navigate('Room'); + + // Advance past outgoing (300ms) + pause (50ms) + incoming (300ms) + vi.advanceTimersByTime(650); + + // After full transition, nextTransition should be null + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + }); + + describe('rapid navigation', () => { + it('consumes nextTransition even during rapid navigation', () => { + const story = makeStoryData([ + makePassage(1, 'Start', 'start'), + makePassage(2, 'Room', 'room'), + makePassage(3, 'End', 'end'), + ]); + useStoryStore.getState().init(story); + + useStoryStore.getState().setNextTransition({ type: 'none' }); + useStoryStore.getState().navigate('Room'); + // First navigation consumes the one-shot + expect(useStoryStore.getState().nextTransition).toBeNull(); + + // Second rapid navigation — no one-shot set, so nothing to consume + useStoryStore.getState().navigate('End'); + expect(useStoryStore.getState().currentPassage).toBe('End'); + }); + }); + + describe('first load', () => { + it('first passage uses fade behavior regardless of store default', () => { + const story = makeStoryData([ + makePassage(1, 'Start', 'Hello', ['transition:none']), + ]); + // setTransition before init + useStoryStore + .getState() + .setTransition({ type: 'crossfade', duration: 600 }); + useStoryStore.getState().init(story); + + // On first load, the passage should render immediately (fade behavior) + // Tags on start passage are ignored for first load (spec hard rule) + expect(useStoryStore.getState().currentPassage).toBe('Start'); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/dom/passage-display-transition.test.tsx` +Expected: FAIL — `.passage-container` wrapper doesn't exist, `data-transition` isn't set. + +- [ ] **Step 3: Rewrite `src/components/macros/PassageDisplay.tsx`** + +Replace the entire file with the state-machine implementation. This is the largest change in the plan. + +```tsx +import { useRef, useEffect, useCallback, useState } from 'preact/hooks'; +import { useStoryStore } from '../../store'; +import { Passage, renderPassageContent } from '../Passage'; +import { defineMacro } from '../../define-macro'; +import { + resolveTransition, + BUILT_IN_DEFAULT, + type ResolvedTransition, +} from '../../transition'; + +type Phase = 'idle' | 'outgoing' | 'paused' | 'incoming' | 'crossfading'; + +/** Strip id attributes from a cloned node tree to avoid duplicates. */ +function stripIds(el: Element): void { + el.removeAttribute('id'); + for (const child of el.querySelectorAll('[id]')) { + child.removeAttribute('id'); + } +} + +/** Pause and mute any media elements in the snapshot. */ +function muteMedia(el: Element): void { + for (const media of el.querySelectorAll('audio, video')) { + media.pause(); + media.muted = true; + media.removeAttribute('autoplay'); + } +} + +/** Set CSS custom properties for transition durations on a container element. */ +function setCSSProperties(el: HTMLElement, config: ResolvedTransition): void { + const durationSec = `${config.duration / 1000}s`; + el.style.setProperty('--passage-in-duration', durationSec); + el.style.setProperty('--passage-out-duration', durationSec); + el.style.setProperty('--passage-pause', `${config.pause / 1000}s`); +} + +defineMacro({ + name: 'passage', + interpolate: true, + render(_props, ctx) { + const currentPassage = useStoryStore((s) => s.currentPassage); + const storyData = useStoryStore((s) => s.storyData); + const historyIndex = useStoryStore((s) => s.historyIndex); + const playthroughId = useStoryStore((s) => s.playthroughId); + + // Render-gating: displayedPassage is what Preact renders. + // currentPassage is what the store says. They can diverge during transitions. + const [displayedPassage, setDisplayedPassage] = useState(currentPassage); + const phaseRef = useRef('idle'); + const containerRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const prevPlaythroughIdRef = useRef(playthroughId); + const prevHistoryLenRef = useRef(1); + const resolvedTypeRef = useRef(BUILT_IN_DEFAULT.type); + + /** Cancel any in-progress transition immediately. */ + const cancelTransition = useCallback(() => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + phaseRef.current = 'idle'; + // Remove any lingering snapshots + if (containerRef.current) { + for (const snap of containerRef.current.querySelectorAll( + '.passage-snapshot', + )) { + snap.remove(); + } + containerRef.current.classList.remove('passage-container--crossfading'); + } + }, []); + + /** Detect if this is a first-load / restart / load (no outgoing phase). */ + const isFirstLoadLike = useCallback((): boolean => { + const state = useStoryStore.getState(); + // Different playthrough = restart or load + if (state.playthroughId !== prevPlaythroughIdRef.current) { + prevPlaythroughIdRef.current = state.playthroughId; + return true; + } + // History was replaced (shorter or reset to index 0 with length 1) + if ( + state.history.length === 1 && + state.historyIndex === 0 && + prevHistoryLenRef.current > 1 + ) { + return true; + } + return false; + }, []); + + // React to currentPassage changes from the store + useEffect(() => { + if (currentPassage === displayedPassage) return; + if (!storyData) return; + + const targetPassage = storyData.passages.get(currentPassage); + if (!targetPassage) { + // Passage not found — just swap immediately + setDisplayedPassage(currentPassage); + return; + } + + // Always consume nextTransition + const next = useStoryStore.getState().consumeNextTransition(); + + // First load or restart/load — just fade in, no outgoing + if (!displayedPassage || isFirstLoadLike()) { + prevHistoryLenRef.current = useStoryStore.getState().history.length; + // First load always uses 'fade' regardless of config (spec hard rule) + resolvedTypeRef.current = 'fade'; + if (containerRef.current) { + setCSSProperties(containerRef.current, { + type: 'fade', + duration: BUILT_IN_DEFAULT.duration, + pause: 0, + }); + } + setDisplayedPassage(currentPassage); + return; + } + + // Cancel any in-progress transition + cancelTransition(); + + // Resolve the transition config for this navigation + const config = resolveTransition( + targetPassage.tags, + next, + useStoryStore.getState().transitionConfig, + ); + + const container = containerRef.current; + if (!container) { + setDisplayedPassage(currentPassage); + return; + } + + setCSSProperties(container, config); + resolvedTypeRef.current = config.type; + prevHistoryLenRef.current = useStoryStore.getState().history.length; + + // type: none or fade — no outgoing phase + if (config.type === 'none' || config.type === 'fade') { + setDisplayedPassage(currentPassage); + return; + } + + // Create snapshot of old passage + const oldPassageEl = container.querySelector('.passage'); + if (!oldPassageEl) { + setDisplayedPassage(currentPassage); + return; + } + + const snapshot = oldPassageEl.cloneNode(true) as HTMLElement; + stripIds(snapshot); + muteMedia(snapshot); + snapshot.classList.remove('passage'); + snapshot.classList.add('passage-snapshot'); + snapshot.setAttribute('data-transition', config.type); + snapshot.style.pointerEvents = 'none'; + snapshot.style.userSelect = 'none'; + + // Track timeouts for cleanup + const timeouts: ReturnType[] = []; + const addTimeout = (fn: () => void, ms: number) => { + timeouts.push(setTimeout(fn, ms)); + }; + + cleanupRef.current = () => { + for (const t of timeouts) clearTimeout(t); + snapshot.remove(); + container.classList.remove('passage-container--crossfading'); + }; + + if (config.type === 'crossfade') { + // Crossfade: show both simultaneously. + // Snapshot is appended after Preact's managed child, so DOM order is + // [new .passage] [snapshot]. With grid stacking, the snapshot (fading out) + // sits visually above the new passage (fading in) — correct for crossfade. + phaseRef.current = 'crossfading'; + container.classList.add('passage-container--crossfading'); + container.appendChild(snapshot); + + // Mount new passage immediately (alongside snapshot) + setDisplayedPassage(currentPassage); + + // After duration, clean up + addTimeout(() => { + snapshot.remove(); + container.classList.remove('passage-container--crossfading'); + phaseRef.current = 'idle'; + cleanupRef.current = null; + }, config.duration); + } else { + // fade-through: outgoing → pause → incoming + phaseRef.current = 'outgoing'; + + // Set displayedPassage to '' so the render function produces an empty + // container (passage lookup returns undefined, but '' !== '' check in + // the error branch is false, so neither error nor passage renders). + // The snapshot clone is the only visible content during outgoing/paused. + setDisplayedPassage(''); + container.appendChild(snapshot); + + // After outgoing duration, enter pause + addTimeout(() => { + phaseRef.current = 'paused'; + + // After pause, mount new passage + addTimeout(() => { + snapshot.remove(); + phaseRef.current = 'incoming'; + setDisplayedPassage(currentPassage); + + // After incoming duration, return to idle + addTimeout(() => { + phaseRef.current = 'idle'; + cleanupRef.current = null; + }, config.duration); + }, config.pause); + }, config.duration); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally + // only fires on store-driven passage changes, not self-triggered setDisplayedPassage + }, [currentPassage]); + + // Cleanup on unmount + useEffect(() => cancelTransition, []); + + const passage = storyData?.passages.get(displayedPassage); + const readyPassage = storyData?.passages.get('PassageReady'); + + // Use the resolved type from the useEffect — this correctly reflects + // one-shot transitions that were consumed during navigation. + const transitionType = resolvedTypeRef.current; + + if (!passage && displayedPassage !== '') { + return ( +
+
+ Error: Passage “{displayedPassage}” not found. +
+
+ ); + } + + return ( +
+ {readyPassage && ( + + )} +
+ {passage && ( + + )} +
+
+ ); + }, +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/dom/passage-display-transition.test.tsx` +Expected: All PASS. + +- [ ] **Step 5: Run the full test suite** + +Run: `npx vitest run` +Expected: All existing tests still pass. If any break due to the `.passage-container` wrapper changing DOM structure, update the affected tests. + +- [ ] **Step 6: Run type check** + +Run: `npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/macros/PassageDisplay.tsx test/dom/passage-display-transition.test.tsx +git commit -m "feat(passage-display): add transition state machine with outgoing phase support" +``` + +--- + +## Task 7: Integration Verification and Cleanup + +**Files:** + +- Possibly modify: test files if DOM structure changes broke anything + +- [ ] **Step 1: Run the full test suite** + +Run: `npx vitest run` +Expected: All PASS. If any tests reference `.passage` as a direct child of `#story` and now need to account for `.passage-container`, update those tests. + +- [ ] **Step 2: Run the build** + +Run: `npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 3: Verify the `beforeEach` resets in existing store tests include new fields** + +Check `test/unit/store.test.ts` and `test/unit/store-extended.test.ts` — if their `beforeEach` calls `useStoryStore.setState(...)` with a partial state, they may need `transitionConfig: null, nextTransition: null` added. If they only use `useStoryStore.getState().init(...)`, the store defaults handle it. + +- [ ] **Step 4: Fix any broken tests** + +If tests broke, the most likely causes are: + +- DOM selectors looking for `.passage` as direct child of `#story` (now it's inside `.passage-container`) +- Tests that do `useStoryStore.setState({...})` without the new fields + +Fix by updating selectors and adding the new fields to partial state resets. + +- [ ] **Step 5: Final full test run** + +Run: `npx vitest run` +Expected: All PASS. + +- [ ] **Step 6: Commit any test fixes** + +```bash +git add -A +git commit -m "fix(tests): update tests for passage-container wrapper" +``` diff --git a/docs/superpowers/specs/2026-03-18-passage-transitions-design.md b/docs/superpowers/specs/2026-03-18-passage-transitions-design.md new file mode 100644 index 0000000..f270b58 --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-passage-transitions-design.md @@ -0,0 +1,450 @@ +# Configurable Passage Transitions with Outgoing Phase Support + +## Problem + +Spindle has a hardcoded passage transition: a 0.3s fade-in with 8px translateY, applied via CSS on `.passage`. The `key={currentPassage}` pattern in PassageDisplay causes instant unmount/remount with no outgoing phase. There is no per-passage or per-navigation control — every passage change uses the same animation. + +This is limiting for games and visual narratives where different narrative contexts call for different transitions (hard cuts, crossfades, slow fades with pauses). + +## Design Overview + +A state machine in PassageDisplay orchestrates transition phases (outgoing, pause, incoming). Old passage content is captured as a DOM snapshot during outgoing phases. Transition configuration is available via Story API, passage tags, and CSS custom properties. + +## Data Model + +### TransitionConfig + +```typescript +type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade'; + +interface TransitionConfig { + type: TransitionType; + duration?: number; // ms, default 300 + pause?: number; // ms, default 0 (only meaningful for fade-through) +} +``` + +### Transition Types + +| Type | Behavior | Duration applies to | Pause | +| -------------- | ----------------------------------------------- | ----------------------------- | ------------------ | +| `none` | Instant swap | ignored | ignored | +| `fade` | Incoming only (legacy behavior) | fade-in time | ignored | +| `fade-through` | Fade out old, optional pause, fade in new | each fade phase independently | gap between phases | +| `crossfade` | Old fades out while new fades in simultaneously | overlap window | ignored | + +Total time for `fade-through`: `duration + pause + duration`. + +### Built-in Default + +```typescript +{ type: 'fade-through', duration: 300, pause: 50 } +``` + +This replaces the current incoming-only fade as the default. The old behavior is available via `{ type: 'fade' }`. + +### Resolution Chain + +Priority from highest to lowest: + +1. **Tags on the target passage** — if a `transition:` tag is present, use it. Unspecified fields within that level are filled from the built-in default (not from lower-priority levels). For example, `transition:crossfade` with no `duration:` tag yields `{ type: 'crossfade', duration: 300, pause: 50 }`. +2. **`Story.setNextTransition()`** — one-shot, **always consumed on navigation** regardless of whether tags override the visual result. This prevents stale one-shots from firing on unintended later navigations. +3. **`Story.setTransition()`** — persistent author default. +4. **Built-in default** — `{ type: 'fade-through', duration: 300, pause: 50 }`. + +## Store Changes + +### New State Fields + +```typescript +transitionConfig: TransitionConfig | null; // author's persistent default (null = use built-in) +nextTransition: TransitionConfig | null; // one-shot, cleared after consumption +``` + +### New Actions + +```typescript +setTransition(config: TransitionConfig | null): void; +setNextTransition(config: TransitionConfig | null): void; +consumeNextTransition(): TransitionConfig | null; // returns and clears nextTransition +``` + +### Not Persisted + +- These fields are **not saved/loaded** — transitions are presentation-layer config, not game state. +- They are **not part of history** — going back doesn't replay the original transition. + +## State Machine in PassageDisplay + +### Phases + +``` +idle → outgoing → paused → incoming → idle + \ / + `--→ crossfading ---' +``` + +The state machine is linear for most types. `crossfade` uses a dedicated `crossfading` state that runs both animations concurrently, then transitions directly to `idle`. + +### Phase Behavior by Type + +| Type | Phase sequence | +| -------------- | --------------------------------------------------------------------------------------------------------------- | +| `none` | `idle` → mount new (no animation) → `idle` | +| `fade` | `idle` → mount new → `incoming` (fade in) → `idle` | +| `fade-through` | `idle` → `outgoing` (fade out snapshot) → `paused` (wait `pause` ms) → `incoming` (mount new, fade in) → `idle` | +| `crossfade` | `idle` → `crossfading` (mount new + fade in, simultaneously fade out snapshot) → `idle` | + +For `crossfade`, the `crossfading` state inserts the snapshot, mounts the new passage immediately, and runs both animations in parallel. The state completes when the longer of the two animations ends. + +### Render Gating + +PassageDisplay maintains a local `displayedPassage` ref (separate from the store's `currentPassage`). When a navigation occurs: + +1. PassageDisplay detects `currentPassage !== displayedPassage` during render. +2. Instead of immediately rendering the new passage, it continues rendering the old passage (or nothing, during the outgoing phase) while orchestrating the transition. +3. Only when the state machine reaches the `incoming` phase (or `crossfading` for crossfade) does PassageDisplay update `displayedPassage` and trigger the Preact re-render that mounts the new ``. + +This decoupling means the store updates immediately (history, visit counts, variable resets all happen on navigation), but the visual swap is deferred until the transition orchestrator is ready. + +**Key implementation detail:** The existing `key={currentPassage}` on `` must be changed to `key={displayedPassage}` so that Preact's unmount/remount is controlled by the state machine, not by the store update. + +**Detecting restart/load vs navigate:** PassageDisplay detects these by comparing the store's `historyIndex` and history length. A `restart()` resets history to a single entry; a `load()` replaces the entire history. If the history has been replaced (different `playthroughId` or `historyIndex` reset to 0 with a fresh history), PassageDisplay treats it as a first-load and skips the outgoing phase. Back/forward change `historyIndex` but preserve history — PassageDisplay detects these as normal `currentPassage` changes and runs the full transition, including consuming `nextTransition`. + +### Snapshot Mechanism + +When a navigation triggers and the transition has an outgoing phase: + +1. `cloneNode(true)` on the `.passage` div specifically (not the passage container or `#story` wrapper — the PassageReady hidden div and other siblings are not included in the snapshot). +2. Strip `id` attributes from the clone to avoid duplicates. +3. Apply inline styles: `pointer-events: none; user-select: none` (inert). +4. Insert the clone into the passage container (the wrapper div that holds `.passage`). +5. Add the `.passage-snapshot` class and `data-transition` attribute to the clone. +6. For `fade-through`: hide the real passage area (PassageDisplay renders nothing during outgoing/paused phases), animate the clone's opacity to 0, wait for `pause`, then mount the new passage. +7. For `crossfade`: mount the new passage immediately alongside the snapshot. Both the snapshot and the new `.passage` are positioned via CSS grid stacking (`display: grid; grid-area: 1/1` on the container) so they overlap visually. Animate the clone out and the new passage in simultaneously. +8. On animation end (or after `duration` ms `setTimeout` fallback), remove the clone from DOM. + +### Passage Container + +PassageDisplay introduces a new intermediate wrapper div (`.passage-container`) between the existing `#story` div and the `.passage` element. This wrapper is the target for snapshot insertion and crossfade layout. Introducing a new element avoids changing `#story`'s display property during crossfade, which could break StoryInterface layouts. + +```html +
+ +
+
+ +
+
+
+``` + +### Crossfade Layout + +During the `crossfading` state, the passage container uses CSS grid stacking to overlap both elements: + +```css +.passage-container--crossfading { + display: grid; +} +.passage-container--crossfading > * { + grid-area: 1 / 1; +} +``` + +This avoids absolute positioning and its associated height-collapse issues. + +### `data-transition` Attribute Delivery + +The `data-transition` attribute on `.passage` is passed as a prop from PassageDisplay to the Passage component, which renders it on the `.passage` div. This ensures the attribute is present in the initial render, before the CSS animation starts — avoiding a flash where the default animation briefly plays before being overridden. The snapshot clone inherits it from the cloned DOM, and also receives it via JS during snapshot setup. + +### Timing Orchestration + +- `useRef`-based state machine driven by `requestAnimationFrame` and `setTimeout`. +- The snapshot is raw DOM manipulation, outside Preact's tree. +- Preact re-render only occurs when `displayedPassage` is updated (to mount the new passage). +- `animationend` events signal phase completion, with `setTimeout(duration)` fallback for safety. +- JS converts `TransitionConfig.duration` (milliseconds) to seconds when setting CSS custom properties (e.g., `300` → `0.3s`). + +## Story API + +### New Methods + +```typescript +// Set persistent default transition +Story.setTransition({ type: 'crossfade', duration: 600 }); +Story.setTransition(null); // revert to built-in default + +// Set one-shot transition for next navigation only +Story.setNextTransition({ type: 'none' }); +Story.setNextTransition(null); // cancel pending one-shot +``` + +Both accept `TransitionConfig | null`. Partial configs are allowed — unspecified fields fall back to built-in defaults. + +### Not Added + +- **No transition lifecycle events.** `Story.on('navigate')` already fires on navigation. Transition is visual-only. Events like `onOutgoingStart` can be added later if a use case emerges. +- **No `Story.getTransition()`.** The resolved transition is internal to PassageDisplay. + +## Passage Tag Parsing + +### Syntax + +``` +:: RefineryCorridor [transition:crossfade duration:600 pause:200] +``` + +Parsed by the existing tag parser as `['transition:crossfade', 'duration:600', 'pause:200']`. + +### Resolution Logic + +A utility function (not in the store): + +```typescript +const TRANSITION_TYPES = new Set([ + 'none', + 'fade', + 'fade-through', + 'crossfade', +]); + +function resolveTransitionFromTags(tags: string[]): TransitionConfig | null { + const typeTag = tags.find((t) => t.startsWith('transition:')); + if (!typeTag) return null; + + const rawType = typeTag.slice('transition:'.length); + if (!TRANSITION_TYPES.has(rawType as TransitionType)) { + console.warn(`Unknown transition type: "${rawType}"`); + return null; // fall through to next priority level + } + + const config: TransitionConfig = { type: rawType as TransitionType }; + + for (const tag of tags) { + if (tag.startsWith('duration:')) { + const n = Number(tag.slice('duration:'.length)); + if (!Number.isNaN(n)) config.duration = n; + } else if (tag.startsWith('pause:')) { + const n = Number(tag.slice('pause:'.length)); + if (!Number.isNaN(n)) config.pause = n; + } + } + + return config; +} +``` + +### Full Resolution Function + +`resolveTransition()` is a pure function in `src/transition.ts`. The caller is responsible for consuming the one-shot beforehand. + +```typescript +const BUILT_IN_DEFAULT: TransitionConfig = { + type: 'fade-through', + duration: 300, + pause: 50, +}; + +function fillDefaults(partial: TransitionConfig): Required { + return { + type: partial.type, + duration: partial.duration ?? BUILT_IN_DEFAULT.duration, + pause: partial.pause ?? BUILT_IN_DEFAULT.pause, + }; +} + +/** Pure — does not consume nextTransition. Caller must do that separately. */ +function resolveTransition( + targetTags: string[], + nextTransition: TransitionConfig | null, + storeDefault: TransitionConfig | null, +): Required { + const fromTags = resolveTransitionFromTags(targetTags); + if (fromTags) return fillDefaults(fromTags); + if (nextTransition) return fillDefaults(nextTransition); + if (storeDefault) return fillDefaults(storeDefault); + return { ...BUILT_IN_DEFAULT } as Required; +} +``` + +PassageDisplay calls it like: + +```typescript +const next = useStoryStore.getState().consumeNextTransition(); // always consumed +const config = resolveTransition( + targetPassage.tags, + next, + store.transitionConfig, +); +``` + +### Validation + +- Invalid type values (e.g. `transition:sparkle`) produce a console warning and fall through to the next priority level. +- Invalid numbers (e.g. `duration:abc`) are ignored (field stays unspecified, filled from built-in default). +- `duration:` and `pause:` tags only activate as transition parameters when a `transition:` tag is present on the same passage. + +## CSS Custom Properties and Author Overrides + +### Properties Set by JS + +On the passage container before each transition: + +```css +--passage-in-duration: 0.3s; +--passage-out-duration: 0.3s; +--passage-pause: 0.05s; +``` + +### Built-in Keyframes + +The keyframes include a subtle `translateY` motion in addition to opacity. The outgoing passage slides slightly upward (as if receding), and the incoming passage slides up from below (as if arriving). This provides a sense of forward momentum. Authors can override the keyframes to remove the motion if desired. + +```css +@keyframes passage-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes passage-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} +``` + +### Classes + +```css +.passage { + animation: passage-fade-in var(--passage-in-duration) ease; +} +.passage-snapshot { + animation: passage-fade-out var(--passage-out-duration) ease forwards; +} +``` + +### Type Targeting + +`data-transition` attribute on both `.passage` and `.passage-snapshot`. Note that `data-transition="fade"` and `data-transition="fade-through"` have no type-specific CSS overrides — they use the default `.passage` animation rule, which is correct since both types use the standard fade-in keyframes. Only `none` and author-defined custom types need attribute-specific selectors: + +```css +.passage[data-transition='none'] { + animation: none; +} +.passage[data-transition='crossfade'] { + animation: my-custom-crossfade-in var(--passage-in-duration) ease; +} +``` + +### Reduced Motion + +Respect `prefers-reduced-motion` at the CSS level: + +```css +@media (prefers-reduced-motion: reduce) { + .passage, + .passage-snapshot { + animation-duration: 0.01s !important; + } +} +``` + +Using `0.01s` rather than `animation: none` ensures the state machine still receives `animationend` events and completes its phase transitions normally. The visual effect is effectively instant. + +### Snapshot Media Handling + +`cloneNode(true)` will clone media elements (audio/video) if authors have embedded them in passage content. During snapshot setup (step 3), the snapshot mechanism also pauses and mutes any `
)} - +
+ {passage && ( + + )} +
); }, diff --git a/src/store.ts b/src/store.ts index d9af272..f523f7f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -7,6 +7,7 @@ import { type Patch, } from 'immer'; import type { StoryData } from './parser'; +import type { TransitionConfig } from './transition'; import type { SavePayload, SaveHistoryMoment } from './saves/types'; import { executeStoryInit } from './story-init'; import { resetTriggers } from './triggers'; @@ -202,6 +203,8 @@ export interface StoryState { maxHistory: number; saveError: string | null; loadError: string | null; + transitionConfig: TransitionConfig | null; + nextTransition: TransitionConfig | null; setMaxHistory: (limit: number) => void; init: ( @@ -223,6 +226,9 @@ export interface StoryState { getSavePayload: () => SavePayload; loadFromPayload: (payload: SavePayload) => void; getHistoryVariables: (index: number) => Record; + setTransition: (config: TransitionConfig | null) => void; + setNextTransition: (config: TransitionConfig | null) => void; + consumeNextTransition: () => TransitionConfig | null; } export const useStoryStore = create()( @@ -241,6 +247,8 @@ export const useStoryStore = create()( maxHistory: 40, saveError: null, loadError: null, + transitionConfig: null, + nextTransition: null, setMaxHistory: (limit: number) => { set((state) => { @@ -616,5 +624,27 @@ export const useStoryStore = create()( getHistoryVariables: (index: number): Record => { return deepClone(reconstructVarsAt(index)); }, + + setTransition: (config: TransitionConfig | null) => { + set((state) => { + state.transitionConfig = config as TransitionConfig | null; + }); + }, + + setNextTransition: (config: TransitionConfig | null) => { + set((state) => { + state.nextTransition = config as TransitionConfig | null; + }); + }, + + consumeNextTransition: (): TransitionConfig | null => { + const current = get().nextTransition; + if (current !== null) { + set((state) => { + state.nextTransition = null; + }); + } + return current; + }, })), ); diff --git a/src/story-api.ts b/src/story-api.ts index 9c8f3da..456bf83 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -23,6 +23,7 @@ import { } from './prng'; import { addTrigger, removeTrigger } from './triggers'; import type { WatchOptions } from './triggers'; +import type { TransitionConfig } from './transition'; export type { StoryAction }; @@ -72,6 +73,8 @@ export interface StoryAPI { callbackOrOptions: (() => void) | WatchOptions, ): () => void; unwatch(name: string): void; + setTransition(config: TransitionConfig | null): void; + setNextTransition(config: TransitionConfig | null): void; random(): number; randomInt(min: number, max: number): number; readonly config: { @@ -281,6 +284,14 @@ function createStoryAPI(): StoryAPI { removeTrigger(name); }, + setTransition(config: TransitionConfig | null): void { + useStoryStore.getState().setTransition(config); + }, + + setNextTransition(config: TransitionConfig | null): void { + useStoryStore.getState().setNextTransition(config); + }, + random(): number { return random(); }, diff --git a/src/styles.css b/src/styles.css index 13de15d..3cfcd19 100644 --- a/src/styles.css +++ b/src/styles.css @@ -29,8 +29,32 @@ tw-storydata { padding: 2em 1.5em; } +/* Passage transitions */ + +.passage-container { + position: relative; +} + +.passage-container--crossfading { + display: grid; +} + +.passage-container--crossfading > * { + grid-area: 1 / 1; +} + .passage { - animation: passage-fade-in 0.3s ease-in; + animation: passage-fade-in var(--passage-in-duration, 0.3s) ease; +} + +.passage[data-transition='none'] { + animation: none; +} + +.passage-snapshot { + animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards; + pointer-events: none; + user-select: none; } @keyframes passage-fade-in { @@ -44,6 +68,24 @@ tw-storydata { } } +@keyframes passage-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +@media (prefers-reduced-motion: reduce) { + .passage, + .passage-snapshot { + animation-duration: 0.01s !important; + } +} + .macro-button { background: rgba(100, 181, 246, 0.15); color: #64b5f6; diff --git a/src/transition.ts b/src/transition.ts new file mode 100644 index 0000000..fcb1eb1 --- /dev/null +++ b/src/transition.ts @@ -0,0 +1,69 @@ +export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade'; + +export interface TransitionConfig { + type: TransitionType; + duration?: number; + pause?: number; +} + +export type ResolvedTransition = Required; + +const TRANSITION_TYPES = new Set([ + 'none', + 'fade', + 'fade-through', + 'crossfade', +]); + +export const BUILT_IN_DEFAULT: ResolvedTransition = { + type: 'fade-through', + duration: 300, + pause: 50, +}; + +export function resolveTransitionFromTags( + tags: string[], +): TransitionConfig | null { + const typeTag = tags.find((t) => t.startsWith('transition:')); + if (!typeTag) return null; + + const rawType = typeTag.slice('transition:'.length); + if (!TRANSITION_TYPES.has(rawType as TransitionType)) { + console.warn(`Unknown transition type: "${rawType}"`); + return null; + } + + const config: TransitionConfig = { type: rawType as TransitionType }; + + for (const tag of tags) { + if (tag.startsWith('duration:')) { + const n = Number(tag.slice('duration:'.length)); + if (!Number.isNaN(n)) config.duration = n; + } else if (tag.startsWith('pause:')) { + const n = Number(tag.slice('pause:'.length)); + if (!Number.isNaN(n)) config.pause = n; + } + } + + return config; +} + +export function fillDefaults(partial: TransitionConfig): ResolvedTransition { + return { + type: partial.type, + duration: partial.duration ?? BUILT_IN_DEFAULT.duration, + pause: partial.pause ?? BUILT_IN_DEFAULT.pause, + }; +} + +export function resolveTransition( + targetTags: string[], + nextTransition: TransitionConfig | null, + storeDefault: TransitionConfig | null, +): ResolvedTransition { + const fromTags = resolveTransitionFromTags(targetTags); + if (fromTags) return fillDefaults(fromTags); + if (nextTransition) return fillDefaults(nextTransition); + if (storeDefault) return fillDefaults(storeDefault); + return { ...BUILT_IN_DEFAULT }; +} diff --git a/test/dom/passage-display-transition.test.tsx b/test/dom/passage-display-transition.test.tsx new file mode 100644 index 0000000..929a0e8 --- /dev/null +++ b/test/dom/passage-display-transition.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render } from 'preact'; +import { act } from 'preact/test-utils'; +import { useStoryStore } from '../../src/store'; +import { tokenize } from '../../src/markup/tokenizer'; +import { buildAST } from '../../src/markup/ast'; +import { renderNodes } from '../../src/markup/render'; +import type { StoryData, Passage as PassageData } from '../../src/parser'; + +// Ensure PassageDisplay macro is registered +import '../../src/components/macros/PassageDisplay'; + +function makePassage( + pid: number, + name: string, + content: string, + tags: string[] = [], +): PassageData { + return { pid, name, tags, metadata: {}, content }; +} + +function makeStoryData(passages: PassageData[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +function renderPassageMacro(container: HTMLElement): void { + const tokens = tokenize('{passage}'); + const ast = buildAST(tokens); + act(() => { + render(<>{renderNodes(ast)}, container); + }); +} + +describe('PassageDisplay transition state machine', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + vi.useRealTimers(); + }); + + it('renders with .passage-container wrapper and data-transition attribute', () => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Hello world')]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + const storyDiv = container.querySelector('#story'); + expect(storyDiv).not.toBeNull(); + + const passageContainer = storyDiv!.querySelector('.passage-container'); + expect(passageContainer).not.toBeNull(); + + const passageDiv = passageContainer!.querySelector('.passage'); + expect(passageDiv).not.toBeNull(); + expect(passageDiv!.getAttribute('data-passage')).toBe('Start'); + expect(passageDiv!.getAttribute('data-transition')).not.toBeNull(); + }); + + it('sets data-transition="none" when no transition configured and first load uses fade', () => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Hello world')]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + const passageDiv = container.querySelector('.passage'); + expect(passageDiv).not.toBeNull(); + // First load should use 'fade' (not 'none') + expect(passageDiv!.getAttribute('data-transition')).toBe('fade'); + }); + + it('consumeNextTransition is consumed on navigation regardless of tags', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start'), + makePassage(2, 'Room', 'A room', ['transition:none']), + ]); + useStoryStore.getState().init(storyData); + + // Set a next transition + useStoryStore + .getState() + .setNextTransition({ type: 'crossfade', duration: 500 }); + + renderPassageMacro(container); + + // Navigate + act(() => { + useStoryStore.getState().navigate('Room'); + }); + + // Advance timers to complete transition + act(() => { + vi.advanceTimersByTime(1000); + }); + + // nextTransition should have been consumed + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + + it('first passage uses fade behavior regardless of store default', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Hello', ['transition:crossfade']), + ]); + useStoryStore.getState().init(storyData); + + // Set a store default that is NOT fade + useStoryStore + .getState() + .setTransition({ type: 'fade-through', duration: 500 }); + + renderPassageMacro(container); + + const passageDiv = container.querySelector('.passage'); + expect(passageDiv).not.toBeNull(); + // Even with store default and tag, first passage should use fade + expect(passageDiv!.getAttribute('data-transition')).toBe('fade'); + }); + + it('renders passage content correctly', () => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Hello world')]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + expect(container.textContent).toContain('Hello world'); + }); + + it('handles navigation between passages', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start content'), + makePassage(2, 'Room', 'Room content'), + ]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + expect(container.textContent).toContain('Start content'); + + act(() => { + useStoryStore.getState().navigate('Room'); + }); + + // Advance timers to complete any transition + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(container.textContent).toContain('Room content'); + }); + + it('renders PassageReady hidden div when present', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Hello'), + makePassage(2, 'PassageReady', '{set $ready = true}'), + ]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + // PassageReady content should exist but be hidden + const hiddenDivs = container.querySelectorAll('[hidden]'); + expect(hiddenDivs.length).toBeGreaterThan(0); + }); + + it('shows error for missing passage', () => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Hello')]); + useStoryStore.getState().init(storyData); + + // Force currentPassage to a non-existent passage + act(() => { + // Use internal state manipulation since navigate validates + useStoryStore.setState({ currentPassage: 'NonExistent' }); + }); + + renderPassageMacro(container); + + const error = container.querySelector('.error'); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain('NonExistent'); + }); + + it('uses transition:none tag correctly after first load', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start'), + makePassage(2, 'Room', 'Room', ['transition:none']), + ]); + useStoryStore.getState().init(storyData); + renderPassageMacro(container); + + // Navigate to Room which has transition:none tag + act(() => { + useStoryStore.getState().navigate('Room'); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + const passageDiv = container.querySelector('.passage'); + expect(passageDiv).not.toBeNull(); + expect(passageDiv!.getAttribute('data-transition')).toBe('none'); + }); +}); diff --git a/test/e2e/story.test.ts b/test/e2e/story.test.ts index f78eb1b..c395f07 100644 --- a/test/e2e/story.test.ts +++ b/test/e2e/story.test.ts @@ -405,15 +405,16 @@ describe('compiled story e2e', () => { }); it('renders timed content after delay', async () => { - await page.waitForSelector('#timed-output', { timeout: 2000 }); - const timedText = await page.textContent('#timed-output'); - expect(timedText).toContain('Timed content appeared!'); + await page.waitForFunction( + () => document.body.textContent?.includes('Timed content appeared!'), + { timeout: 5000 }, + ); }); it('renders timed next section', async () => { await page.waitForFunction( () => document.body.textContent?.includes('Second section!'), - { timeout: 2000 }, + { timeout: 3000 }, ); }); diff --git a/test/unit/store-transition.test.ts b/test/unit/store-transition.test.ts new file mode 100644 index 0000000..67c7097 --- /dev/null +++ b/test/unit/store-transition.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { useStoryStore } from '../../src/store'; +import type { Passage } from '../../src/parser'; +import type { StoryData } from '../../src/parser'; +import type { TransitionConfig } from '../../src/transition'; + +function makePassage(pid: number, name: string, content = ''): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const passageMap = new Map(); + const passagesById = new Map(); + for (const p of passages) { + passageMap.set(p.name, p); + passagesById.set(p.pid, p); + } + return { + name: 'Test Story', + startNode, + ifid: 'TEST-IFID', + format: 'spindle', + formatVersion: '0.1.0', + passages: passageMap, + passagesById, + userCSS: '', + userScript: '', + }; +} + +describe('useStoryStore — transition state', () => { + beforeEach(() => { + useStoryStore.setState({ + storyData: null, + currentPassage: '', + variables: {}, + variableDefaults: {}, + temporary: {}, + history: [], + historyIndex: -1, + visitCounts: {}, + renderCounts: {}, + transitionConfig: null, + nextTransition: null, + }); + }); + + describe('initial state', () => { + it('transitionConfig is null by default', () => { + expect(useStoryStore.getState().transitionConfig).toBeNull(); + }); + + it('nextTransition is null by default', () => { + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + }); + + describe('setTransition()', () => { + it('sets a persistent default transition config', () => { + const config: TransitionConfig = { type: 'fade', duration: 400 }; + useStoryStore.getState().setTransition(config); + expect(useStoryStore.getState().transitionConfig).toEqual(config); + }); + + it('clears the persistent default with null', () => { + useStoryStore.getState().setTransition({ type: 'crossfade' }); + useStoryStore.getState().setTransition(null); + expect(useStoryStore.getState().transitionConfig).toBeNull(); + }); + + it('does not affect nextTransition', () => { + const next: TransitionConfig = { type: 'none' }; + useStoryStore.getState().setNextTransition(next); + useStoryStore.getState().setTransition({ type: 'fade' }); + expect(useStoryStore.getState().nextTransition).toEqual(next); + }); + }); + + describe('setNextTransition()', () => { + it('sets a one-shot transition override', () => { + const config: TransitionConfig = { type: 'fade-through', pause: 100 }; + useStoryStore.getState().setNextTransition(config); + expect(useStoryStore.getState().nextTransition).toEqual(config); + }); + + it('clears the one-shot override with null', () => { + useStoryStore.getState().setNextTransition({ type: 'fade' }); + useStoryStore.getState().setNextTransition(null); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + + it('does not affect transitionConfig', () => { + const cfg: TransitionConfig = { type: 'crossfade', duration: 200 }; + useStoryStore.getState().setTransition(cfg); + useStoryStore.getState().setNextTransition({ type: 'none' }); + expect(useStoryStore.getState().transitionConfig).toEqual(cfg); + }); + }); + + describe('consumeNextTransition()', () => { + it('returns the one-shot value and clears it', () => { + const config: TransitionConfig = { type: 'fade', duration: 300 }; + useStoryStore.getState().setNextTransition(config); + + const consumed = useStoryStore.getState().consumeNextTransition(); + expect(consumed).toEqual(config); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + + it('returns null when nextTransition is already null', () => { + const result = useStoryStore.getState().consumeNextTransition(); + expect(result).toBeNull(); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); + + it('does not affect transitionConfig', () => { + const cfg: TransitionConfig = { type: 'fade-through' }; + useStoryStore.getState().setTransition(cfg); + useStoryStore.getState().setNextTransition({ type: 'none' }); + useStoryStore.getState().consumeNextTransition(); + expect(useStoryStore.getState().transitionConfig).toEqual(cfg); + }); + }); + + describe('getSavePayload()', () => { + it('does NOT include transitionConfig in the payload', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + useStoryStore.getState().setTransition({ type: 'fade', duration: 500 }); + + const payload = useStoryStore.getState().getSavePayload(); + expect('transitionConfig' in payload).toBe(false); + }); + + it('does NOT include nextTransition in the payload', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + useStoryStore.getState().setNextTransition({ type: 'crossfade' }); + + const payload = useStoryStore.getState().getSavePayload(); + expect('nextTransition' in payload).toBe(false); + }); + }); + + describe('loadFromPayload()', () => { + it('does NOT overwrite transitionConfig', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + const cfg: TransitionConfig = { type: 'fade', duration: 400 }; + useStoryStore.getState().setTransition(cfg); + + const payload = useStoryStore.getState().getSavePayload(); + useStoryStore.getState().loadFromPayload(payload); + + expect(useStoryStore.getState().transitionConfig).toEqual(cfg); + }); + + it('does NOT overwrite nextTransition', () => { + const story = makeStoryData([makePassage(1, 'Start')]); + useStoryStore.getState().init(story); + const next: TransitionConfig = { type: 'none' }; + useStoryStore.getState().setNextTransition(next); + + const payload = useStoryStore.getState().getSavePayload(); + useStoryStore.getState().loadFromPayload(payload); + + expect(useStoryStore.getState().nextTransition).toEqual(next); + }); + }); +}); diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 846c8c5..e46f96c 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -49,6 +49,8 @@ describe('useStoryStore', () => { historyIndex: -1, visitCounts: {}, renderCounts: {}, + transitionConfig: null, + nextTransition: null, }); }); diff --git a/test/unit/story-api-transition.test.ts b/test/unit/story-api-transition.test.ts new file mode 100644 index 0000000..388ff65 --- /dev/null +++ b/test/unit/story-api-transition.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content = ''): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +let Story: any; + +describe('Story.setTransition / setNextTransition', () => { + beforeEach(async () => { + useStoryStore + .getState() + .init(makeStoryData([makePassage(1, 'Start', 'Hello')])); + const mod = await import('../../src/story-api'); + mod.installStoryAPI(); + Story = (globalThis as any).window?.Story ?? (globalThis as any).Story; + }); + + it('Story.setTransition sets persistent default in store', () => { + Story.setTransition({ type: 'crossfade', duration: 600 }); + expect(useStoryStore.getState().transitionConfig).toEqual({ + type: 'crossfade', + duration: 600, + }); + }); + + it('Story.setTransition(null) clears it', () => { + Story.setTransition({ type: 'none' }); + Story.setTransition(null); + expect(useStoryStore.getState().transitionConfig).toBeNull(); + }); + + it('Story.setNextTransition sets one-shot in store', () => { + Story.setNextTransition({ type: 'none' }); + expect(useStoryStore.getState().nextTransition).toEqual({ type: 'none' }); + }); + + it('Story.setNextTransition(null) clears it', () => { + Story.setNextTransition({ type: 'fade' }); + Story.setNextTransition(null); + expect(useStoryStore.getState().nextTransition).toBeNull(); + }); +}); diff --git a/test/unit/transition.test.ts b/test/unit/transition.test.ts new file mode 100644 index 0000000..3eb64d6 --- /dev/null +++ b/test/unit/transition.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; +import { + resolveTransitionFromTags, + resolveTransition, + fillDefaults, + BUILT_IN_DEFAULT, +} from '../../src/transition'; + +describe('resolveTransitionFromTags', () => { + it('returns null when no transition: tag present', () => { + expect(resolveTransitionFromTags(['widget', 'nobr'])).toBeNull(); + expect(resolveTransitionFromTags([])).toBeNull(); + }); + + it('parses transition type from tag', () => { + expect(resolveTransitionFromTags(['transition:crossfade'])).toEqual({ + type: 'crossfade', + }); + }); + + it('parses duration and pause tags', () => { + const tags = ['transition:fade-through', 'duration:600', 'pause:200']; + expect(resolveTransitionFromTags(tags)).toEqual({ + type: 'fade-through', + duration: 600, + pause: 200, + }); + }); + + it('ignores duration/pause without transition: tag', () => { + expect(resolveTransitionFromTags(['duration:600', 'pause:200'])).toBeNull(); + }); + + it('returns null and warns for invalid type', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveTransitionFromTags(['transition:sparkle'])).toBeNull(); + expect(spy).toHaveBeenCalledWith('Unknown transition type: "sparkle"'); + spy.mockRestore(); + }); + + it('ignores NaN duration values', () => { + const tags = ['transition:fade', 'duration:abc']; + expect(resolveTransitionFromTags(tags)).toEqual({ type: 'fade' }); + }); + + it('treats empty string after colon as valid zero', () => { + const tags = ['transition:fade', 'pause:']; + expect(resolveTransitionFromTags(tags)).toEqual({ type: 'fade', pause: 0 }); + }); +}); + +describe('fillDefaults', () => { + it('fills missing duration and pause from built-in default', () => { + expect(fillDefaults({ type: 'crossfade' })).toEqual({ + type: 'crossfade', + duration: 300, + pause: 50, + }); + }); + + it('preserves explicitly set values', () => { + expect( + fillDefaults({ type: 'fade-through', duration: 600, pause: 0 }), + ).toEqual({ + type: 'fade-through', + duration: 600, + pause: 0, + }); + }); +}); + +describe('resolveTransition', () => { + it('uses tags when present (highest priority)', () => { + const result = resolveTransition( + ['transition:none'], + { type: 'crossfade', duration: 600 }, + { type: 'fade' }, + ); + expect(result.type).toBe('none'); + }); + + it('uses nextTransition when no tags', () => { + const result = resolveTransition( + [], + { type: 'crossfade', duration: 600 }, + { type: 'fade' }, + ); + expect(result).toEqual({ type: 'crossfade', duration: 600, pause: 50 }); + }); + + it('uses storeDefault when no tags and no nextTransition', () => { + const result = resolveTransition([], null, { type: 'fade' }); + expect(result).toEqual({ type: 'fade', duration: 300, pause: 50 }); + }); + + it('uses built-in default when nothing configured', () => { + const result = resolveTransition([], null, null); + expect(result).toEqual(BUILT_IN_DEFAULT); + }); + + it('fills defaults from built-in, not from lower priority levels', () => { + const result = resolveTransition(['transition:crossfade'], null, { + type: 'fade', + duration: 600, + }); + expect(result.duration).toBe(300); + }); +});