diff --git a/app/runs/[id]/page.tsx b/app/runs/[id]/page.tsx index 51fe54b..74fd22e 100644 --- a/app/runs/[id]/page.tsx +++ b/app/runs/[id]/page.tsx @@ -105,6 +105,10 @@ function RunWizard({ id }: { id: string }) { toast.success('Launch plan copied') } + function editField(platform: PlatformId, path: (string | number)[], value: string) { + dispatch({ type: 'EDIT_FIELD', platform, path, value }) + } + if (status === 'loading') { return

Loading run…

} @@ -161,6 +165,7 @@ function RunWizard({ id }: { id: string }) { onExportMarkdown={exportMarkdown} onCopyPlan={copyPlan} onExportPlan={exportPlan} + onEditField={editField} onStartOver={() => router.push('/runs')} /> )} diff --git a/components/common/EditableText/EditableText.test.tsx b/components/common/EditableText/EditableText.test.tsx new file mode 100644 index 0000000..42db264 --- /dev/null +++ b/components/common/EditableText/EditableText.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { EditableText } from './EditableText' + +describe('EditableText', () => { + it('commits trimmed text on blur when changed', () => { + const onCommit = vi.fn() + render() + const el = screen.getByRole('textbox', { name: 'Field' }) + el.textContent = ' world ' + fireEvent.blur(el) + expect(onCommit).toHaveBeenCalledWith('world') + }) + + it('does not commit when the value is unchanged', () => { + const onCommit = vi.fn() + render() + const el = screen.getByRole('textbox', { name: 'Field' }) + fireEvent.blur(el) + expect(onCommit).not.toHaveBeenCalled() + }) + + it('reverts to the previous value when cleared to empty', () => { + const onCommit = vi.fn() + render() + const el = screen.getByRole('textbox', { name: 'Field' }) + el.textContent = ' ' + fireEvent.blur(el) + expect(onCommit).not.toHaveBeenCalled() + expect(el.textContent).toBe('hello') + }) + + it('renders read-only text (no textbox) when onCommit is omitted', () => { + render() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.getByText('static')).toBeInTheDocument() + }) +}) diff --git a/components/common/EditableText/EditableText.tsx b/components/common/EditableText/EditableText.tsx new file mode 100644 index 0000000..6e17f3a --- /dev/null +++ b/components/common/EditableText/EditableText.tsx @@ -0,0 +1,65 @@ +'use client' +import { useRef, type KeyboardEvent, type ClipboardEvent } from 'react' +import type { EditableTextProps } from './EditableText.types' + +// Seamless inline editing: the text itself is contentEditable — no input box, no +// edit/save buttons. Edits commit on blur and persist via the run's autosave. +// React renders `value` as the initial child and never re-renders it mid-edit +// (commits happen on blur, so the value prop only changes when unfocused), so the +// caret never jumps. +export function EditableText({ value, onCommit, multiline = false, ariaLabel, className = '' }: EditableTextProps) { + const ref = useRef(null) + + if (!onCommit) { + return ( +
+ {value} +
+ ) + } + + function commit() { + const el = ref.current + if (!el) return + // jsdom lacks innerText; fall back to textContent so tests still exercise commit. + const raw = (multiline ? el.innerText || el.textContent : el.textContent) || '' + const next = raw.replace(/ /g, ' ').replace(/\n+$/, '').trim() + if (!next) { + el.textContent = value // never persist blank prose — revert + return + } + if (next !== value) onCommit!(next) + } + + function handleKeyDown(e: KeyboardEvent) { + if (!multiline && e.key === 'Enter') { + e.preventDefault() + ref.current?.blur() + } + } + + function handlePaste(e: ClipboardEvent) { + e.preventDefault() + const text = e.clipboardData.getData('text/plain') + document.execCommand('insertText', false, text) + } + + return ( +
+ {value} +
+ ) +} diff --git a/components/common/EditableText/EditableText.types.ts b/components/common/EditableText/EditableText.types.ts new file mode 100644 index 0000000..354abd7 --- /dev/null +++ b/components/common/EditableText/EditableText.types.ts @@ -0,0 +1,9 @@ +export type EditableTextProps = { + value: string + /** When omitted, the text renders read-only (no contentEditable). */ + onCommit?: (next: string) => void + /** Multiline keeps line breaks and lets Enter insert a newline. */ + multiline?: boolean + ariaLabel: string + className?: string +} diff --git a/components/common/EditableText/index.ts b/components/common/EditableText/index.ts new file mode 100644 index 0000000..da5c4f2 --- /dev/null +++ b/components/common/EditableText/index.ts @@ -0,0 +1 @@ +export { EditableText } from './EditableText' diff --git a/components/post-kit/PostKit/PostKit.test.tsx b/components/post-kit/PostKit/PostKit.test.tsx index a7101e4..51f6f64 100644 --- a/components/post-kit/PostKit/PostKit.test.tsx +++ b/components/post-kit/PostKit/PostKit.test.tsx @@ -39,6 +39,7 @@ function renderKit() { onExportMarkdown={vi.fn()} onCopyPlan={vi.fn()} onExportPlan={vi.fn()} + onEditField={vi.fn()} onStartOver={vi.fn()} /> , diff --git a/components/post-kit/PostKit/PostKit.tsx b/components/post-kit/PostKit/PostKit.tsx index afda6c7..62654bc 100644 --- a/components/post-kit/PostKit/PostKit.tsx +++ b/components/post-kit/PostKit/PostKit.tsx @@ -15,7 +15,7 @@ import type { PostKitProps } from './PostKit.types' type View = 'edit' | 'launch-ops' | 'preview' const VIEW_LABELS: Record = { edit: 'Edit', 'launch-ops': 'Launch ops', preview: 'Preview' } -export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, onExportPlan, onStartOver }: PostKitProps) { +export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, onExportPlan, onEditField, onStartOver }: PostKitProps) { const { core, platforms, plan } = generation const firstReady = PLATFORMS.find((p) => platforms[p.id])?.id ?? 'product-hunt' const [platform, setPlatform] = useState(firstReady) @@ -117,7 +117,9 @@ export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, This platform didn't generate. Pick another, or start over to try again.

) : activeView === 'edit' ? ( - Sections ? : null + Sections ? ( + onEditField(platform, path, value)} /> + ) : null ) : activeView === 'launch-ops' ? ( ) : Preview ? ( diff --git a/components/post-kit/PostKit/PostKit.types.ts b/components/post-kit/PostKit/PostKit.types.ts index 99892dd..531b2c0 100644 --- a/components/post-kit/PostKit/PostKit.types.ts +++ b/components/post-kit/PostKit/PostKit.types.ts @@ -7,5 +7,6 @@ export type PostKitProps = { onExportMarkdown: (platform: PlatformId) => void onCopyPlan: () => void onExportPlan: () => void + onEditField: (platform: PlatformId, path: (string | number)[], value: string) => void onStartOver: () => void } diff --git a/components/post-kit/platforms/AppSumoSections/AppSumoSections.tsx b/components/post-kit/platforms/AppSumoSections/AppSumoSections.tsx index 74db1e4..a220b43 100644 --- a/components/post-kit/platforms/AppSumoSections/AppSumoSections.tsx +++ b/components/post-kit/platforms/AppSumoSections/AppSumoSections.tsx @@ -1,16 +1,22 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import { Badge } from '@/components/ui/badge' import type { AppSumoContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' -export function AppSumoSections({ content, loading = false }: PlatformSectionsProps) { +export function AppSumoSections({ content, loading = false, onEdit }: PlatformSectionsProps) { const deal = content as AppSumoContent return (
-

{deal.dealHeadline}

+ onEdit(['dealHeadline'], v))} + ariaLabel="Deal headline" + className="flex-1 font-medium" + />
@@ -20,7 +26,12 @@ export function AppSumoSections({ content, loading = false }: PlatformSectionsPr Overview
-

{deal.pitch}

+ onEdit(['pitch'], v))} + multiline + ariaLabel="Pitch" + /> diff --git a/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx b/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx index 5f8936c..12d0ca2 100644 --- a/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx +++ b/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx @@ -1,15 +1,21 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import type { HackerNewsContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' -export function HackerNewsSections({ content, loading = false }: PlatformSectionsProps) { +export function HackerNewsSections({ content, loading = false, onEdit }: PlatformSectionsProps) { const hn = content as HackerNewsContent return (
-

{hn.title}

+ onEdit(['title'], v))} + ariaLabel="Show HN title" + className="flex-1 font-medium" + />
@@ -19,7 +25,12 @@ export function HackerNewsSections({ content, loading = false }: PlatformSection Body
-

{hn.postBody}

+ onEdit(['postBody'], v))} + multiline + ariaLabel="Post body" + />
@@ -27,7 +38,12 @@ export function HackerNewsSections({ content, loading = false }: PlatformSection Comment -

{hn.firstComment}

+ onEdit(['firstComment'], v))} + multiline + ariaLabel="First comment" + />
diff --git a/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx b/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx index 87ff157..8ee09d9 100644 --- a/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx +++ b/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx @@ -6,12 +6,12 @@ import type { ProductHuntContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' // Launch-day ops live in their own "Launch ops" tab in PostKit, not in this edit view. -export function ProductHuntSections({ content, loading }: PlatformSectionsProps) { +export function ProductHuntSections({ content, loading, onEdit }: PlatformSectionsProps) { const kit = content as ProductHuntContent return (
- - + +
diff --git a/components/post-kit/platforms/RedditSections/RedditSections.tsx b/components/post-kit/platforms/RedditSections/RedditSections.tsx index f53b4b2..424a2a4 100644 --- a/components/post-kit/platforms/RedditSections/RedditSections.tsx +++ b/components/post-kit/platforms/RedditSections/RedditSections.tsx @@ -1,9 +1,10 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import type { RedditContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' -export function RedditSections({ content, loading = false }: PlatformSectionsProps) { +export function RedditSections({ content, loading = false, onEdit }: PlatformSectionsProps) { const reddit = content as RedditContent return (
@@ -33,14 +34,24 @@ export function RedditSections({ content, loading = false }: PlatformSectionsPro Title
-

{reddit.title}

+ onEdit(['title'], v))} + ariaLabel="Reddit post title" + className="font-medium" + />
Body
-

{reddit.body}

+ onEdit(['body'], v))} + multiline + ariaLabel="Reddit post body" + />
diff --git a/components/post-kit/platforms/SocialSections/SocialSections.test.tsx b/components/post-kit/platforms/SocialSections/SocialSections.test.tsx index a21a85b..a0a1d35 100644 --- a/components/post-kit/platforms/SocialSections/SocialSections.test.tsx +++ b/components/post-kit/platforms/SocialSections/SocialSections.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' import { SocialSections } from './SocialSections' import type { LaunchCore, SocialContent } from '@/lib/types' @@ -25,4 +25,18 @@ describe('SocialSections', () => { expect(screen.getByText('Would you share how you use Acme?')).toBeInTheDocument() expect(screen.getByText('#buildinpublic')).toBeInTheDocument() }) + + it('commits a tweet edit with the correct array path', () => { + const onEdit = vi.fn() + render() + const tweet = screen.getByRole('textbox', { name: 'Tweet 2' }) + tweet.textContent = 'Edited second tweet' + fireEvent.blur(tweet) + expect(onEdit).toHaveBeenCalledWith(['thread', 'tweets', 1], 'Edited second tweet') + }) + + it('renders tweets read-only when onEdit is omitted', () => { + render() + expect(screen.queryByRole('textbox', { name: 'Tweet 1' })).toBeNull() + }) }) diff --git a/components/post-kit/platforms/SocialSections/SocialSections.tsx b/components/post-kit/platforms/SocialSections/SocialSections.tsx index 4056013..e6332f6 100644 --- a/components/post-kit/platforms/SocialSections/SocialSections.tsx +++ b/components/post-kit/platforms/SocialSections/SocialSections.tsx @@ -1,9 +1,10 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import type { SocialContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' -export function SocialSections({ content, loading = false }: PlatformSectionsProps) { +export function SocialSections({ content, loading = false, onEdit }: PlatformSectionsProps) { const social = content as SocialContent const fullThread = social.thread.tweets.join('\n\n') const channels: { label: string; value: string }[] = [ @@ -21,12 +22,17 @@ export function SocialSections({ content, loading = false }: PlatformSectionsPro
    {social.thread.tweets.map((tweet, i) => ( -
  1. +
  2. {i + 1}/{social.thread.tweets.length}
    -

    {tweet}

    + onEdit(['thread', 'tweets', i], v))} + multiline + ariaLabel={`Tweet ${i + 1}`} + />
  3. ))}
diff --git a/components/post-kit/platforms/platform-sections.types.ts b/components/post-kit/platforms/platform-sections.types.ts index 06b5e65..5d20325 100644 --- a/components/post-kit/platforms/platform-sections.types.ts +++ b/components/post-kit/platforms/platform-sections.types.ts @@ -1,5 +1,14 @@ import type { LaunchCore, PlatformContent } from '@/lib/types' +// Commits an inline edit of one prose field. `path` is relative to the active +// platform's content object (e.g. ['copy','tagline'], ['thread','tweets',2]). +export type EditFieldFn = (path: (string | number)[], value: string) => void + // Every platform's edit-view section group takes the shared core plus its own -// content (narrowed internally) and a loading flag for the regenerate shimmer. -export type PlatformSectionsProps = { core: LaunchCore; content: PlatformContent; loading?: boolean } +// content (narrowed internally), a loading flag, and an optional inline-edit hook. +export type PlatformSectionsProps = { + core: LaunchCore + content: PlatformContent + loading?: boolean + onEdit?: EditFieldFn +} diff --git a/components/post-kit/sections/CopySection/CopySection.tsx b/components/post-kit/sections/CopySection/CopySection.tsx index 177f0cf..04b9ed3 100644 --- a/components/post-kit/sections/CopySection/CopySection.tsx +++ b/components/post-kit/sections/CopySection/CopySection.tsx @@ -1,16 +1,22 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import { Badge } from '@/components/ui/badge' import type { CopySectionProps } from './CopySection.types' -export function CopySection({ kit, loading = false }: CopySectionProps) { +export function CopySection({ kit, loading = false, onEdit }: CopySectionProps) { const { copy } = kit return (
Tagline ({copy.tagline.length}/60)
-

{copy.tagline}

+ onEdit(['copy', 'tagline'], v))} + ariaLabel="Tagline" + className="flex-1 font-medium" + />
@@ -26,7 +32,12 @@ export function CopySection({ kit, loading = false }: CopySectionProps) { Description
-

{copy.description}

+ onEdit(['copy', 'description'], v))} + multiline + ariaLabel="Description" + />
) diff --git a/components/post-kit/sections/CopySection/CopySection.types.ts b/components/post-kit/sections/CopySection/CopySection.types.ts index 937e619..16dcb06 100644 --- a/components/post-kit/sections/CopySection/CopySection.types.ts +++ b/components/post-kit/sections/CopySection/CopySection.types.ts @@ -1,3 +1,4 @@ import type { LaunchKit } from '@/lib/types' +import type { EditFieldFn } from '@/components/post-kit/platforms/platform-sections.types' -export type CopySectionProps = { kit: LaunchKit; loading?: boolean } +export type CopySectionProps = { kit: LaunchKit; loading?: boolean; onEdit?: EditFieldFn } diff --git a/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.tsx b/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.tsx index d645d10..9962668 100644 --- a/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.tsx +++ b/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.tsx @@ -1,9 +1,10 @@ import { SectionCard } from '@/components/common/SectionCard' import { CopyButton } from '@/components/common/CopyButton' +import { EditableText } from '@/components/common/EditableText' import { Badge } from '@/components/ui/badge' import type { TopicsCommentSectionProps } from './TopicsCommentSection.types' -export function TopicsCommentSection({ kit, loading = false }: TopicsCommentSectionProps) { +export function TopicsCommentSection({ kit, loading = false, onEdit }: TopicsCommentSectionProps) { return (
@@ -14,7 +15,12 @@ export function TopicsCommentSection({ kit, loading = false }: TopicsCommentSect Maker's first comment
-

{kit.firstComment}

+ onEdit(['firstComment'], v))} + multiline + ariaLabel="Maker's first comment" + />
) diff --git a/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.types.ts b/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.types.ts index ae903c6..8957f63 100644 --- a/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.types.ts +++ b/components/post-kit/sections/TopicsCommentSection/TopicsCommentSection.types.ts @@ -1,3 +1,4 @@ import type { LaunchKit } from '@/lib/types' +import type { EditFieldFn } from '@/components/post-kit/platforms/platform-sections.types' -export type TopicsCommentSectionProps = { kit: LaunchKit; loading?: boolean } +export type TopicsCommentSectionProps = { kit: LaunchKit; loading?: boolean; onEdit?: EditFieldFn } diff --git a/lib/wizard/set-in.test.ts b/lib/wizard/set-in.test.ts new file mode 100644 index 0000000..daeaf61 --- /dev/null +++ b/lib/wizard/set-in.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { setIn } from './set-in' + +describe('setIn', () => { + it('sets a nested object key immutably', () => { + const o = { copy: { tagline: 'a', description: 'd' } } + const next = setIn(o, ['copy', 'tagline'], 'b') + expect(next).toEqual({ copy: { tagline: 'b', description: 'd' } }) + expect(o.copy.tagline).toBe('a') + expect(next).not.toBe(o) + expect(next.copy).not.toBe(o.copy) + }) + + it('sets an array index immutably', () => { + const o = { thread: { tweets: ['x', 'y', 'z'] } } + const next = setIn(o, ['thread', 'tweets', 1], 'Y') + expect(next.thread.tweets).toEqual(['x', 'Y', 'z']) + expect(o.thread.tweets[1]).toBe('y') + expect(next.thread.tweets).not.toBe(o.thread.tweets) + }) + + it('replaces the whole value at an empty path', () => { + expect(setIn({ a: 1 }, [], 'whole')).toBe('whole') + }) +}) diff --git a/lib/wizard/set-in.ts b/lib/wizard/set-in.ts new file mode 100644 index 0000000..4f6a939 --- /dev/null +++ b/lib/wizard/set-in.ts @@ -0,0 +1,13 @@ +// Immutable deep-set: returns a copy of `obj` with `value` written at `path`, +// cloning only the nodes along the path. Supports object keys and array indices. +export function setIn(obj: T, path: ReadonlyArray, value: unknown): T { + if (path.length === 0) return value as T + const [key, ...rest] = path + if (typeof key === 'number') { + const arr = Array.isArray(obj) ? obj.slice() : [] + arr[key] = setIn(arr[key], rest, value) + return arr as T + } + const src = obj && typeof obj === 'object' ? (obj as Record) : {} + return { ...src, [key]: setIn(src[key], rest, value) } as T +} diff --git a/lib/wizard/wizard-reducer.test.ts b/lib/wizard/wizard-reducer.test.ts index 0a3a9a3..c5cc911 100644 --- a/lib/wizard/wizard-reducer.test.ts +++ b/lib/wizard/wizard-reducer.test.ts @@ -61,4 +61,27 @@ describe('wizardReducer', () => { expect(re.generation).toBeNull() expect(re.step).toBe('review') }) + + it('EDIT_FIELD deep-sets a nested prose field immutably', () => { + const editable = { + core: gen.core, + platforms: { 'product-hunt': { copy: { tagline: 'old' } }, social: { thread: { tweets: ['a', 'b'] } } }, + } as unknown as Generation + const atKit = { ...initialState, step: 'kit' as const, context: ctx, generation: editable } + const s = wizardReducer(atKit, { type: 'EDIT_FIELD', platform: 'product-hunt', path: ['copy', 'tagline'], value: 'new' }) + expect((s.generation!.platforms['product-hunt'] as { copy: { tagline: string } }).copy.tagline).toBe('new') + expect((editable.platforms['product-hunt'] as { copy: { tagline: string } }).copy.tagline).toBe('old') + }) + + it('EDIT_FIELD edits a social tweet by array index', () => { + const editable = { core: gen.core, platforms: { social: { thread: { tweets: ['a', 'b'] } } } } as unknown as Generation + const atKit = { ...initialState, step: 'kit' as const, context: ctx, generation: editable } + const s = wizardReducer(atKit, { type: 'EDIT_FIELD', platform: 'social', path: ['thread', 'tweets', 0], value: 'A' }) + expect((s.generation!.platforms.social as { thread: { tweets: string[] } }).thread.tweets).toEqual(['A', 'b']) + }) + + it('EDIT_FIELD is a no-op when there is no generation', () => { + const s = wizardReducer(initialState, { type: 'EDIT_FIELD', platform: 'reddit', path: ['title'], value: 'x' }) + expect(s.generation).toBeNull() + }) }) diff --git a/lib/wizard/wizard-reducer.ts b/lib/wizard/wizard-reducer.ts index d68487f..5a86d74 100644 --- a/lib/wizard/wizard-reducer.ts +++ b/lib/wizard/wizard-reducer.ts @@ -1,5 +1,6 @@ import type { ProjectContext, Refinements, Generation, PlatformContent } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' +import { setIn } from './set-in' export type WizardStep = 'folder' | 'review' | 'kit' @@ -18,6 +19,7 @@ export type WizardAction = | { type: 'EDIT_REFINEMENTS'; refinements: Refinements } | { type: 'GENERATED'; generation: Generation } | { type: 'REGEN_PLATFORM'; platform: PlatformId; content: PlatformContent } + | { type: 'EDIT_FIELD'; platform: PlatformId; path: (string | number)[]; value: string } | { type: 'GO'; step: WizardStep } | { type: 'HYDRATE'; state: WizardState } | { type: 'RESET' } @@ -43,6 +45,17 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS }, } : state + case 'EDIT_FIELD': + // Inline edit: deep-set a single field inside the active platform's content. + return state.generation + ? { + ...state, + generation: { + ...state.generation, + platforms: setIn(state.generation.platforms, [action.platform, ...action.path], action.value), + }, + } + : state case 'GO': return { ...state, step: action.step } case 'HYDRATE': return action.state case 'RESET': return initialState