diff --git a/docs/superpowers/specs/2026-04-03-dot-path-story-api-design.md b/docs/superpowers/specs/2026-04-03-dot-path-story-api-design.md new file mode 100644 index 0000000..43a6fba --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-dot-path-story-api-design.md @@ -0,0 +1,52 @@ +# Dot-path notation for Story.set / Story.get + +**Issue:** rohal12/spindle#150 +**Date:** 2026-04-03 + +## Problem + +`Story.set('alma.view', 'home')` creates a flat variable named `"alma.view"` instead of setting the `view` property on the `$alma` object. This is inconsistent with the twee macro `{set $alma.view = "home"}`, which works correctly via the expression engine's JS property access on Immer drafts. + +`Story.get('alma.view')` has the same inconsistency — it looks up a flat key instead of traversing nested properties. + +## Solution + +Add dot-path resolution in `story-api.ts` only. The store layer stays unchanged (flat keys). Two private helpers: + +- `getByPath(obj, path)` — split on `.`, traverse, return value or `undefined` +- `setByPath(obj, path, value)` — split on `.`, traverse Immer draft to parent, set final key + +### Story.get(name) + +1. Strip `%` prefix → select `transient` or `variables` +2. If `name` contains `.`: split into segments, use `getByPath` +3. Otherwise: existing flat lookup (no behavior change) + +### Story.set(name, value) + +1. Strip `%` prefix → select namespace +2. If `name` contains `.`: call `useStoryStore.setState()` with `setByPath` on the appropriate draft object (`state.variables` or `state.transient`) +3. Otherwise: existing `state.setVariable()` / `state.setTransient()` (no behavior change) + +### Story.set(vars) — batch form + +Iterate entries. Each key follows the same single-key logic above. + +## Edge cases + +- **Single segment (no dot):** Unchanged behavior, no regression. +- **get on missing intermediate:** Returns `undefined` (consistent with JS `obj.missing?.prop`). +- **set on missing/non-object intermediate:** Throws TypeError (consistent with `{set $undefined.prop = x}` in twee). +- **Numeric segments:** Work naturally for arrays — `items.0` accesses `items[0]` since JS objects coerce string keys. +- **Transient variables:** `Story.set('%alma.view', 'home')` strips `%`, resolves dot-path on `state.transient`. + +## Files changed + +- `src/story-api.ts` — add `getByPath`, `setByPath`, update `get()` and `set()` +- `test/unit/story-api.test.ts` — new tests for dot-path get/set + +## Not changed + +- `src/store.ts` — stays flat-key only +- `src/expression.ts` — already handles paths via JS property access +- Macros — unaffected diff --git a/src/story-api.ts b/src/story-api.ts index d2f3c55..057c374 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -113,6 +113,37 @@ function ensureVariableChangedSubscription(): void { }); } +/** Traverse a dot-delimited path on an object and return the value. */ +function getByPath(obj: Record, path: string): unknown { + const segments = path.split('.'); + let current: unknown = obj[segments[0]!]; + for (let i = 1; i < segments.length; i++) { + if (current == null) return undefined; + current = (current as Record)[segments[i]!]; + } + return current; +} + +/** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */ +function setByPath( + obj: Record, + path: string, + value: unknown, +): void { + const segments = path.split('.'); + let current: Record = obj; + for (let i = 0; i < segments.length - 1; i++) { + const next = current[segments[i]!]; + if (next == null || typeof next !== 'object') { + throw new TypeError( + `spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`, + ); + } + current = next as Record; + } + current[segments[segments.length - 1]!] = value; +} + export interface StoryAPI { get(name: string): unknown; set(name: string, value: unknown): void; @@ -192,30 +223,43 @@ export interface StoryAPI { }; } +/** Set a single variable, resolving dot-paths if present. */ +function setOne(name: string, value: unknown): void { + const isTransient = name.startsWith('%'); + const key = isTransient ? name.slice(1) : name; + const namespace = isTransient ? 'transient' : 'variables'; + + if (key.includes('.')) { + useStoryStore.setState((state) => { + setByPath(state[namespace] as Record, key, value); + }); + } else { + const state = useStoryStore.getState(); + if (isTransient) { + state.setTransient(key, value); + } else { + state.setVariable(key, value); + } + } +} + function createStoryAPI(): StoryAPI { return { get(name: string): unknown { - if (name.startsWith('%')) { - return useStoryStore.getState().transient[name.slice(1)]; - } - return useStoryStore.getState().variables[name]; + const isTransient = name.startsWith('%'); + const key = isTransient ? name.slice(1) : name; + const store = isTransient + ? useStoryStore.getState().transient + : useStoryStore.getState().variables; + return key.includes('.') ? getByPath(store, key) : store[key]; }, set(nameOrVars: string | Record, value?: unknown): void { - const state = useStoryStore.getState(); if (typeof nameOrVars === 'string') { - if (nameOrVars.startsWith('%')) { - state.setTransient(nameOrVars.slice(1), value); - } else { - state.setVariable(nameOrVars, value); - } + setOne(nameOrVars, value); } else { for (const [k, v] of Object.entries(nameOrVars)) { - if (k.startsWith('%')) { - state.setTransient(k.slice(1), v); - } else { - state.setVariable(k, v); - } + setOne(k, v); } } }, diff --git a/test/unit/story-api.test.ts b/test/unit/story-api.test.ts index 3710d6f..128dab7 100644 --- a/test/unit/story-api.test.ts +++ b/test/unit/story-api.test.ts @@ -77,6 +77,110 @@ describe('StoryAPI', () => { }); }); + describe('dot-path get/set', () => { + it('Story.get resolves dot-path on nested object', () => { + useStoryStore + .getState() + .setVariable('alma', { view: 'home', expanded: '' }); + expect(Story.get('alma.view')).toBe('home'); + }); + + it('Story.get returns undefined for missing intermediate', () => { + expect(Story.get('nonexistent.prop')).toBeUndefined(); + }); + + it('Story.get returns undefined for missing leaf', () => { + useStoryStore.getState().setVariable('alma', { view: 'home' }); + expect(Story.get('alma.missing')).toBeUndefined(); + }); + + it('Story.get with deep path', () => { + useStoryStore + .getState() + .setVariable('config', { ui: { theme: { color: 'red' } } }); + expect(Story.get('config.ui.theme.color')).toBe('red'); + }); + + it('Story.get without dot still works (no regression)', () => { + useStoryStore.getState().setVariable('gold', 100); + expect(Story.get('gold')).toBe(100); + }); + + it('Story.get resolves dot-path on transient (%)', () => { + useStoryStore.getState().setTransient('ui', { panel: 'open' }); + expect(Story.get('%ui.panel')).toBe('open'); + }); + + it('Story.get with numeric segment accesses array index', () => { + useStoryStore.getState().setVariable('items', ['a', 'b', 'c']); + expect(Story.get('items.1')).toBe('b'); + }); + + it('Story.set with dot-path sets nested property', () => { + useStoryStore + .getState() + .setVariable('alma', { view: 'home', expanded: '' }); + Story.set('alma.view', 'settings'); + expect(useStoryStore.getState().variables.alma).toEqual({ + view: 'settings', + expanded: '', + }); + }); + + it('Story.set with deep dot-path', () => { + useStoryStore + .getState() + .setVariable('config', { ui: { theme: { color: 'red' } } }); + Story.set('config.ui.theme.color', 'blue'); + expect( + (useStoryStore.getState().variables.config as any).ui.theme.color, + ).toBe('blue'); + }); + + it('Story.set without dot still works (no regression)', () => { + Story.set('gold', 100); + expect(useStoryStore.getState().variables.gold).toBe(100); + }); + + it('Story.set with dot-path on transient (%)', () => { + useStoryStore.getState().setTransient('ui', { panel: 'closed' }); + Story.set('%ui.panel', 'open'); + expect(useStoryStore.getState().transient.ui).toEqual({ panel: 'open' }); + }); + + it('Story.set batch form with dot-paths', () => { + useStoryStore.getState().setVariable('alma', { view: 'home' }); + useStoryStore.getState().setVariable('character', { level: 1 }); + Story.set({ 'alma.view': 'settings', 'character.level': 5 }); + expect((useStoryStore.getState().variables.alma as any).view).toBe( + 'settings', + ); + expect((useStoryStore.getState().variables.character as any).level).toBe( + 5, + ); + }); + + it('Story.set batch form with mixed dot-path and flat keys', () => { + useStoryStore.getState().setVariable('alma', { view: 'home' }); + Story.set({ 'alma.view': 'settings', gold: 200 }); + expect((useStoryStore.getState().variables.alma as any).view).toBe( + 'settings', + ); + expect(useStoryStore.getState().variables.gold).toBe(200); + }); + + it('Story.set batch form with transient dot-path', () => { + useStoryStore.getState().setTransient('ui', { panel: 'closed' }); + Story.set({ '%ui.panel': 'open', gold: 50 }); + expect((useStoryStore.getState().transient.ui as any).panel).toBe('open'); + expect(useStoryStore.getState().variables.gold).toBe(50); + }); + + it('Story.set throws on missing intermediate', () => { + expect(() => Story.set('nonexistent.prop', 'value')).toThrow(); + }); + }); + describe('navigation', () => { it('goto navigates to passage', () => { useStoryStore.getState().navigate('Room');