diff --git a/prd.json b/prd.json index fa18f0b..dca59fa 100644 --- a/prd.json +++ b/prd.json @@ -694,6 +694,59 @@ "priority": 39, "passes": true, "notes": "" + }, + { + "id": "US-040", + "title": "Conditionals on choice options", + "description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).", + "acceptanceCriteria": [ + "Each ChoiceOption can have optional condition (variableName, operator, value)", + "Update ChoiceNode UI to show 'Add condition' button per option", + "Condition editor modal for each option", + "Visual indicator (icon/badge) on options with conditions", + "Update TypeScript types: ChoiceOption gets optional condition field", + "Export includes per-option conditions in Ren'Py JSON", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 40, + "passes": true, + "notes": "Dependencies: US-018, US-019, US-025. Complexity: M" + }, + { + "id": "US-041", + "title": "Change password for logged-in user", + "description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.", + "acceptanceCriteria": [ + "Settings/profile page accessible from dashboard header", + "Form with: current password, new password, confirm new password fields", + "Calls Supabase updateUser with new password", + "Requires current password verification (re-authenticate)", + "Shows success/error messages", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 41, + "passes": false, + "notes": "Dependencies: US-004. Complexity: S" + }, + { + "id": "US-042", + "title": "Password reset modal on token arrival", + "description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.", + "acceptanceCriteria": [ + "Detect password reset token in URL (from Supabase email link)", + "Show modal/dialog automatically when token present", + "Modal has: new password, confirm password fields", + "Calls Supabase updateUser with token to complete reset", + "On success, close modal and redirect to login", + "On error, show error message", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 42, + "passes": false, + "notes": "Dependencies: US-006. Complexity: S" } ] } diff --git a/progress.txt b/progress.txt index a62938e..280f732 100644 --- a/progress.txt +++ b/progress.txt @@ -586,3 +586,20 @@ - LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts - Link component from next/link is needed in server components for navigation (no useRouter in server components) --- + +## 2026-01-22 - US-040 +- What was implemented: Conditionals on choice options - per-option visibility conditions +- Files changed: + - src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption + - src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management + - src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value) + - src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition) +- **Learnings for future iterations:** + - Per-option conditions use the same Condition type as edge conditions + - Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it + - Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings + - OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs + - In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility + - Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not + - Condition badge below option shows "if variableName operator value" text in compact format +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 60bb57d..707cebc 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -297,6 +297,11 @@ function convertToRenpyFormat( } } + // Per-option condition (visibility condition) takes priority over edge condition + if (option.condition) { + choice.condition = option.condition + } + return choice }) diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx new file mode 100644 index 0000000..23739c5 --- /dev/null +++ b/src/components/editor/OptionConditionEditor.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import type { Condition } from '@/types/flowchart' + +type OptionConditionEditorProps = { + optionId: string + optionLabel: string + condition?: Condition + onSave: (optionId: string, condition: Condition) => void + onRemove: (optionId: string) => void + onCancel: () => void +} + +const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] + +export default function OptionConditionEditor({ + optionId, + optionLabel, + condition, + onSave, + onRemove, + onCancel, +}: OptionConditionEditorProps) { + const [variableName, setVariableName] = useState(condition?.variableName ?? '') + const [operator, setOperator] = useState(condition?.operator ?? '==') + const [value, setValue] = useState(condition?.value ?? 0) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onCancel]) + + const handleSave = useCallback(() => { + if (!variableName.trim()) return + onSave(optionId, { + variableName: variableName.trim(), + operator, + value, + }) + }, [optionId, variableName, operator, value, onSave]) + + const handleRemove = useCallback(() => { + onRemove(optionId) + }, [optionId, onRemove]) + + const hasExistingCondition = !!condition + + return ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'} +

+

+ Option: {optionLabel || '(unnamed)'} +

+ +
+ {/* Variable Name Input */} +
+ + setVariableName(e.target.value)} + placeholder="e.g., affection, score, health" + autoFocus + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + setValue(parseFloat(e.target.value) || 0)} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Show option when: {variableName.trim()} {operator} {value} + +
+ )} +
+ + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 7073d2d..82bca9b 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -1,12 +1,15 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' +import type { Condition } from '@/types/flowchart' +import OptionConditionEditor from '@/components/editor/OptionConditionEditor' type ChoiceOption = { id: string label: string + condition?: Condition } type ChoiceNodeData = { @@ -19,6 +22,7 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) const updatePrompt = useCallback( (e: ChangeEvent) => { @@ -96,6 +100,57 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, data.options.length, setNodes] ) + const handleSaveCondition = useCallback( + (optionId: string, condition: Condition) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => + opt.id === optionId ? { ...opt, condition } : opt + ), + }, + } + : node + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const handleRemoveCondition = useCallback( + (optionId: string) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => { + if (opt.id !== optionId) return opt + const updated = { ...opt } + delete updated.condition + return updated + }), + }, + } + : node + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const editingOption = editingConditionOptionId + ? data.options.find((opt) => opt.id === editingConditionOptionId) + : null + return (
) {
{data.options.map((option, index) => ( -
- updateOptionLabel(option.id, e.target.value)} - placeholder={`Option ${index + 1}`} - className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" - /> - - +
+
+ updateOptionLabel(option.id, e.target.value)} + placeholder={`Option ${index + 1}`} + className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" + /> + + + +
+ {option.condition && ( +
+ + if {option.condition.variableName} {option.condition.operator} {option.condition.value} + +
+ )}
))}
@@ -158,6 +242,17 @@ export default function ChoiceNode({ id, data }: NodeProps) { > + Add Option + + {editingOption && ( + setEditingConditionOptionId(null)} + /> + )}
) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 21a5c54..50fee50 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -4,6 +4,13 @@ export type Position = { y: number; }; +// Condition type for conditional edges and choice options +export type Condition = { + variableName: string; + operator: '>' | '<' | '==' | '>=' | '<=' | '!='; + value: number; +}; + // DialogueNode type: represents character speech/dialogue export type DialogueNode = { id: string; @@ -19,6 +26,7 @@ export type DialogueNode = { export type ChoiceOption = { id: string; label: string; + condition?: Condition; }; // ChoiceNode type: represents branching decisions @@ -47,13 +55,6 @@ export type VariableNode = { // Union type for all node types export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode; -// Condition type for conditional edges -export type Condition = { - variableName: string; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; -}; - // FlowchartEdge type: represents connections between nodes export type FlowchartEdge = { id: string;