diff --git a/AGENTS.md b/AGENTS.md index 86561fb9f..4c4cb760a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,9 @@ - `desktop/`: Windows desktop (Tauri + Preact, экраны `mobile/src`). TypeScript адаптеры в `desktop/src/platform`, Rust-процессы, загрузки, mpv и обновления в `desktop/src-tauri`, тесты в `desktop/tests`. NSIS-установщик собирается через `npm --prefix desktop run tauri -- build --bundles nsis`. Установка и переносимость описаны в `docs/desktop-cross-platform.md`. -- `editor/`: локальный визуальный редактор тем и макета (flow/absolute, слои, пресеты, сравнение, доступность), запуск `npm run editor`, сборка `npm run build:editor`. +- `editor/`: локальный визуальный редактор тем, макета и логики (flow/absolute, слои, пресеты, сравнение, доступность, node canvas потоков с debugger и шаблонами), запуск `npm run editor`, сборка `npm run build:editor`. `design/`: версионированные JSON-темы и правила, семантические tokens и детерминированный CSS generator. - Проверки: `npm run test:editor`, `npm run editor:generate -- --check`. Production макет активируется только явным экспортом через generated-модуль `design/generated/layout.ts`; autosave production не меняет. + Проверки: `npm run test:editor`, `npm run editor:generate -- --check`. Production макет активируется только явным экспортом через generated-модуль `design/generated/layout.ts`, логика потоков - через `design/generated/flow.ts`; autosave production не меняет. - `ops/billing/`: биллинг и админ-панель (`src/*.mjs` сервер и бот @OMPBILLINGBOT, `admin/` React-панель, Postgres). - `ops/vps/`: сервер (NUC): `docker-compose.yml` + `docker-compose.nuc.yml`, `Caddyfile.nuc`, `deploy.sh`, `jacred/`, `jackett-monitor/`, `site/`. - `scripts/`: релиз, CHANGELOG, Telegram-посты, TorrServer, проверки (`npm run check:boot`, `check:changelog`, `check:torrserver`, diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 3e267ba66..dd5c78b7f 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -6,6 +6,9 @@ The English translation covers the latest versions only; the full history (in Ru ## Unreleased +- [tech] The local editor became a visual logic editor: a node canvas with triggers, conditions, actions and variables, output-input links, multi-select with a clipboard, five flow templates and a step-by-step debugger with mocks and trace comparison. Node editing is available on the desktop target and the settings page only +- [tech] Logic flows pass validation (cycles, unreachable nodes, value types) and export together with the layout: the scoped diff shows changed rules, nodes, edges and variables. Production apps apply logic only after an explicit export through the generated module design/generated/flow.ts; autosave and preview do not change production + ## 0.30.1-beta.6 - [phone] The embedded 2160 Player is updated to 0.2.26. The seek bar is full width with equal side gutters, elapsed time on the left and remaining on the right (both show --:-- until duration is known), and buffering shows a spinner in the centre button diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdb5e537..8281e1663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## Не выпущено +- [tech] Локальный редактор стал визуальным редактором логики: node canvas с триггерами, условиями, действиями и переменными, связи output-input, мульти-выбор с буфером обмена, пять шаблонов потоков и пошаговый debugger с моками и сравнением traces. Node editing доступен только на desktop-цели и странице настроек +- [tech] Потоки логики проходят проверки (циклы, недостижимые узлы, типы значений) и экспортируются вместе с макетом: scoped diff показывает изменённые правила, узлы, связи и переменные. Production-приложения применяют логику только после явного экспорта через сгенерированный модуль design/generated/flow.ts; autosave и preview production не меняют + ## 0.30.1-beta.6 - [phone] Встроенный плеер 2160 обновлён до 0.2.26. Полоса перемотки на всю ширину с одинаковыми боковыми отступами, прошедшее слева и остаток справа (пока длина неизвестна, обе стороны --:--), при буферизации в центре спиннер diff --git a/design/flow-graph.ts b/design/flow-graph.ts new file mode 100644 index 000000000..86096792a --- /dev/null +++ b/design/flow-graph.ts @@ -0,0 +1,614 @@ +import { compareCondition, dispatchInteraction, type InteractionContext, type InteractionDispatcher } from './interaction.ts'; +import type { + ConditionPath, + FlowDocument, + FlowEdge, + FlowEdgeBranch, + FlowEnvironment, + FlowEventType, + FlowGraph, + FlowNodeKind, + FlowNodeView, + FlowVariable, + FlowVariableValue, + InteractionAction, + InteractionCondition, + InteractionRule, +} from './schema.ts'; + +// Runtime and execution policy limits (design 2026-11-12, section 9). +export const FLOW_STEP_LIMIT = 32; + +export interface FlowDispatchOutcome { + ok: boolean; + error?: string; +} + +// Typed synchronous dispatcher: graph actions call these instead of executing directly. +// Async application APIs stay in the existing adapters; the interpreter never awaits them. +export interface FlowDispatcher { + navigate(screenId: string): FlowDispatchOutcome; + back(): FlowDispatchOutcome; + openScreen(screenId: string): FlowDispatchOutcome; + toast(message: string): FlowDispatchOutcome; + dialog(title: string, message: string): FlowDispatchOutcome; + playbackPlayPause(): FlowDispatchOutcome; + playbackStop(): FlowDispatchOutcome; + playbackNextEpisode(): FlowDispatchOutcome; + queueAdd(): FlowDispatchOutcome; + queueReplace(): FlowDispatchOutcome; + serverConnect(): FlowDispatchOutcome; + serverReconnect(): FlowDispatchOutcome; + serverRetry(): FlowDispatchOutcome; + sourceRetry(): FlowDispatchOutcome; + sourceNext(): FlowDispatchOutcome; + linkOpen(url: string): FlowDispatchOutcome; + telegramOpen(target: string): FlowDispatchOutcome; +} + +export interface FlowEvent { + type: FlowEventType; + screenId: string; + elementId: string; +} + +// One event creates one execution context: documented read-only environment values plus +// session variables. Variables never leave the session and are dropped by resetFlowSession. +export interface ExecutionContext extends FlowEnvironment { + variables: Record; +} + +export type ExecutionStatus = 'completed' | 'not-matched' | 'error' | 'step-limit'; + +export interface ExecutionTraceEntry { + ruleId: string; + nodeId?: string; + stepId?: string; + kind?: FlowNodeKind; + branch?: FlowEdgeBranch; + condition?: boolean; + action?: InteractionAction['type']; + target?: string; + message?: string; + error?: string; +} + +export interface ExecutionResult { + status: ExecutionStatus; + matched: 'legacy' | 'graph' | null; + trace: ExecutionTraceEntry[]; + steps: number; + variables: Record; + route?: string; + toast?: string; + error?: string; +} + +const DEFAULT_ENVIRONMENT: FlowEnvironment = { + target: 'desktop-windows', + screenId: 'settings', + serverConnected: false, + networkOnline: true, + accountSignedIn: false, + playbackState: 'idle', + downloadState: 'idle', +}; + +interface FlowSession { + env: FlowEnvironment; + variables: Record; + lastRunAt: Record; +} + +let session: FlowSession | null = null; + +function currentSession(): FlowSession { + if (!session) session = { env: { ...DEFAULT_ENVIRONMENT }, variables: {}, lastRunAt: {} }; + return session; +} + +export function beginFlowSession(env: FlowEnvironment): void { + session = { env: { ...env }, variables: {}, lastRunAt: {} }; +} + +export function resetFlowSession(): void { + session = null; +} + +// Production publishers refresh the read-only environment values between events without +// dropping session variables or cooldown bookkeeping. +export function updateFlowEnvironment(env: FlowEnvironment): void { + currentSession().env = { ...env }; +} + +function createContext(event: FlowEvent): ExecutionContext & { route?: string; toast?: string; error?: string } { + const active = currentSession(); + return { ...active.env, screenId: event.screenId, variables: { ...active.variables } }; +} + +function commitContext(context: ExecutionContext): void { + currentSession().variables = { ...context.variables }; +} + +function ensureDocumentVariables(flow: FlowDocument, active: FlowSession): void { + for (const variable of flow.variables ?? []) { + if (active.variables[variable.id] === undefined) active.variables[variable.id] = variable.initial; + } +} + +// One initialization per session: declared variables get their initial values before any +// event runs (click through executeFlow does the same; publisher events drive rules directly). +export function ensureFlowDocumentVariables(flow: FlowDocument): void { + ensureDocumentVariables(flow, currentSession()); +} + +function resolvePath(path: ConditionPath, context: ExecutionContext): FlowVariableValue | undefined { + if (path === 'server.connected') return context.serverConnected; + if (path === 'target') return context.target; + if (path === 'screen.current') return context.screenId; + if (path === 'network.online') return context.networkOnline; + if (path === 'account.signedIn') return context.accountSignedIn; + if (path === 'playback.state') return context.playbackState; + if (path === 'download.state') return context.downloadState; + return context.variables[path.slice('variable.'.length)]; +} + +function evaluateCondition(condition: InteractionCondition, context: ExecutionContext): boolean { + if (condition.kind === 'all') return condition.conditions.every((entry) => evaluateCondition(entry, context)); + if (condition.kind === 'any') return condition.conditions.some((entry) => evaluateCondition(entry, context)); + if (condition.kind === 'not') return !evaluateCondition(condition.condition, context); + const actual = resolvePath(condition.path, context); + if (condition.kind === 'exists') return actual !== undefined; + return compareCondition(condition.kind, actual, condition.value); +} + +function variableAccepts(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +export interface MutableContext extends ExecutionContext { + route?: string; + toast?: string; + error?: string; +} + +function runAction( + action: InteractionAction, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, +): FlowDispatchOutcome { + switch (action.type) { + case 'navigate': { + const outcome = dispatcher.navigate(action.screenId); + if (outcome.ok) context.route = action.screenId; + return outcome; + } + case 'back': + return dispatcher.back(); + case 'openScreen': { + const outcome = dispatcher.openScreen(action.screenId); + if (outcome.ok) context.route = action.screenId; + return outcome; + } + case 'toast': { + const outcome = dispatcher.toast(action.message); + if (outcome.ok) context.toast = action.message; + return outcome; + } + case 'dialog': + return dispatcher.dialog(action.title, action.message); + case 'playback.playPause': + return dispatcher.playbackPlayPause(); + case 'playback.stop': + return dispatcher.playbackStop(); + case 'playback.nextEpisode': + return dispatcher.playbackNextEpisode(); + case 'queue.add': + return dispatcher.queueAdd(); + case 'queue.replace': + return dispatcher.queueReplace(); + case 'server.connect': + return dispatcher.serverConnect(); + case 'server.reconnect': + return dispatcher.serverReconnect(); + case 'server.retry': + return dispatcher.serverRetry(); + case 'source.retry': + return dispatcher.sourceRetry(); + case 'source.next': + return dispatcher.sourceNext(); + case 'variable.set': { + const variable = declared.get(action.variableId); + if (!variable) return { ok: false, error: `unknown variable ${action.variableId}` }; + if (!variableAccepts(variable, action.value)) return { ok: false, error: `variable ${action.variableId} expects ${variable.type}` }; + context.variables[action.variableId] = action.value; + return { ok: true }; + } + case 'link.open': + return dispatcher.linkOpen(action.url); + case 'telegram.open': + return dispatcher.telegramOpen(action.target); + } +} + +// Terminal actions end the branch after they run; toast, dialog, telegram.open and queue.add +// are fire-and-forget and let the walk continue along the default edge. queue.add appends +// to the open queue without touching the route, so a chain like «append, then next episode» +// stays one branch. +export const TERMINAL_ACTIONS = new Set([ + 'navigate', + 'back', + 'openScreen', + 'playback.playPause', + 'playback.stop', + 'playback.nextEpisode', + 'queue.replace', + 'server.connect', + 'server.reconnect', + 'server.retry', + 'source.retry', + 'source.next', + 'link.open', +]); + +interface RunState { + steps: number; +} + +export interface RunOutcome { + status: ExecutionStatus; +} + +function traceError(trace: ExecutionTraceEntry[], ruleId: string, message: string): void { + trace.push({ ruleId, error: message }); +} + +function nextByBranch(edges: FlowEdge[], from: string, branch: FlowEdgeBranch): FlowEdge | undefined { + return edges.find((edge) => edge.from === from && edge.branch === branch); +} + +// The walk is a generator so a debugger can drive the same execution node by node: +// executeGraphRule ignores the yielded node ids and drives to completion, the editor +// debugger steps and pauses on them. Yields happen after a node executed and, for graph +// rules, once for the trigger before any node runs. +function* runGraphWalk( + rule: InteractionRule, + graph: FlowGraph, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, + trace: ExecutionTraceEntry[], + state: RunState, +): Generator { + const steps = new Map((rule.steps ?? []).map((entry) => [entry.id, entry])); + const outgoing = new Map(); + for (const edge of graph.edges) { + outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]); + } + const trigger = graph.nodes.find((node) => node.kind === 'trigger') + ?? graph.nodes.find((node) => !graph.edges.some((edge) => edge.to === node.id)); + let current: FlowNodeView | undefined; + if (trigger) { + yield trigger.id; + const entryEdge = nextByBranch(outgoing.get(trigger.id) ?? [], trigger.id, 'default'); + current = entryEdge ? graph.nodes.find((node) => node.id === entryEdge.to) : undefined; + } + while (current) { + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + const step = current.stepId ? steps.get(current.stepId) : undefined; + if (current.kind === 'condition') { + const matched = step?.condition ? evaluateCondition(step.condition, context) : false; + trace.push({ ruleId: rule.id, nodeId: current.id, stepId: current.stepId, kind: 'condition', condition: matched }); + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, matched ? 'true' : 'false'); + yield current.id; + current = edge ? graph.nodes.find((node) => node.id === edge.to) : undefined; + continue; + } + if (current.kind === 'action' || current.kind === 'result') { + const entry: ExecutionTraceEntry = { ruleId: rule.id, nodeId: current.id, stepId: current.stepId, kind: current.kind }; + trace.push(entry); + if (step?.action) { + entry.action = step.action.type; + if (step.action.type === 'navigate' || step.action.type === 'openScreen') entry.target = step.action.screenId; + if (step.action.type === 'toast') entry.message = step.action.message; + let outcome: FlowDispatchOutcome; + try { + outcome = runAction(step.action, context, dispatcher, declared); + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!outcome.ok) { + entry.error = outcome.error ?? 'action failed'; + context.error = entry.error; + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, 'error'); + yield current.id; + if (!edge) return { status: 'error' }; + current = graph.nodes.find((node) => node.id === edge.to); + continue; + } + } + if (current.kind === 'result' || (step?.action && TERMINAL_ACTIONS.has(step.action.type))) { + yield current.id; + return { status: 'completed' }; + } + yield current.id; + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, 'default'); + current = edge ? graph.nodes.find((node) => node.id === edge.to) : undefined; + continue; + } + // Unknown kinds cannot pass validation; stop the walk instead of guessing. + return { status: 'completed' }; + } + return { status: 'completed' }; +} + +function driveWalk(walk: Generator): RunOutcome { + let result = walk.next(); + while (!result.done) result = walk.next(); + return result.value; +} + +function runGraph( + rule: InteractionRule, + graph: FlowGraph, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, + trace: ExecutionTraceEntry[], + state: RunState, +): RunOutcome { + return driveWalk(runGraphWalk(rule, graph, context, dispatcher, declared, trace, state)); +} + +function* runStepsWalk( + rule: InteractionRule, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, + trace: ExecutionTraceEntry[], + state: RunState, +): Generator { + for (const step of rule.steps ?? []) { + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + if (step.condition) { + const matched = evaluateCondition(step.condition, context); + trace.push({ ruleId: rule.id, stepId: step.id, kind: 'condition', condition: matched }); + if (!matched) { + yield step.id; + return { status: 'completed' }; + } + } + if (step.action) { + const entry: ExecutionTraceEntry = { ruleId: rule.id, stepId: step.id, kind: 'action', action: step.action.type }; + trace.push(entry); + let outcome: FlowDispatchOutcome; + try { + outcome = runAction(step.action, context, dispatcher, declared); + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!outcome.ok) { + entry.error = outcome.error ?? 'action failed'; + context.error = entry.error; + yield step.id; + return { status: 'error' }; + } + if (TERMINAL_ACTIONS.has(step.action.type)) { + yield step.id; + return { status: 'completed' }; + } + } + yield step.id; + } + // The rule action is the terminal step of an ordered chain without a graph. + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + const terminal: ExecutionTraceEntry = { ruleId: rule.id, kind: 'action', action: rule.action.type }; + if (rule.action.type === 'navigate' || rule.action.type === 'openScreen') terminal.target = rule.action.screenId; + if (rule.action.type === 'toast') terminal.message = rule.action.message; + trace.push(terminal); + let terminalOutcome: FlowDispatchOutcome; + try { + terminalOutcome = runAction(rule.action, context, dispatcher, declared); + } catch (error) { + terminalOutcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!terminalOutcome.ok) { + terminal.error = terminalOutcome.error ?? 'action failed'; + context.error = terminal.error; + return { status: 'error' }; + } + return { status: 'completed' }; +} + +function triggerMatches(rule: InteractionRule, event: FlowEvent, context: ExecutionContext): boolean { + const active = currentSession(); + if (rule.cooldownMs && active.lastRunAt[rule.id] !== undefined && Date.now() - active.lastRunAt[rule.id] < rule.cooldownMs) { + return false; + } + if (rule.screenId !== '*' && rule.screenId !== event.screenId) return false; + if (rule.event !== event.type) return false; + if (rule.elementId !== '*' && rule.elementId !== event.elementId) return false; + if (rule.condition && !evaluateCondition(rule.condition, context)) return false; + active.lastRunAt[rule.id] = Date.now(); + return true; +} + +function buildResult( + status: ExecutionStatus, + matched: ExecutionResult['matched'], + trace: ExecutionTraceEntry[], + state: RunState, + context: MutableContext, +): ExecutionResult { + const result: ExecutionResult = { + status, + matched, + trace, + steps: state.steps, + variables: { ...context.variables }, + }; + if (context.route !== undefined) result.route = context.route; + if (context.toast !== undefined) result.toast = context.toast; + if (context.error !== undefined) result.error = context.error; + return result; +} + +export function executeGraphRule( + rule: InteractionRule, + event: FlowEvent, + dispatcher: FlowDispatcher, + variables: FlowVariable[] = [], +): ExecutionResult { + const context = createContext(event); + const state: RunState = { steps: 0 }; + const trace: ExecutionTraceEntry[] = []; + if (!triggerMatches(rule, event, context)) { + return buildResult('not-matched', null, trace, state, context); + } + const declared = new Map(variables.map((variable) => [variable.id, variable])); + const outcome = rule.graph + ? runGraph(rule, rule.graph, context, dispatcher, declared, trace, state) + : driveWalk(runStepsWalk(rule, context, dispatcher, declared, trace, state)); + commitContext(context); + return buildResult(outcome.status, 'graph', trace, state, context); +} + +export interface RuleWalkStep { + done: boolean; + nodeId?: string; + outcome?: RunOutcome; +} + +// A re-entrant rule execution: the editor debugger drives it one node at a time while +// executeGraphRule drives an identical walk to completion, so stepping and running share +// one implementation. An optional external context (the debug session context) is mutated +// in place instead of the module session; variables commit stays with the module session. +export interface RuleWalk { + readonly matched: boolean; + readonly context: MutableContext; + readonly trace: ExecutionTraceEntry[]; + readonly steps: number; + step(): RuleWalkStep; +} + +export function createRuleWalk( + rule: InteractionRule, + event: FlowEvent, + dispatcher: FlowDispatcher, + variables: FlowVariable[] = [], + context?: ExecutionContext, +): RuleWalk { + const active: MutableContext = context ? (context as MutableContext) : createContext(event); + const state: RunState = { steps: 0 }; + const trace: ExecutionTraceEntry[] = []; + const matched = triggerMatches(rule, event, active); + const declared = new Map(variables.map((variable) => [variable.id, variable])); + const walk = matched + ? rule.graph + ? runGraphWalk(rule, rule.graph, active, dispatcher, declared, trace, state) + : runStepsWalk(rule, active, dispatcher, declared, trace, state) + : null; + return { + matched, + context: active, + trace, + get steps() { + return state.steps; + }, + step() { + if (!walk) return { done: true, outcome: { status: 'not-matched' } }; + const result = walk.next(); + if (result.done) { + if (!context) commitContext(active); + return { done: true, outcome: result.value }; + } + return { done: false, nodeId: result.value }; + }, + }; +} + +function isGraphRule(rule: InteractionRule): boolean { + return Boolean(rule.graph || (rule.steps && rule.steps.length)); +} + +function legacyDispatcher(dispatcher: FlowDispatcher): InteractionDispatcher { + return { + navigate: (screenId) => { + dispatcher.navigate(screenId); + }, + back: () => { + dispatcher.back(); + }, + toast: (message) => { + dispatcher.toast(message); + }, + }; +} + +function legacyResult(traces: ReturnType, variables: Record): ExecutionResult { + const trace: ExecutionTraceEntry[] = traces.map((entry) => { + const mapped: ExecutionTraceEntry = { ruleId: entry.ruleId, kind: 'action', action: entry.action }; + if (entry.target !== undefined) mapped.target = entry.target; + if (entry.message !== undefined) mapped.message = entry.message; + return mapped; + }); + const result: ExecutionResult = { + status: 'completed', + matched: 'legacy', + trace, + steps: trace.length, + variables: { ...variables }, + }; + const last = traces[traces.length - 1]; + if (last?.action === 'navigate' && last.target) result.route = last.target; + if (last?.action === 'toast' && last.message) result.toast = last.message; + return result; +} + +// One entry point for an event: legacy rules keep their original order and error semantics +// through the existing dispatchInteraction, and graph rules run only when no legacy rule matched. +export function executeFlow(flow: FlowDocument, event: FlowEvent, dispatcher: FlowDispatcher): ExecutionResult { + const active = currentSession(); + ensureDocumentVariables(flow, active); + if (event.type === 'click') { + const legacyContext: InteractionContext = { + target: active.env.target, + screenId: event.screenId, + serverConnected: active.env.serverConnected, + }; + const legacyEvent = { type: 'click' as const, screenId: event.screenId, elementId: event.elementId }; + const traces = dispatchInteraction(flow, legacyEvent, legacyContext, legacyDispatcher(dispatcher)); + if (traces.length) return legacyResult(traces, active.variables); + } + for (const rule of flow.rules) { + if (!isGraphRule(rule)) continue; + const result = executeGraphRule(rule, event, dispatcher, flow.variables ?? []); + if (result.matched === 'graph') return result; + } + return { + status: 'not-matched', + matched: null, + trace: [], + steps: 0, + variables: { ...active.variables }, + }; +} diff --git a/design/flow-module.ts b/design/flow-module.ts new file mode 100644 index 000000000..2b6993fed --- /dev/null +++ b/design/flow-module.ts @@ -0,0 +1,16 @@ +import type { FlowDocument } from './schema.ts'; + +export const FLOW_MODULE_OUTPUT = 'design/generated/flow.ts'; + +// The production runtime imports this module. It carries only the validated flow document +// as JSON, so preview and production share the same behavior after an explicit export. +export function generateFlowModule(flow: FlowDocument): string { + return [ + "import type { FlowDocument } from '../schema.ts';", + '', + '// Generated from design/flows. The export step rewrites this file with the reviewed', + '// flow document; a document without rules keeps the production runtime a no-op.', + `export const PRODUCTION_FLOW: FlowDocument = ${JSON.stringify(flow, null, 2)};`, + '', + ].join('\n'); +} diff --git a/design/flow-templates.ts b/design/flow-templates.ts new file mode 100644 index 000000000..7e86bd528 --- /dev/null +++ b/design/flow-templates.ts @@ -0,0 +1,367 @@ +import type { + FlowDocument, + FlowEdge, + FlowNodeView, + FlowVariable, + InteractionCondition, + InteractionRule, + InteractionStep, +} from './schema.ts'; + +export const FLOW_TEMPLATE_IDS = ['connect-server', 'offline-retry', 'episode-queue', 'playback-fallback', 'restore-position'] as const; +export type FlowTemplateId = (typeof FLOW_TEMPLATE_IDS)[number]; + +export interface FlowTemplateMeta { + id: FlowTemplateId; + title: string; + description: string; +} + +// Instantiation context: plain document identifiers and saved UI state only, never secrets. +// Every field is optional beyond id/baseCommit; templates fall back to their documented defaults. +export interface FlowTemplateContext { + id: string; + baseCommit: string; + screenId?: string; + elementId?: string; + routeId?: string; + savedRoute?: string; + savedPosition?: number; + savedElement?: string; +} + +export type FlowTemplateErrorCode = 'UNKNOWN_TEMPLATE' | 'INVALID_CONTEXT'; + +export class FlowTemplateError extends Error { + readonly code: FlowTemplateErrorCode; + + constructor(code: FlowTemplateErrorCode, message: string) { + super(`${code}: ${message}`); + this.code = code; + } +} + +export const FLOW_TEMPLATES: ReadonlyArray = [ + { + id: 'connect-server', + title: 'Подключение сервера', + description: 'Кнопка продолжения: при подключенном сервере сразу открывает каталог, иначе подключает сервер.', + }, + { + id: 'offline-retry', + title: 'Повтор при пропадании сети', + description: 'При событии network.offline показывает ошибку и повторяет подключение к серверу.', + }, + { + id: 'episode-queue', + title: 'Очередь серий', + description: 'Выбор серии добавляет её в очередь и запускает следующую серию.', + }, + { + id: 'playback-fallback', + title: 'Запасной источник', + description: 'Пробует другой источник воспроизведения, при неудаче показывает ошибку.', + }, + { + id: 'restore-position', + title: 'Возврат позиции: пример variables', + description: 'Конфигурируемый пример variables и графа: сохраняет вручную заданные route, позицию и элемент в переменные и возвращает к ним на входе. Живой restore в production требует отдельных typed adapters.', + }, +]; + +interface TemplateBody { + rules: InteractionRule[]; + variables: FlowVariable[]; +} + +interface FlowTemplateDefinition extends FlowTemplateMeta { + build: (context: FlowTemplateContext) => TemplateBody; +} + +// The placeholder terminal action keeps the document schema-valid for graph rules; behavior +// comes from the graph and its steps, the same convention as the canvas node operations. +const PLACEHOLDER_ACTION: InteractionRule['action'] = { type: 'back' }; + +function node(id: string, kind: FlowNodeView['kind'], x: number, y: number, label: string, stepId?: string): FlowNodeView { + const view: FlowNodeView = { id, kind, x, y, label }; + if (stepId) view.stepId = stepId; + return view; +} + +function edge(id: string, from: string, to: string, branch: FlowEdge['branch']): FlowEdge { + return { id, from, to, branch }; +} + +function graphRule( + id: string, + screenId: string, + elementId: string, + event: InteractionRule['event'], + nodes: FlowNodeView[], + edges: FlowEdge[], + steps: InteractionStep[], +): InteractionRule { + return { id, screenId, elementId, event, action: PLACEHOLDER_ACTION, steps, graph: { nodes, edges } }; +} + +const DEFINITIONS: ReadonlyArray = [ + { + id: 'connect-server', + title: FLOW_TEMPLATES[0].title, + description: FLOW_TEMPLATES[0].description, + build: (context) => ({ + variables: [], + rules: [ + graphRule( + 'connect-server', + context.screenId ?? 'connect', + context.elementId ?? 'connect-continue', + 'click', + [ + node('cs-trigger', 'trigger', 80, 120, 'Продолжить'), + node('cs-check', 'condition', 320, 120, 'Сервер подключен?', 'cs-check-step'), + node('cs-go-catalog', 'result', 560, 40, 'В каталог', 'cs-go-step'), + node('cs-connect', 'result', 560, 200, 'Подключить сервер', 'cs-connect-step'), + ], + [ + edge('cs-edge-1', 'cs-trigger', 'cs-check', 'default'), + edge('cs-edge-2', 'cs-check', 'cs-go-catalog', 'true'), + edge('cs-edge-3', 'cs-check', 'cs-connect', 'false'), + ], + [ + { id: 'cs-check-step', condition: { kind: 'eq', path: 'server.connected', value: true } }, + { id: 'cs-go-step', action: { type: 'navigate', screenId: context.routeId ?? 'library' } }, + { id: 'cs-connect-step', action: { type: 'server.connect' } }, + ], + ), + ], + }), + }, + { + id: 'offline-retry', + title: FLOW_TEMPLATES[1].title, + description: FLOW_TEMPLATES[1].description, + build: (context) => ({ + variables: [], + rules: [ + graphRule( + 'offline-retry', + context.screenId ?? '*', + context.elementId ?? 'network', + 'network.offline', + [ + node('or-trigger', 'trigger', 80, 120, 'Сеть пропала'), + node('or-toast', 'action', 320, 120, 'Ошибка: нет сети', 'or-toast-step'), + node('or-retry', 'action', 560, 120, 'Повторить подключение', 'or-retry-step'), + ], + [ + edge('or-edge-1', 'or-trigger', 'or-toast', 'default'), + edge('or-edge-2', 'or-toast', 'or-retry', 'default'), + ], + [ + { id: 'or-toast-step', action: { type: 'toast', message: 'Нет сети - подключение будет повторено' } }, + { id: 'or-retry-step', action: { type: 'server.retry' } }, + ], + ), + ], + }), + }, + { + id: 'episode-queue', + title: FLOW_TEMPLATES[2].title, + description: FLOW_TEMPLATES[2].description, + build: (context) => ({ + variables: [], + rules: [ + graphRule( + 'episode-queue', + context.screenId ?? 'library', + context.elementId ?? 'episode-item', + 'click', + [ + node('eq-trigger', 'trigger', 80, 120, 'Серия выбрана'), + node('eq-add', 'action', 320, 120, 'В очередь', 'eq-add-step'), + node('eq-next', 'action', 560, 120, 'Следующая серия', 'eq-next-step'), + ], + [ + edge('eq-edge-1', 'eq-trigger', 'eq-add', 'default'), + edge('eq-edge-2', 'eq-add', 'eq-next', 'default'), + ], + [ + { id: 'eq-add-step', action: { type: 'queue.add' } }, + { id: 'eq-next-step', action: { type: 'playback.nextEpisode' } }, + ], + ), + ], + }), + }, + { + id: 'playback-fallback', + title: FLOW_TEMPLATES[3].title, + description: FLOW_TEMPLATES[3].description, + build: (context) => ({ + variables: [], + rules: [ + graphRule( + 'playback-fallback', + context.screenId ?? 'remote', + context.elementId ?? 'source-fallback', + 'click', + [ + node('pf-trigger', 'trigger', 80, 120, 'Источник не работает'), + node('pf-next', 'action', 320, 120, 'Другой источник', 'pf-next-step'), + node('pf-error', 'result', 560, 120, 'Ошибка источника', 'pf-error-step'), + ], + [ + edge('pf-edge-1', 'pf-trigger', 'pf-next', 'default'), + edge('pf-edge-2', 'pf-next', 'pf-error', 'error'), + ], + [ + { id: 'pf-next-step', action: { type: 'source.next' } }, + { id: 'pf-error-step', action: { type: 'toast', message: 'Не удалось открыть другой источник' } }, + ], + ), + ], + }), + }, + { + id: 'restore-position', + title: FLOW_TEMPLATES[4].title, + description: FLOW_TEMPLATES[4].description, + build: (context) => { + const savedRoute = context.savedRoute ?? ''; + const savedPosition = context.savedPosition ?? 0; + const savedElement = context.savedElement ?? ''; + return { + variables: [ + { id: 'return-route', name: 'Сохраненный route', type: 'string', initial: savedRoute }, + { id: 'return-position', name: 'Сохраненная позиция', type: 'number', initial: savedPosition }, + { id: 'return-element', name: 'Сохраненный элемент', type: 'string', initial: savedElement }, + ], + rules: [ + graphRule( + 'restore-save', + context.screenId ?? 'library', + context.elementId ?? 'content', + 'screen.leave', + [ + node('rs-trigger', 'trigger', 80, 140, 'Уход со экрана'), + node('rs-element', 'action', 320, 40, 'Запомнить элемент', 'rs-element-step'), + node('rs-position', 'action', 320, 140, 'Запомнить позицию', 'rs-position-step'), + node('rs-route', 'action', 320, 240, 'Запомнить route', 'rs-route-step'), + node('rs-done', 'result', 560, 140, 'Сохранено'), + ], + [ + edge('rs-edge-1', 'rs-trigger', 'rs-element', 'default'), + edge('rs-edge-2', 'rs-element', 'rs-position', 'default'), + edge('rs-edge-3', 'rs-position', 'rs-route', 'default'), + edge('rs-edge-4', 'rs-route', 'rs-done', 'default'), + ], + [ + { id: 'rs-element-step', action: { type: 'variable.set', variableId: 'return-element', value: savedElement } }, + { id: 'rs-position-step', action: { type: 'variable.set', variableId: 'return-position', value: savedPosition } }, + { id: 'rs-route-step', action: { type: 'variable.set', variableId: 'return-route', value: savedRoute } }, + ], + ), + graphRule( + 'restore-enter', + context.screenId ?? 'library', + context.elementId ?? 'content', + 'screen.enter', + [ + node('re-trigger', 'trigger', 80, 140, 'Вход на экран'), + node('re-check', 'condition', 320, 140, 'Есть сохранение?', 're-check-step'), + node('re-back', 'result', 560, 60, 'Вернуться к просмотру', 're-back-step'), + node('re-empty', 'result', 560, 220, 'Нет сохранений'), + ], + [ + edge('re-edge-1', 're-trigger', 're-check', 'default'), + edge('re-edge-2', 're-check', 're-back', 'true'), + edge('re-edge-3', 're-check', 're-empty', 'false'), + ], + [ + { id: 're-check-step', condition: { kind: 'neq', path: 'variable.return-route', value: '' } }, + { id: 're-back-step', action: { type: 'navigate', screenId: context.savedRoute || context.routeId || 'library' } }, + ], + ), + ], + }; + }, + }, +]; + +const DEFINITION_BY_ID = new Map(DEFINITIONS.map((definition) => [definition.id, definition])); + +function assertContext(context: FlowTemplateContext): void { + if (!context || typeof context.id !== 'string' || context.id.trim() === '') { + throw new FlowTemplateError('INVALID_CONTEXT', 'context.id must be a non-empty string'); + } + if (typeof context.baseCommit !== 'string' || context.baseCommit.trim() === '') { + throw new FlowTemplateError('INVALID_CONTEXT', 'context.baseCommit must be a non-empty string'); + } + if (context.savedPosition !== undefined && typeof context.savedPosition !== 'number') { + throw new FlowTemplateError('INVALID_CONTEXT', 'context.savedPosition must be a number'); + } +} + +function rewriteCondition(condition: InteractionCondition, variableIdByOld: ReadonlyMap): void { + if (condition.kind === 'all' || condition.kind === 'any') { + for (const entry of condition.conditions) rewriteCondition(entry, variableIdByOld); + return; + } + if (condition.kind === 'not') { + rewriteCondition(condition.condition, variableIdByOld); + return; + } + if (condition.path.startsWith('variable.')) { + const mapped = variableIdByOld.get(condition.path.slice('variable.'.length)); + if (mapped) condition.path = `variable.${mapped}`; + } +} + +// Every instance gets a fresh random token in all rule/node/step/edge/variable ids, so two +// instantiations of one template can coexist in a single document without collisions. +function rewriteIds(body: TemplateBody, token: string): void { + const suffix = `-${token}`; + const variableIdByOld = new Map(body.variables.map((variable) => [variable.id, `${variable.id}${suffix}`])); + for (const variable of body.variables) variable.id = variableIdByOld.get(variable.id)!; + for (const rule of body.rules) { + rule.id = `${rule.id}${suffix}`; + const nodeIdByOld = new Map((rule.graph?.nodes ?? []).map((entry) => [entry.id, `${entry.id}${suffix}`])); + for (const entry of rule.graph?.nodes ?? []) { + entry.id = nodeIdByOld.get(entry.id)!; + entry.ruleId = rule.id; + if (entry.stepId) entry.stepId = `${entry.stepId}${suffix}`; + } + for (const entry of rule.graph?.edges ?? []) { + entry.id = `${entry.id}${suffix}`; + entry.from = nodeIdByOld.get(entry.from) ?? entry.from; + entry.to = nodeIdByOld.get(entry.to) ?? entry.to; + } + for (const step of rule.steps ?? []) step.id = `${step.id}${suffix}`; + if (rule.condition) rewriteCondition(rule.condition, variableIdByOld); + for (const step of rule.steps ?? []) { + if (step.condition) rewriteCondition(step.condition, variableIdByOld); + if (step.action?.type === 'variable.set') { + step.action.variableId = variableIdByOld.get(step.action.variableId) ?? step.action.variableId; + } + } + } +} + +export function instantiateFlowTemplate(templateId: FlowTemplateId, context: FlowTemplateContext): FlowDocument { + const definition = DEFINITION_BY_ID.get(templateId); + if (!definition) throw new FlowTemplateError('UNKNOWN_TEMPLATE', `unknown flow template ${String(templateId)}`); + assertContext(context); + // The clone guards the definition even if a build output ever becomes shared: user edits of + // the returned document can never leak back into the template source. + const body = structuredClone(definition.build(context)); + rewriteIds(body, crypto.randomUUID().replace(/-/g, '').slice(0, 12)); + return { + schemaVersion: 1, + id: context.id, + baseCommit: context.baseCommit, + rules: body.rules, + variables: body.variables.length ? body.variables : undefined, + }; +} diff --git a/design/flow-validation-graph.ts b/design/flow-validation-graph.ts new file mode 100644 index 000000000..a8af008f6 --- /dev/null +++ b/design/flow-validation-graph.ts @@ -0,0 +1,231 @@ +import { TERMINAL_ACTIONS } from './flow-graph.ts'; +import type { + FlowDocument, + FlowEdge, + FlowGraph, + FlowNodeView, + FlowVariable, + FlowVariableValue, + InteractionAction, + InteractionCondition, + InteractionRule, + InteractionStep, +} from './schema.ts'; +import type { FlowIssue } from './flow-validation.ts'; + +// The legacy pass only executes navigate, back and toast rules without steps or a graph; +// a rule with any other action and no steps or graph never runs in either runtime. +const LEGACY_ACTION_TYPES = new Set(['navigate', 'back', 'toast']); + +function variableAccepts(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +interface RuleScope { + rule: InteractionRule; + variables: ReadonlyMap; + routes: ReadonlySet; + issues: FlowIssue[]; + nodeByStep: Map; +} + +function checkCondition(condition: InteractionCondition, scope: RuleScope, nodeId?: string): void { + if (condition.kind === 'all' || condition.kind === 'any') { + for (const entry of condition.conditions) checkCondition(entry, scope, nodeId); + return; + } + if (condition.kind === 'not') { + checkCondition(condition.condition, scope, nodeId); + return; + } + const issue = (code: FlowIssue['code'], message: string) => + scope.issues.push({ code, ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); + // Only documented read-only context paths resolve without a declaration; variable. + // paths must reference a declared variable in every leaf, including inside nested groups. + let variable: FlowVariable | undefined; + if (condition.path.startsWith('variable.')) { + variable = scope.variables.get(condition.path.slice('variable.'.length)); + if (!variable) { + issue('UNKNOWN_VARIABLE', `condition references undeclared variable ${condition.path.slice('variable.'.length)}`); + return; + } + } + if (condition.kind === 'exists') return; + if (condition.kind === 'gt' || condition.kind === 'gte' || condition.kind === 'lt' || condition.kind === 'lte') { + if (typeof condition.value !== 'number') issue('WRONG_VALUE_TYPE', `condition ${condition.kind} expects a number value`); + return; + } + if (condition.kind === 'contains') { + if (typeof condition.value !== 'string') issue('WRONG_VALUE_TYPE', 'condition contains expects a string value'); + return; + } + if (variable && (condition.kind === 'eq' || condition.kind === 'neq') && !variableAccepts(variable, condition.value)) { + issue('WRONG_VALUE_TYPE', `condition on variable ${variable.id} expects a ${variable.type} value`); + } +} + +function checkAction(action: InteractionAction, scope: RuleScope, nodeId?: string): void { + const issue = (code: FlowIssue['code'], message: string) => + scope.issues.push({ code, ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); + if ((action.type === 'navigate' || action.type === 'openScreen') && !scope.routes.has(action.screenId)) { + issue('UNKNOWN_ROUTE', `unknown route ${action.screenId}`); + } + if (action.type === 'toast' && !action.message.trim()) issue('EMPTY_MESSAGE', 'toast message is empty'); + if (action.type === 'dialog' && (!action.title.trim() || !action.message.trim())) { + issue('EMPTY_MESSAGE', 'dialog title and message are required'); + } + if (action.type === 'variable.set') { + const variable = scope.variables.get(action.variableId); + if (!variable) { + issue('UNKNOWN_VARIABLE', `variable.set references undeclared variable ${action.variableId}`); + } else if (!variableAccepts(variable, action.value)) { + issue('WRONG_VALUE_TYPE', `variable ${action.variableId} expects a ${variable.type} value`); + } + } +} + +function isTerminal(node: FlowNodeView, steps: ReadonlyMap): boolean { + if (node.kind === 'result') return true; + if (!node.stepId) return false; + const action = steps.get(node.stepId)?.action; + return Boolean(action && TERMINAL_ACTIONS.has(action.type)); +} + +function validateGraph(scope: RuleScope, graph: FlowGraph): void { + const { rule, issues } = scope; + const nodes = new Map(graph.nodes.map((entry) => [entry.id, entry])); + const known = (id: string) => nodes.has(id); + + const outgoing = new Map(); + for (const edge of graph.edges) { + if (!known(edge.from) || !known(edge.to)) { + issues.push({ + code: 'MISSING_NODE', + ruleId: rule.id, + edgeId: edge.id, + message: `edge ${edge.id} references an unknown node`, + }); + } + if (edge.from === edge.to) { + issues.push({ code: 'SELF_LOOP', ruleId: rule.id, edgeId: edge.id, message: `edge ${edge.id} loops onto its own node` }); + } + outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]); + } + for (const [from, branches] of outgoing) { + const byBranch = new Map(); + for (const edge of branches) byBranch.set(edge.branch, [...(byBranch.get(edge.branch) ?? []), edge]); + for (const [branch, edges] of byBranch) { + if (edges.length > 1) { + issues.push({ + code: 'AMBIGUOUS_BRANCH', + ruleId: rule.id, + nodeId: from, + edgeId: edges[1].id, + message: `node ${from} has ${edges.length} outgoing ${branch} edges`, + }); + } + } + } + + // Cycle detection and reachability walk only over edges between existing nodes. + const adjacency = new Map(); + for (const edge of graph.edges) { + if (!known(edge.from) || !known(edge.to)) continue; + adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), edge.to]); + } + const state = new Map(); + const visit = (id: string): void => { + state.set(id, 'visiting'); + for (const next of adjacency.get(id) ?? []) { + const status = state.get(next); + if (status === 'visiting') { + issues.push({ code: 'CYCLE', ruleId: rule.id, nodeId: next, message: `cycle reaches node ${next}` }); + } else if (status !== 'done') { + visit(next); + } + } + state.set(id, 'done'); + }; + for (const entry of graph.nodes) { + if (!state.has(entry.id)) visit(entry.id); + } + + const triggers = graph.nodes.filter((entry) => entry.kind === 'trigger'); + if (!triggers.length) { + issues.push({ code: 'NO_TRIGGER', ruleId: rule.id, message: `rule ${rule.id} graph has no trigger node` }); + return; + } + if (triggers.length > 1) { + issues.push({ + code: 'MULTIPLE_TRIGGERS', + ruleId: rule.id, + nodeId: triggers[1].id, + message: `rule ${rule.id} graph has ${triggers.length} trigger nodes`, + }); + } + const reachable = new Set(); + const queue = triggers.map((entry) => entry.id); + for (const id of queue) reachable.add(id); + while (queue.length) { + const id = queue.shift()!; + for (const next of adjacency.get(id) ?? []) { + if (reachable.has(next)) continue; + reachable.add(next); + queue.push(next); + } + } + for (const entry of graph.nodes) { + if (!reachable.has(entry.id)) { + issues.push({ + code: 'UNREACHABLE_NODE', + ruleId: rule.id, + nodeId: entry.id, + message: `node ${entry.id} is not reachable from a trigger`, + }); + } + } + const steps = new Map((rule.steps ?? []).map((entry) => [entry.id, entry])); + const hasTerminalPath = [...reachable].some((id) => { + const entry = nodes.get(id); + return Boolean(entry && isTerminal(entry, steps)); + }); + if (!hasTerminalPath) { + issues.push({ + code: 'NO_TERMINAL_PATH', + ruleId: rule.id, + nodeId: triggers[0].id, + message: `trigger ${triggers[0].id} has no path to a result or terminal action`, + }); + } +} + +export function validateFlowGraph(flow: FlowDocument, routeIds: string[]): FlowIssue[] { + const issues: FlowIssue[] = []; + const variables = new Map((flow.variables ?? []).map((entry) => [entry.id, entry])); + const routes = new Set(routeIds); + for (const rule of flow.rules) { + const nodeByStep = new Map( + (rule.graph?.nodes ?? []).flatMap((entry) => (entry.stepId ? [[entry.stepId, entry.id] as const] : [])), + ); + const scope: RuleScope = { rule, variables, routes, issues, nodeByStep }; + if (!rule.graph && !(rule.steps?.length) && !LEGACY_ACTION_TYPES.has(rule.action.type)) { + issues.push({ + code: 'DEAD_RULE', + ruleId: rule.id, + message: `rule ${rule.id} has a ${rule.action.type} action but no steps or graph, so no runtime executes it`, + }); + } + if (rule.condition) checkCondition(rule.condition, scope); + checkAction(rule.action, scope); + for (const step of rule.steps ?? []) { + const nodeId = nodeByStep.get(step.id); + if (step.condition) checkCondition(step.condition, scope, nodeId); + if (step.action) checkAction(step.action, scope, nodeId); + } + if (rule.graph) validateGraph(scope, rule.graph); + } + return issues; +} diff --git a/design/flow-validation.ts b/design/flow-validation.ts index 617684810..5116cfe39 100644 --- a/design/flow-validation.ts +++ b/design/flow-validation.ts @@ -1,8 +1,29 @@ import type { FlowDocument, InteractionRule } from './schema.ts'; +export { validateFlowGraph } from './flow-validation-graph.ts'; + +export type FlowIssueCode = + | 'SHADOWED_RULE' + | 'UNKNOWN_ROUTE' + | 'DUPLICATE_RULE' + | 'AMBIGUOUS_BRANCH' + | 'SELF_LOOP' + | 'MISSING_NODE' + | 'CYCLE' + | 'WRONG_VALUE_TYPE' + | 'UNREACHABLE_NODE' + | 'NO_TERMINAL_PATH' + | 'DEAD_RULE' + | 'EMPTY_MESSAGE' + | 'NO_TRIGGER' + | 'MULTIPLE_TRIGGERS' + | 'UNKNOWN_VARIABLE'; + export interface FlowIssue { - code: 'SHADOWED_RULE' | 'UNKNOWN_ROUTE' | 'DUPLICATE_RULE'; - ruleId: string; + code: FlowIssueCode; + ruleId?: string; + nodeId?: string; + edgeId?: string; message: string; } diff --git a/design/generate.ts b/design/generate.ts index 484fae953..4bb8bebf1 100644 --- a/design/generate.ts +++ b/design/generate.ts @@ -1,9 +1,10 @@ import { createHash } from 'node:crypto'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { DESIGN_TARGETS, type DesignTarget, type LayoutDocument, type ThemeDocument } from './schema.ts'; +import { DESIGN_TARGETS, type DesignTarget, type FlowDocument, type LayoutDocument, type ThemeDocument } from './schema.ts'; import { generateThemeCss } from './theme.ts'; -import { parseThemeDocument } from './validation.ts'; +import { FLOW_MODULE_OUTPUT, generateFlowModule } from './flow-module.ts'; +import { parseFlowDocument, parseThemeDocument } from './validation.ts'; export interface GeneratedFile { path: string; @@ -55,6 +56,22 @@ async function sourceThemes(root: string): Promise { return themes; } +// The production runtime reads a single generated module, so the first flow document +// (sorted by file name) is the generated source, mirroring the single committed design. +async function sourceFlow(root: string): Promise { + const directory = join(root, 'design', 'flows'); + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + const first = names.filter((entry) => entry.endsWith('.json')).sort()[0]; + if (!first) return null; + return parseFlowDocument(JSON.parse(await readFile(join(directory, first), 'utf8'))); +} + async function expectedFiles(root: string): Promise { const themes = await sourceThemes(root); const files: GeneratedContent[] = []; @@ -74,6 +91,15 @@ async function expectedFiles(root: string): Promise { sha256: createHash('sha256').update(layout).digest('hex'), }); } + const flow = await sourceFlow(root); + if (flow) { + const module = generateFlowModule(flow); + files.push({ + path: FLOW_MODULE_OUTPUT, + content: module, + sha256: createHash('sha256').update(module).digest('hex'), + }); + } return files.sort((a, b) => a.path.localeCompare(b.path)); } diff --git a/design/generated/flow.ts b/design/generated/flow.ts new file mode 100644 index 000000000..e182b0d56 --- /dev/null +++ b/design/generated/flow.ts @@ -0,0 +1,91 @@ +import type { FlowDocument } from '../schema.ts'; + +// Generated from design/flows. The export step rewrites this file with the reviewed +// flow document; a document without rules keeps the production runtime a no-op. +export const PRODUCTION_FLOW: FlowDocument = { + "schemaVersion": 1, + "id": "desktop-default", + "baseCommit": "ec4c646aba58e56c096d713992757fa77e732d04", + "rules": [ + { + "id": "nav-library", + "screenId": "*", + "elementId": "nav-library", + "event": "click", + "action": { + "type": "navigate", + "screenId": "library" + } + }, + { + "id": "nav-news", + "screenId": "*", + "elementId": "nav-news", + "event": "click", + "action": { + "type": "navigate", + "screenId": "news" + } + }, + { + "id": "nav-search", + "screenId": "*", + "elementId": "nav-search", + "event": "click", + "action": { + "type": "navigate", + "screenId": "search" + } + }, + { + "id": "nav-remote", + "screenId": "*", + "elementId": "nav-remote", + "event": "click", + "action": { + "type": "navigate", + "screenId": "remote" + } + }, + { + "id": "nav-settings", + "screenId": "*", + "elementId": "nav-settings", + "event": "click", + "action": { + "type": "navigate", + "screenId": "settings" + } + }, + { + "id": "settings-continue-connected", + "screenId": "settings", + "elementId": "settings-continue", + "event": "click", + "action": { + "type": "navigate", + "screenId": "library" + }, + "condition": { + "kind": "eq", + "path": "server.connected", + "value": true + } + }, + { + "id": "settings-continue-disconnected", + "screenId": "settings", + "elementId": "settings-continue", + "event": "click", + "action": { + "type": "navigate", + "screenId": "connect" + }, + "condition": { + "kind": "eq", + "path": "server.connected", + "value": false + } + } + ] +}; diff --git a/design/interaction.ts b/design/interaction.ts index 613ea8a34..27df62ec4 100644 --- a/design/interaction.ts +++ b/design/interaction.ts @@ -1,4 +1,5 @@ -import type { DesignTarget, FlowDocument, InteractionCondition, InteractionRule } from './schema.ts'; +import type { DesignTarget, FlowDocument, InteractionAction, InteractionCondition, InteractionRule } from './schema.ts'; +import type { ExecutionTraceEntry } from './flow-graph.ts'; export interface InteractionContext { target: DesignTarget; @@ -21,24 +22,68 @@ export interface InteractionDispatcher { export interface InteractionTrace { ruleId: string; event: 'click'; - action: 'navigate' | 'back' | 'toast'; + action: InteractionAction['type']; target?: string; message?: string; condition?: InteractionCondition; } +// Shared operator semantics for legacy and graph condition evaluation. +export function compareCondition(kind: string, actual: unknown, value: unknown): boolean { + if (kind === 'eq') return actual === value; + if (kind === 'neq') return actual !== value; + if (kind === 'gt' || kind === 'gte' || kind === 'lt' || kind === 'lte') { + if (typeof actual !== 'number' || typeof value !== 'number') return false; + if (kind === 'gt') return actual > value; + if (kind === 'gte') return actual >= value; + if (kind === 'lt') return actual < value; + return actual <= value; + } + if (kind === 'contains') return typeof actual === 'string' && typeof value === 'string' && actual.includes(value); + return false; +} + +// Previews keep reporting the legacy trace shape while executing through the shared +// interpreter: every executed action or result step becomes one trace entry, conditions +// and step bookkeeping stay out of the public preview trace. The matched legacy rule +// condition is restored by rule id, exactly what the legacy dispatcher used to attach; +// graph rules never contribute one - their step conditions are evaluation bookkeeping. +export function toInteractionTraces(flow: FlowDocument, trace: ReadonlyArray): InteractionTrace[] { + const result: InteractionTrace[] = []; + for (const entry of trace) { + if ((entry.kind !== 'action' && entry.kind !== 'result') || !entry.action) continue; + const item: InteractionTrace = { ruleId: entry.ruleId, event: 'click', action: entry.action }; + if (entry.target !== undefined) item.target = entry.target; + if (entry.message !== undefined) item.message = entry.message; + const rule = flow.rules.find((candidate) => candidate.id === entry.ruleId); + if (rule && !rule.graph && !(rule.steps && rule.steps.length) && rule.condition) { + item.condition = rule.condition; + } + result.push(item); + } + return result; +} + function conditionMatches(condition: InteractionCondition | undefined, context: InteractionContext): boolean { if (!condition) return true; - if (condition.kind === 'eq') { - const actual = condition.path === 'server.connected' ? context.serverConnected : condition.path === 'target' ? context.target : context.screenId; - return actual === condition.value; - } if (condition.kind === 'all') return condition.conditions.every((entry) => conditionMatches(entry, context)); if (condition.kind === 'any') return condition.conditions.some((entry) => conditionMatches(entry, context)); - return !conditionMatches(condition.condition, context); + if (condition.kind === 'not') return !conditionMatches(condition.condition, context); + const actual = condition.path === 'server.connected' + ? context.serverConnected + : condition.path === 'target' + ? context.target + : condition.path === 'screen.current' + ? context.screenId + : undefined; + if (condition.kind === 'exists') return actual !== undefined; + return compareCondition(condition.kind, actual, condition.value); } function ruleMatches(rule: InteractionRule, event: InteractionEvent, context: InteractionContext): boolean { + // Rules with steps or a graph belong to the flow graph runtime and never match in the legacy pass. + if (rule.graph || (rule.steps && rule.steps.length)) return false; + if (rule.action.type !== 'navigate' && rule.action.type !== 'back' && rule.action.type !== 'toast') return false; if (rule.screenId !== '*' && rule.screenId !== event.screenId) return false; if (rule.elementId !== event.elementId || rule.event !== event.type) return false; return conditionMatches(rule.condition, context); @@ -52,16 +97,18 @@ export function dispatchInteraction( ): InteractionTrace[] { const rule = flow.rules.find((entry) => ruleMatches(entry, event, context)); if (!rule) return []; - const trace: InteractionTrace = { ruleId: rule.id, event: 'click', action: rule.action.type }; + const action = rule.action; + if (action.type !== 'navigate' && action.type !== 'back' && action.type !== 'toast') return []; + const trace: InteractionTrace = { ruleId: rule.id, event: 'click', action: action.type }; if (rule.condition) trace.condition = rule.condition; - if (rule.action.type === 'navigate') { - trace.target = rule.action.screenId; - dispatcher.navigate(rule.action.screenId); - } else if (rule.action.type === 'back') { + if (action.type === 'navigate') { + trace.target = action.screenId; + dispatcher.navigate(action.screenId); + } else if (action.type === 'back') { dispatcher.back(); } else { - trace.message = rule.action.message; - dispatcher.toast(rule.action.message); + trace.message = action.message; + dispatcher.toast(action.message); } return [trace]; } diff --git a/design/production-flow.ts b/design/production-flow.ts new file mode 100644 index 000000000..408b544df --- /dev/null +++ b/design/production-flow.ts @@ -0,0 +1,429 @@ +import { PRODUCTION_FLOW } from './generated/flow.ts'; +import { + beginFlowSession, + ensureFlowDocumentVariables, + executeFlow, + executeGraphRule, + resetFlowSession, + updateFlowEnvironment, + type FlowDispatcher, + type FlowDispatchOutcome, +} from './flow-graph.ts'; +import { FLOW_MODULE_OUTPUT, generateFlowModule } from './flow-module.ts'; + +export type { FlowDispatcher, FlowDispatchOutcome } from './flow-graph.ts'; +import type { + DesignTarget, + FlowDocument, + FlowDownloadState, + FlowEventType, + FlowNodeView, + FlowPlaybackState, + InteractionCondition, + InteractionRule, +} from './schema.ts'; + +// Production flow integration: the runtime stays a no-op until an export writes a nonempty +// generated flow document. Events reach the shared interpreter through typed publishers on +// top of the existing app APIs; arbitrary JavaScript and network access from flow data stay +// impossible. One install owns the timer registry: a repeated install (a repeated export) +// tears the previous runtime down, so timers never accumulate. + +export const FLOW_TIMER_REGISTRY_LIMIT = 16; + +// Events that are not tied to a DOM element still carry the rule elementId the document +// declares ('*' rules get a placeholder), mirroring the editor debugger event construction. +const WILDCARD_EVENT_ELEMENT = 'flow-event'; + +export interface ProductionFlowEnvironment { + serverConnected: boolean; + networkOnline: boolean; + accountSignedIn: boolean; + playbackState: FlowPlaybackState; + downloadState: FlowDownloadState; +} + +// Typed event sources: each subscription reports through an existing app API (signals, +// window events) and the runtime maps the new state to the documented flow events. +// Playback and download carry the event explicitly: a finished playback is a stopped +// state but a distinct event. +export interface ProductionFlowPublishers { + subscribeServer?(notify: (connected: boolean) => void): () => void; + subscribeNetwork?(notify: (online: boolean) => void): () => void; + subscribeAccount?(notify: (signedIn: boolean) => void): () => void; + subscribePlayback?(notify: (update: PlaybackFlowUpdate) => void): () => void; + subscribeDownload?(notify: (update: DownloadFlowUpdate) => void): () => void; +} + +export interface PlaybackFlowUpdate { + state: FlowPlaybackState; + event: 'playback.started' | 'playback.paused' | 'playback.stopped' | 'playback.finished' | null; +} + +export interface DownloadFlowUpdate { + state: FlowDownloadState; + event: 'download.started' | 'download.completed' | 'download.failed' | null; +} + +export interface ProductionFlowOptions { + target: DesignTarget; + screenOf(): string | null; + subscribeScreen?(notify: () => void): () => void; + environment?(): Partial; + publishers?: ProductionFlowPublishers; + dispatcher: FlowDispatcher; +} + +const DEFAULT_ENVIRONMENT: ProductionFlowEnvironment = { + serverConnected: false, + networkOnline: true, + accountSignedIn: false, + playbackState: 'idle', + downloadState: 'idle', +}; + +// Fills a dispatcher with explicit unsupported outcomes so an app wires every action it +// exposes instead of silently dropping it; overrides are the typed app API adapters. +export function createProductionDispatcher(partial: Partial): FlowDispatcher { + const unsupported = (name: string) => (): FlowDispatchOutcome => ({ + ok: false, + error: `${name} has no production publisher on this platform`, + }); + return { + navigate: unsupported('navigate'), + back: unsupported('back'), + openScreen: unsupported('openScreen'), + toast: unsupported('toast'), + dialog: unsupported('dialog'), + playbackPlayPause: unsupported('playback.playPause'), + playbackStop: unsupported('playback.stop'), + playbackNextEpisode: unsupported('playback.nextEpisode'), + queueAdd: unsupported('queue.add'), + queueReplace: unsupported('queue.replace'), + serverConnect: unsupported('server.connect'), + serverReconnect: unsupported('server.reconnect'), + serverRetry: unsupported('server.retry'), + sourceRetry: unsupported('source.retry'), + sourceNext: unsupported('source.next'), + linkOpen: unsupported('link.open'), + telegramOpen: unsupported('telegram.open'), + ...partial, + }; +} + +// A documented platform validation error for actions the platform cannot perform: the +// dispatcher stays synchronous and explicit instead of silently succeeding. +export function unavailableProductionAction(action: string, platform: string, reason: string): FlowDispatchOutcome { + return { ok: false, error: `${action} is unavailable on ${platform}: ${reason}` }; +} + +const flowTimers = new Set>(); + +export function clearProductionFlowTimers(): void { + for (const timer of flowTimers) clearInterval(timer); + flowTimers.clear(); +} + +function environmentOf(options: ProductionFlowOptions) { + return { ...DEFAULT_ENVIRONMENT, ...(options.environment?.() ?? {}) }; +} + +// The interpreter keeps legacy precedence (legacy rules first, graph rules only otherwise), +// so production behavior matches the editor preview and debugger for the same document. +// DOM-less events (screen/server/network/account/playback/download) are dispatched per rule: +// the event carries the rule elementId exactly like the editor debugger builds its events. +// Each matching rule executes exactly once - one published event never runs the first +// matching rule twice - and rules without steps or a graph never run for non-click events. +function dispatchForRules(flow: FlowDocument, options: ProductionFlowOptions, type: FlowEventType, screenId: string): void { + for (const rule of flow.rules) { + if (rule.event !== type) continue; + if (rule.screenId !== '*' && rule.screenId !== screenId) continue; + if (!(rule.graph || (rule.steps && rule.steps.length))) continue; + executeGraphRule(rule, { + type, + screenId, + elementId: rule.elementId === '*' ? WILDCARD_EVENT_ELEMENT : rule.elementId, + }, options.dispatcher, flow.variables ?? []); + } +} + +export function activateProductionFlow(root: HTMLElement, flow: FlowDocument, options: ProductionFlowOptions): () => void { + // A repeated activation (a repeated export) starts from a clean timer registry. + clearProductionFlowTimers(); + const target = options.target; + let screen = options.screenOf() ?? '*'; + // Values published through the typed subscriptions accumulate on top of the environment + // reader: one publisher must never clobber the state another one already reported. + let published: Partial = {}; + const sessionEnvironmentNow = () => ({ + target, + screenId: screen, + ...environmentOf(options), + ...published, + }); + beginFlowSession(sessionEnvironmentNow()); + ensureFlowDocumentVariables(flow); + + // Clicks are captured before the app's own handlers: a matched rule takes over the click + // (no double navigation), while an unmatched click falls through untouched, so documents + // without a matching rule change nothing about the native behavior. + const onClick = (event: MouseEvent): void => { + const origin = event.target instanceof Element ? event.target : null; + const element = origin?.closest('[data-design-id]'); + if (!element || !root.contains(element)) return; + const elementId = element.dataset.designId; + if (!elementId) return; + const result = executeFlow(flow, { type: 'click', screenId: screen, elementId }, options.dispatcher); + if (result.matched) { + event.preventDefault(); + event.stopPropagation(); + } + }; + root.addEventListener('click', onClick, true); + + const refreshScreen = (): void => { + const next = options.screenOf() ?? '*'; + if (next === screen) return; + const previous = screen; + screen = next; + updateFlowEnvironment(sessionEnvironmentNow()); + dispatchForRules(flow, options, 'screen.leave', previous); + dispatchForRules(flow, options, 'screen.enter', screen); + }; + const unsubscribeScreen = options.subscribeScreen?.(refreshScreen); + + const watch = ( + subscribe: ((notify: (value: T) => void) => () => void) | undefined, + key: keyof ProductionFlowEnvironment, + events: ReadonlyArray<[FlowEventType, T]>, + ): (() => void) | undefined => { + if (!subscribe) return undefined; + let current = environmentOf(options)[key] as T; + return subscribe((next) => { + if (next === current) return; + current = next; + published = { ...published, [key]: next }; + updateFlowEnvironment(sessionEnvironmentNow()); + for (const [type, value] of events) { + if (value === next) dispatchForRules(flow, options, type, screen); + } + }); + }; + + const watchUpdates = ( + subscribe: ((notify: (update: { state: S; event: FlowEventType | null }) => void) => () => void) | undefined, + key: 'playbackState' | 'downloadState', + ): (() => void) | undefined => { + if (!subscribe) return undefined; + return subscribe((update) => { + published = { ...published, [key]: update.state }; + updateFlowEnvironment(sessionEnvironmentNow()); + if (update.event) dispatchForRules(flow, options, update.event, screen); + }); + }; + + const unsubscribers = [ + watch(options.publishers?.subscribeServer, 'serverConnected', [['server.connected', true], ['server.disconnected', false]]), + watch(options.publishers?.subscribeNetwork, 'networkOnline', [['network.online', true], ['network.offline', false]]), + watch(options.publishers?.subscribeAccount, 'accountSignedIn', [['account.signed-in', true], ['account.signed-out', false]]), + watchUpdates(options.publishers?.subscribePlayback, 'playbackState'), + watchUpdates(options.publishers?.subscribeDownload, 'downloadState'), + ]; + + for (const rule of flow.rules) { + if (rule.event !== 'timer' || flowTimers.size >= FLOW_TIMER_REGISTRY_LIMIT) continue; + // Per-rule dispatch, exactly like dispatchForRules for the published events: the interval + // belongs to its rule, so every tick executes this rule's own graph (never the first + // matching timer rule of the whole document twice) and session variables persist between + // ticks through the shared session. + if (!(rule.graph || (rule.steps && rule.steps.length))) continue; + const elementId = rule.elementId === '*' ? WILDCARD_EVENT_ELEMENT : rule.elementId; + const timer = setInterval(() => { + executeGraphRule(rule, { type: 'timer', screenId: screen, elementId }, options.dispatcher, flow.variables ?? []); + }, rule.intervalMs ?? 0); + flowTimers.add(timer); + } + + return () => { + root.removeEventListener('click', onClick, true); + unsubscribeScreen?.(); + for (const unsubscribe of unsubscribers) unsubscribe?.(); + clearProductionFlowTimers(); + resetFlowSession(); + }; +} + +const installedFlows = new WeakMap void>(); + +export function installProductionFlow(root: HTMLElement, options: ProductionFlowOptions): () => void { + installedFlows.get(root)?.(); + installedFlows.delete(root); + if (!PRODUCTION_FLOW.rules.length) return () => undefined; + const stop = activateProductionFlow(root, PRODUCTION_FLOW, options); + installedFlows.set(root, stop); + return () => { + if (installedFlows.get(root) === stop) installedFlows.delete(root); + stop(); + }; +} + +export type FlowExportScope = + | { kind: 'all' } + | { kind: 'page'; screenId: string } + | { kind: 'component'; nodeId: string }; + +export interface FlowExportPlan { + scope: FlowExportScope; + flow: FlowDocument; + files: Array<{ path: string; content: string }>; +} + +const SCOPE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; + +function normalizeScope(scope: unknown): FlowExportScope { + if (typeof scope !== 'object' || scope === null || Array.isArray(scope)) throw new Error('INVALID_EXPORT_SCOPE'); + const source = scope as Record; + if (source.kind === 'all') return { kind: 'all' }; + if (source.kind === 'page' && typeof source.screenId === 'string' && SCOPE_ID.test(source.screenId)) { + return { kind: 'page', screenId: source.screenId }; + } + if (source.kind === 'component' && typeof source.nodeId === 'string' && SCOPE_ID.test(source.nodeId)) { + return { kind: 'component', nodeId: source.nodeId }; + } + throw new Error('INVALID_EXPORT_SCOPE'); +} + +// A rule belongs to a page scope by its screen, to a component scope by its trigger element. +export function flowRuleInScope(rule: InteractionRule, scope: FlowExportScope): boolean { + if (scope.kind === 'page') return rule.screenId === scope.screenId; + if (scope.kind === 'component') return rule.elementId === scope.nodeId; + return true; +} + +function referencedVariableIds(rules: InteractionRule[]): Set { + const ids = new Set(); + const visit = (condition: InteractionCondition | undefined): void => { + if (!condition) return; + if (condition.kind === 'all' || condition.kind === 'any') { + for (const entry of condition.conditions) visit(entry); + return; + } + if (condition.kind === 'not') { + visit(condition.condition); + return; + } + if (condition.path.startsWith('variable.')) ids.add(condition.path.slice('variable.'.length)); + }; + for (const rule of rules) { + visit(rule.condition); + for (const step of rule.steps ?? []) { + visit(step.condition); + if (step.action?.type === 'variable.set') ids.add(step.action.variableId); + } + } + return ids; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +// The flow contribution of an export. The all scope ships the complete document and the +// generated production module; a scoped export ships only the rules the scope owns, with +// the variables those rules reference, so unrelated screens keep their behavior. The scoped +// plan also names the generated module: the export step rewrites it from the full merged +// document (never from the scoped subset), so the production runtime and the generator +// check never drift behind the merged flow json. Merging with the files on disk happens in +// the export step. +export function createFlowExportPlan(draft: { flow: FlowDocument }, scopeInput: unknown): FlowExportPlan { + const scope = normalizeScope(scopeInput); + const flow = draft.flow; + if (!SCOPE_ID.test(flow.id)) throw new Error('INVALID_FLOW_ID'); + const flowPath = `design/flows/${flow.id}.json`; + if (scope.kind === 'all') { + return { + scope, + flow, + files: [ + { path: flowPath, content: json(flow) }, + { path: FLOW_MODULE_OUTPUT, content: generateFlowModule(flow) }, + ].sort((a, b) => a.path.localeCompare(b.path)), + }; + } + const rules = flow.rules.filter((rule) => flowRuleInScope(rule, scope)); + const variables = (flow.variables ?? []).filter((variable) => referencedVariableIds(rules).has(variable.id)); + const scoped: FlowDocument = { + schemaVersion: 1, + id: flow.id, + baseCommit: flow.baseCommit, + rules, + ...(variables.length ? { variables } : {}), + }; + return { + scope, + flow: scoped, + files: [ + { path: flowPath, content: json(scoped) }, + // Scoped placeholder, same convention as the scoped layout module: the export step + // replaces the content with the module generated from the merged document. + { path: FLOW_MODULE_OUTPUT, content: generateFlowModule(scoped) }, + ].sort((a, b) => a.path.localeCompare(b.path)), + }; +} + +export interface FlowIdDiff { + added: string[]; + removed: string[]; + changed: string[]; +} + +export interface FlowDocumentDiff { + rules: FlowIdDiff; + nodes: FlowIdDiff; + edges: FlowIdDiff; + variables: FlowIdDiff; +} + +function diffIds(before: T[], after: T[], same: (left: T, right: T) => boolean): FlowIdDiff { + const beforeById = new Map(before.map((entry) => [(entry as { id: string }).id, entry])); + const afterById = new Map(after.map((entry) => [(entry as { id: string }).id, entry])); + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + for (const [id, entry] of afterById) { + const previous = beforeById.get(id); + if (!previous) added.push(id); + else if (!same(previous, entry)) changed.push(id); + } + for (const id of beforeById.keys()) { + if (!afterById.has(id)) removed.push(id); + } + return { + added: added.sort(), + removed: removed.sort(), + changed: changed.sort(), + }; +} + +// Export review diff by identity: rules compare without their graph (nodes and edges have +// their own sections) and node moves on the canvas are not logic changes. +export function diffFlowDocuments(before: FlowDocument, after: FlowDocument): FlowDocumentDiff { + const ruleView = (rule: InteractionRule): InteractionRule => { + const { graph: _graph, ...rest } = rule; + return rest; + }; + const nodeView = (node: FlowNodeView) => { + const { x: _x, y: _y, ...rest } = node; + return rest; + }; + const beforeNodes = before.rules.flatMap((rule) => rule.graph?.nodes ?? []); + const afterNodes = after.rules.flatMap((rule) => rule.graph?.nodes ?? []); + const beforeEdges = before.rules.flatMap((rule) => rule.graph?.edges ?? []); + const afterEdges = after.rules.flatMap((rule) => rule.graph?.edges ?? []); + return { + rules: diffIds(before.rules.map(ruleView), after.rules.map(ruleView), (left, right) => JSON.stringify(left) === JSON.stringify(right)), + nodes: diffIds(beforeNodes.map(nodeView), afterNodes.map(nodeView), (left, right) => JSON.stringify(left) === JSON.stringify(right)), + edges: diffIds(beforeEdges, afterEdges, (left, right) => JSON.stringify(left) === JSON.stringify(right)), + variables: diffIds(before.variables ?? [], after.variables ?? [], (left, right) => JSON.stringify(left) === JSON.stringify(right)), + }; +} diff --git a/design/schema.ts b/design/schema.ts index 6f1aff82a..e995db358 100644 --- a/design/schema.ts +++ b/design/schema.ts @@ -120,25 +120,170 @@ export interface ThemeDocument { stylePresets?: StylePreset[]; } -export type ConditionPath = 'server.connected' | 'target' | 'screen.current'; +export type ConditionPath = + | 'server.connected' + | 'target' + | 'screen.current' + | 'network.online' + | 'account.signedIn' + | 'playback.state' + | 'download.state' + | `variable.${string}`; + +export type ConditionValue = string | number | boolean; + export type InteractionCondition = - | { kind: 'eq'; path: ConditionPath; value: string | boolean } + | { kind: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'; path: ConditionPath; value: ConditionValue } + | { kind: 'exists'; path: ConditionPath } | { kind: 'all'; conditions: InteractionCondition[] } | { kind: 'any'; conditions: InteractionCondition[] } | { kind: 'not'; condition: InteractionCondition }; +export const FLOW_EVENT_TYPES = [ + 'click', + 'screen.enter', + 'screen.leave', + 'server.connected', + 'server.disconnected', + 'network.online', + 'network.offline', + 'account.signed-in', + 'account.signed-out', + 'playback.started', + 'playback.paused', + 'playback.stopped', + 'playback.finished', + 'download.started', + 'download.completed', + 'download.failed', + 'timer', +] as const; +export type FlowEventType = (typeof FLOW_EVENT_TYPES)[number]; + +// Timer triggers are representable now, but nothing starts them in the background: +// a bounded interval is validated here, and only an explicit timer event runs the rule. +export const FLOW_TIMER_INTERVAL_MIN = 1000; +export const FLOW_TIMER_INTERVAL_MAX = 3600000; + +export const FLOW_NODE_KINDS = ['trigger', 'condition', 'action', 'result'] as const; +export type FlowNodeKind = (typeof FLOW_NODE_KINDS)[number]; +export const FLOW_EDGE_BRANCHES = ['default', 'true', 'false', 'error'] as const; +export type FlowEdgeBranch = (typeof FLOW_EDGE_BRANCHES)[number]; + +// Graph nodes are views over the typed rule parts: a node references its step by id +// instead of carrying behavior, and positions never affect execution. +export interface FlowNodeView { + id: string; + kind: FlowNodeKind; + ruleId?: string; + stepId?: string; + x: number; + y: number; + label?: string; +} + +export interface FlowEdge { + id: string; + from: string; + to: string; + branch: FlowEdgeBranch; +} + +export interface FlowGraph { + nodes: FlowNodeView[]; + edges: FlowEdge[]; +} + +export const FLOW_VARIABLE_TYPES = ['boolean', 'string', 'number', 'enum'] as const; +export type FlowVariableType = (typeof FLOW_VARIABLE_TYPES)[number]; +export type FlowVariableValue = string | number | boolean; + +export interface FlowVariable { + id: string; + name: string; + type: FlowVariableType; + initial: FlowVariableValue; + values?: string[]; +} + +export type FlowPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'; +export type FlowDownloadState = 'idle' | 'active' | 'completed' | 'failed'; + +// Documented read-only environment values available to conditions during one execution. +export interface FlowEnvironment { + target: DesignTarget; + screenId: string; + serverConnected: boolean; + networkOnline: boolean; + accountSignedIn: boolean; + playbackState: FlowPlaybackState; + downloadState: FlowDownloadState; +} + +// Documented project links only: HTTPS targets the OMP project already publishes. +export const FLOW_LINK_HOSTS = ['t.me'] as const; +export const FLOW_TELEGRAM_TARGETS = ['channel', 'billing-bot'] as const; + export type InteractionAction = | { type: 'navigate'; screenId: string } | { type: 'back' } - | { type: 'toast'; message: string }; + | { type: 'toast'; message: string } + | { type: 'openScreen'; screenId: string } + | { type: 'dialog'; title: string; message: string } + | { type: 'playback.playPause' } + | { type: 'playback.stop' } + | { type: 'playback.nextEpisode' } + | { type: 'queue.add' } + | { type: 'queue.replace' } + | { type: 'server.connect' } + | { type: 'server.reconnect' } + | { type: 'server.retry' } + | { type: 'source.retry' } + | { type: 'source.next' } + | { type: 'variable.set'; variableId: string; value: FlowVariableValue } + | { type: 'link.open'; url: string } + | { type: 'telegram.open'; target: string }; + +// The runtime whitelist mirrors the InteractionAction union one to one: protocol and +// editor boundaries validate posted values against these names instead of trusting strings. +export const INTERACTION_ACTION_TYPES: ReadonlyArray = [ + 'navigate', + 'back', + 'toast', + 'openScreen', + 'dialog', + 'playback.playPause', + 'playback.stop', + 'playback.nextEpisode', + 'queue.add', + 'queue.replace', + 'server.connect', + 'server.reconnect', + 'server.retry', + 'source.retry', + 'source.next', + 'variable.set', + 'link.open', + 'telegram.open', +]; + +export interface InteractionStep { + id: string; + condition?: InteractionCondition; + action?: InteractionAction; +} export interface InteractionRule { id: string; screenId: string; elementId: string; - event: 'click'; + event: FlowEventType; condition?: InteractionCondition; action: InteractionAction; + steps?: InteractionStep[]; + graph?: FlowGraph; + cooldownMs?: number; + intervalMs?: number; } export interface FlowDocument { @@ -146,4 +291,5 @@ export interface FlowDocument { id: string; baseCommit: string; rules: InteractionRule[]; + variables?: FlowVariable[]; } diff --git a/design/validation.ts b/design/validation.ts index cbd19adbd..7d51fdb31 100644 --- a/design/validation.ts +++ b/design/validation.ts @@ -2,6 +2,14 @@ import { DESIGN_FORMATS, DESIGN_STATES, DESIGN_TARGETS, + FLOW_EDGE_BRANCHES, + FLOW_EVENT_TYPES, + FLOW_LINK_HOSTS, + FLOW_NODE_KINDS, + FLOW_TELEGRAM_TARGETS, + FLOW_TIMER_INTERVAL_MAX, + FLOW_TIMER_INTERVAL_MIN, + FLOW_VARIABLE_TYPES, LAYOUT_ALIGNMENTS, LAYOUT_ANCHORS, LAYOUT_DIRECTIONS, @@ -11,10 +19,16 @@ import { type DesignState, type DesignTarget, type ConditionPath, + type ConditionValue, type FlowDocument, + type FlowGraph, + type FlowNodeView, + type FlowVariable, + type FlowVariableValue, type InteractionAction, type InteractionCondition, type InteractionRule, + type InteractionStep, type LayoutContainer, type LayoutClone, type LayoutDocument, @@ -33,7 +47,8 @@ const SIZE = /^(?:0|[1-9]\d{0,2})(?:\.\d{1,2})?px$/; const VERSION = /^\d+\.\d+\.\d+(?:-beta\.\d+)?$/; const COLORS = new Set(['--omp-color-background', '--omp-color-panel', '--omp-color-text', '--omp-color-accent', '--omp-color-muted', '--omp-color-danger']); const SIZES = new Set(['--omp-size-space', '--omp-size-font', '--omp-size-sidebar', '--omp-size-radius']); -const CONDITION_PATHS = new Set(['server.connected', 'target', 'screen.current']); +const CONDITION_PATHS = new Set(['server.connected', 'target', 'screen.current', 'network.online', 'account.signedIn', 'playback.state', 'download.state']); +const FLOW_COOLDOWN_LIMIT = 3600000; const SCOPES = new Set(['target', 'component', 'screen', 'element', 'format', 'state']); export const LAYOUT_GEOMETRY_LIMIT = 10000; export const LAYOUT_ZINDEX_LIMIT = 999; @@ -54,6 +69,15 @@ export function isLayoutZIndexValue(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= LAYOUT_ZINDEX_LIMIT; } +// Element ids keep the same safe id shape the flow document schema enforces, so an inspector +// edit can never smuggle arbitrary JavaScript or URLs into a trigger binding. The wildcard is +// accepted only where the schema accepts it (timer rules). +export function isFlowElementIdValue(value: unknown, options: { allowWildcard?: boolean } = {}): value is string { + if (typeof value !== 'string' || value.length > 64) return false; + if (value === '*') return Boolean(options.allowWildcard); + return ID.test(value); +} + export function isLayoutMarginValue(value: unknown): value is string { return typeof value === 'string' && SIZE.test(value) && Number.parseFloat(value) <= LAYOUT_SIZE_PX_LIMIT; } @@ -328,59 +352,251 @@ export function parseThemeDocument(value: unknown): ThemeDocument { return result; } -function condition(value: unknown, path: string, depth = 0): InteractionCondition { +function isScalar(value: unknown): value is ConditionValue { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +function conditionPath(value: unknown, path: string, variables: ReadonlySet | undefined): ConditionPath { + const result = text(value, path, 80); + if (CONDITION_PATHS.has(result)) return result as ConditionPath; + if (result.startsWith('variable.')) { + const variableId = result.slice('variable.'.length); + if (!ID.test(variableId)) fail(path, 'invalid variable condition path'); + if (variables && !variables.has(variableId)) fail(path, 'unknown variable'); + return result as ConditionPath; + } + fail(path, 'unknown condition path'); +} + +function condition(value: unknown, path: string, variables?: ReadonlySet, depth = 0): InteractionCondition { if (depth > 8) fail(path, 'condition nesting is limited to 8'); const source = plain(value, path, ['kind', 'path', 'value', 'conditions', 'condition']); const kind = text(source.kind, `${path}.kind`, 10); - if (kind === 'eq') { - const conditionPath = text(source.path, `${path}.path`, 40); - if (!CONDITION_PATHS.has(conditionPath)) fail(`${path}.path`, 'unknown condition path'); - if (typeof source.value !== 'string' && typeof source.value !== 'boolean') fail(`${path}.value`, 'string or boolean required'); - return { kind, path: conditionPath as ConditionPath, value: source.value }; + if (kind === 'eq' || kind === 'neq') { + if (!isScalar(source.value)) fail(`${path}.value`, 'string, number or boolean required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'gt' || kind === 'gte' || kind === 'lt' || kind === 'lte') { + if (typeof source.value !== 'number') fail(`${path}.value`, 'number required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'contains') { + if (typeof source.value !== 'string') fail(`${path}.value`, 'string required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'exists') { + if (source.value !== undefined) fail(`${path}.value`, 'value is not allowed'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables) }; } if (kind === 'all' || kind === 'any') { if (!Array.isArray(source.conditions) || !source.conditions.length) fail(`${path}.conditions`, 'non-empty array required'); - return { kind, conditions: source.conditions.map((entry, index) => condition(entry, `${path}.conditions[${index}]`, depth + 1)) }; + return { kind, conditions: source.conditions.map((entry, index) => condition(entry, `${path}.conditions[${index}]`, variables, depth + 1)) }; } - if (kind === 'not') return { kind, condition: condition(source.condition, `${path}.condition`, depth + 1) }; + if (kind === 'not') return { kind, condition: condition(source.condition, `${path}.condition`, variables, depth + 1) }; return fail(`${path}.kind`, 'unknown condition kind'); } -function action(value: unknown, path: string): InteractionAction { - const source = plain(value, path, ['type', 'screenId', 'message']); +function variableValueMatches(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +function action(value: unknown, path: string, variables?: ReadonlyMap): InteractionAction { + const source = plain(value, path, ['type', 'screenId', 'message', 'title', 'variableId', 'value', 'url', 'target']); const type = text(source.type, `${path}.type`, 20); if (type === 'navigate') return { type, screenId: id(source.screenId, `${path}.screenId`) }; if (type === 'back') return { type }; if (type === 'toast') return { type, message: text(source.message, `${path}.message`, 200) }; + if (type === 'openScreen') return { type, screenId: id(source.screenId, `${path}.screenId`) }; + if (type === 'dialog') { + return { type, title: text(source.title, `${path}.title`), message: text(source.message, `${path}.message`, 500) }; + } + if (type === 'playback.playPause' || type === 'playback.stop' || type === 'playback.nextEpisode') return { type }; + if (type === 'queue.add' || type === 'queue.replace') return { type }; + if (type === 'server.connect' || type === 'server.reconnect' || type === 'server.retry') return { type }; + if (type === 'source.retry' || type === 'source.next') return { type }; + if (type === 'variable.set') { + const variableId = id(source.variableId, `${path}.variableId`); + if (!isScalar(source.value)) fail(`${path}.value`, 'string, number or boolean required'); + const variable = variables?.get(variableId); + if (variables && !variable) fail(`${path}.variableId`, 'unknown variable'); + if (variable && !variableValueMatches(variable, source.value)) fail(`${path}.value`, `value does not match variable type ${variable.type}`); + return { type, variableId, value: source.value }; + } + if (type === 'link.open') { + const url = text(source.url, `${path}.url`, 500); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + fail(`${path}.url`, 'valid URL required'); + } + if (parsed.protocol !== 'https:') fail(`${path}.url`, 'https URL required'); + if (parsed.username || parsed.password) fail(`${path}.url`, 'credentials are not allowed'); + if (!(FLOW_LINK_HOSTS as readonly string[]).includes(parsed.host)) fail(`${path}.url`, 'link host is not allowed'); + return { type, url }; + } + if (type === 'telegram.open') return { type, target: oneOf(source.target, [...FLOW_TELEGRAM_TARGETS], `${path}.target`) }; return fail(`${path}.type`, 'unknown action type'); } -function rule(value: unknown, path: string): InteractionRule { - const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action']); +function flowVariable(value: unknown, path: string): FlowVariable { + const source = plain(value, path, ['id', 'name', 'type', 'initial', 'values']); + const type = oneOf(source.type, [...FLOW_VARIABLE_TYPES], `${path}.type`); + if (!isScalar(source.initial)) fail(`${path}.initial`, 'string, number or boolean required'); + const result: FlowVariable = { + id: id(source.id, `${path}.id`), + name: text(source.name, `${path}.name`), + type, + initial: source.initial, + }; + if (type === 'boolean' && typeof source.initial !== 'boolean') fail(`${path}.initial`, 'boolean required'); + if (type === 'number' && typeof source.initial !== 'number') fail(`${path}.initial`, 'number required'); + if (type === 'string' && typeof source.initial !== 'string') fail(`${path}.initial`, 'string required'); + if (type === 'enum') { + if (!Array.isArray(source.values) || !source.values.length) fail(`${path}.values`, 'non-empty array required'); + const values = source.values.map((entry, index) => text(entry, `${path}.values[${index}]`, 64)); + if (new Set(values).size !== values.length) fail(`${path}.values`, 'duplicate value'); + if (typeof source.initial !== 'string' || !values.includes(source.initial)) fail(`${path}.initial`, 'initial value must be one of values'); + result.values = values; + } else if (source.values !== undefined) { + fail(`${path}.values`, 'values are only allowed for enum variables'); + } + return result; +} + +function step(value: unknown, path: string, variables: ReadonlyMap): InteractionStep { + const source = plain(value, path, ['id', 'condition', 'action']); + const result: InteractionStep = { id: id(source.id, `${path}.id`) }; + if (source.condition === undefined && source.action === undefined) fail(path, 'condition or action required'); + if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`, new Set(variables.keys())); + if (source.action !== undefined) result.action = action(source.action, `${path}.action`, variables); + return result; +} + +function flowGraph(value: unknown, path: string, ruleId: string, steps: ReadonlyMap): FlowGraph { + const source = plain(value, path, ['nodes', 'edges']); + if (!Array.isArray(source.nodes)) fail(`${path}.nodes`, 'array required'); + if (!Array.isArray(source.edges)) fail(`${path}.edges`, 'array required'); + const nodes: FlowNodeView[] = []; + for (const [index, entry] of source.nodes.entries()) { + const nodePath = `${path}.nodes[${index}]`; + const nodeSource = plain(entry, nodePath, ['id', 'kind', 'ruleId', 'stepId', 'x', 'y', 'label']); + const node: FlowNodeView = { + id: id(nodeSource.id, `${nodePath}.id`), + kind: oneOf(nodeSource.kind, [...FLOW_NODE_KINDS], `${nodePath}.kind`), + x: layoutGeometry(nodeSource.x, `${nodePath}.x`), + y: layoutGeometry(nodeSource.y, `${nodePath}.y`), + }; + if (nodeSource.ruleId !== undefined) { + const nodeRuleId = id(nodeSource.ruleId, `${nodePath}.ruleId`); + if (nodeRuleId !== ruleId) fail(`${nodePath}.ruleId`, 'node belongs to a different rule'); + node.ruleId = nodeRuleId; + } + if (nodeSource.label !== undefined) node.label = layoutLabel(nodeSource.label, `${nodePath}.label`); + const stepId = nodeSource.stepId === undefined ? undefined : id(nodeSource.stepId, `${nodePath}.stepId`); + if (stepId !== undefined) { + const linked = steps.get(stepId); + if (!linked) fail(`${nodePath}.stepId`, 'unknown step'); + if (node.kind === 'condition' && !linked.condition) fail(`${nodePath}.stepId`, 'condition node requires a step with a condition'); + if ((node.kind === 'action' || node.kind === 'result') && !linked.action) fail(`${nodePath}.stepId`, `${node.kind} node requires a step with an action`); + node.stepId = stepId; + } + nodes.push(node); + } + if (new Set(nodes.map((entry) => entry.id)).size !== nodes.length) fail(`${path}.nodes`, 'duplicate node id'); + const nodeIds = new Set(nodes.map((entry) => entry.id)); + const edges: FlowGraph['edges'] = []; + for (const [index, entry] of source.edges.entries()) { + const edgePath = `${path}.edges[${index}]`; + const edgeSource = plain(entry, edgePath, ['id', 'from', 'to', 'branch']); + const from = id(edgeSource.from, `${edgePath}.from`); + const to = id(edgeSource.to, `${edgePath}.to`); + if (!nodeIds.has(from) || !nodeIds.has(to)) fail(edgePath, 'edge references an unknown node'); + if (from === to) fail(edgePath, 'self-loop edge'); + edges.push({ + id: id(edgeSource.id, `${edgePath}.id`), + from, + to, + branch: oneOf(edgeSource.branch, [...FLOW_EDGE_BRANCHES], `${edgePath}.branch`), + }); + } + if (new Set(edges.map((entry) => entry.id)).size !== edges.length) fail(`${path}.edges`, 'duplicate edge id'); + return { nodes, edges }; +} + +function rule(value: unknown, path: string, variables: ReadonlyMap): InteractionRule { + const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action', 'steps', 'graph', 'cooldownMs', 'intervalMs']); const screenId = text(source.screenId, `${path}.screenId`, 64); if (screenId !== '*' && !ID.test(screenId)) fail(`${path}.screenId`, 'invalid screen id or wildcard'); - if (source.event !== 'click') fail(`${path}.event`, 'only click is supported'); + const event = oneOf(source.event, [...FLOW_EVENT_TYPES], `${path}.event`); + const elementId = text(source.elementId, `${path}.elementId`, 64); + if (event === 'timer') { + if (elementId !== '*' && !ID.test(elementId)) fail(`${path}.elementId`, 'invalid element id or wildcard'); + } else { + id(source.elementId, `${path}.elementId`); + } const result: InteractionRule = { id: id(source.id, `${path}.id`), screenId, - elementId: id(source.elementId, `${path}.elementId`), - event: 'click', - action: action(source.action, `${path}.action`), + elementId, + event, + action: action(source.action, `${path}.action`, variables), }; - if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`); + const variableIds = new Set(variables.keys()); + if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`, variableIds); + if (source.steps !== undefined) { + if (!Array.isArray(source.steps)) fail(`${path}.steps`, 'array required'); + const steps = source.steps.map((entry, index) => step(entry, `${path}.steps[${index}]`, variables)); + if (new Set(steps.map((entry) => entry.id)).size !== steps.length) fail(`${path}.steps`, 'duplicate step id'); + result.steps = steps; + } + if (source.graph !== undefined) { + const steps = new Map((result.steps ?? []).map((entry) => [entry.id, entry])); + result.graph = flowGraph(source.graph, `${path}.graph`, result.id, steps); + } + if (source.cooldownMs !== undefined) { + if (typeof source.cooldownMs !== 'number' || !Number.isInteger(source.cooldownMs) || source.cooldownMs < 0 || source.cooldownMs > FLOW_COOLDOWN_LIMIT) { + fail(`${path}.cooldownMs`, `integer from 0 to ${FLOW_COOLDOWN_LIMIT} required`); + } + result.cooldownMs = source.cooldownMs; + } + if (event === 'timer') { + if (source.intervalMs === undefined) fail(`${path}.intervalMs`, 'timer rules require intervalMs'); + if (typeof source.intervalMs !== 'number' || !Number.isInteger(source.intervalMs) + || source.intervalMs < FLOW_TIMER_INTERVAL_MIN || source.intervalMs > FLOW_TIMER_INTERVAL_MAX) { + fail(`${path}.intervalMs`, `integer from ${FLOW_TIMER_INTERVAL_MIN} to ${FLOW_TIMER_INTERVAL_MAX} required`); + } + result.intervalMs = source.intervalMs; + } else if (source.intervalMs !== undefined) { + fail(`${path}.intervalMs`, 'intervalMs is only allowed for timer rules'); + } return result; } export function parseFlowDocument(value: unknown): FlowDocument { - const source = plain(value, 'flow', ['schemaVersion', 'id', 'baseCommit', 'rules']); + const source = plain(value, 'flow', ['schemaVersion', 'id', 'baseCommit', 'rules', 'variables']); if (source.schemaVersion !== 1) fail('flow.schemaVersion', 'only schema version 1 is supported'); if (!Array.isArray(source.rules)) fail('flow.rules', 'array required'); - const rules = source.rules.map((entry, index) => rule(entry, `flow.rules[${index}]`)); + const variables = new Map(); + if (source.variables !== undefined) { + if (!Array.isArray(source.variables)) fail('flow.variables', 'array required'); + for (const [index, entry] of source.variables.entries()) { + const variable = flowVariable(entry, `flow.variables[${index}]`); + if (variables.has(variable.id)) fail('flow.variables', 'duplicate variable id'); + variables.set(variable.id, variable); + } + } + const rules = source.rules.map((entry, index) => rule(entry, `flow.rules[${index}]`, variables)); if (new Set(rules.map((entry) => entry.id)).size !== rules.length) fail('flow.rules', 'duplicate rule id'); - return { + const result: FlowDocument = { schemaVersion: 1, id: id(source.id, 'flow.id'), baseCommit: commit(source.baseCommit, 'flow.baseCommit'), rules, }; + if (source.variables !== undefined) result.variables = [...variables.values()]; + return result; } diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 5ed40a907..0da203543 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -4,6 +4,9 @@ import { createBridge } from './platform/bridge'; import { flushCrashes, queueCrash } from '../../src/report/crash'; import { setDesktopUpdatePlatform } from '../../src/lib/updateInfo'; import { desktopTargetForPlatform, installProductionDesign } from '../../design/production.ts'; +import { installProductionFlow } from '../../design/production-flow.ts'; +import { createDesktopFlowDispatcher } from './productionFlowDispatcher'; +import { createPhoneFlowPublishers } from '../../mobile/src/productionFlowPublishers'; async function boot(): Promise { let platform = ''; @@ -14,12 +17,24 @@ async function boot(): Promise { // The browser preview has no Tauri command. The feed then uses the user agent. } await import('../../mobile/src/main'); + const desktopTarget = desktopTargetForPlatform(platform); const stopProductionLayout = installProductionDesign(document.body, { - target: desktopTargetForPlatform(platform), + target: desktopTarget, screenOf: () => currentRoute.value.name, subscribeScreen: (notify) => routeStack.subscribe(notify), }); window.addEventListener('pagehide', stopProductionLayout, { once: true }); + // The desktop shell renders the phone app, so the same typed app APIs drive the exported + // flows; the empty generated document keeps this a no-op until an explicit export. + const stopProductionFlow = installProductionFlow(document.body, { + target: desktopTarget, + screenOf: () => currentRoute.value.name, + subscribeScreen: (notify) => routeStack.subscribe(notify), + environment: () => ({ networkOnline: navigator.onLine }), + publishers: createPhoneFlowPublishers(), + dispatcher: createDesktopFlowDispatcher(), + }); + window.addEventListener('pagehide', stopProductionFlow, { once: true }); try { const text = await createBridge().invoke('take_native_crashes'); if (text) queueCrash({ kind: 'native', message: text.slice(0, 400), stack: text, platform: 'desktop' }); diff --git a/desktop/src/preview/DesktopPreview.tsx b/desktop/src/preview/DesktopPreview.tsx index 80098aa1c..f640cff53 100644 --- a/desktop/src/preview/DesktopPreview.tsx +++ b/desktop/src/preview/DesktopPreview.tsx @@ -1,7 +1,9 @@ import { h } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import type { DesignTarget, DesignFormat, DesignState, FlowDocument, ThemeDocument } from '../../../design/schema.ts'; -import { dispatchInteraction, type InteractionTrace } from '../../../design/interaction.ts'; +import { beginFlowSession, executeFlow, resetFlowSession, type FlowDispatcher } from '../../../design/flow-graph.ts'; +import { createProductionDispatcher } from '../../../design/production-flow.ts'; +import { toInteractionTraces, type InteractionTrace } from '../../../design/interaction.ts'; import { generateThemeCss } from '../../../design/theme.ts'; import { DesktopShell } from '../shell/DesktopShell'; import { isDesktopRouteId, type DesktopRouteId } from '../shell/routes'; @@ -51,23 +53,46 @@ export function DesktopPreview(props: DesktopPreviewProps) { props.onSelect(elementId); return; } - const trace = dispatchInteraction( - props.flow, - { screenId: props.route, elementId, type: 'click' }, - { target: props.target, screenId: props.route, serverConnected: props.serverConnected }, - { - navigate: (screenId) => { - if (isDesktopRouteId(screenId)) navigate(screenId); - }, - back: () => { - if (history.current.length > 1) { - history.current.pop(); - props.onNavigate(history.current[history.current.length - 1]); - } - }, - toast: setToast, + // The preview runs the shared interpreter, exactly like the debugger and production: + // legacy rules keep their precedence and graph rules walk their nodes. One click is one + // session (begin, execute, reset), so variables never leak between preview clicks, and + // the dispatcher is a mock typed adapter: navigation, back and toast drive the preview + // state while every unsupported action reports a synchronous outcome instead of calling + // a real app API. + const dispatcher: FlowDispatcher = createProductionDispatcher({ + navigate: (screenId) => { + if (!isDesktopRouteId(screenId)) return { ok: false, error: `UNKNOWN_ROUTE: ${screenId}` }; + navigate(screenId); + return { ok: true }; }, - ); + back: () => { + if (history.current.length > 1) { + history.current.pop(); + props.onNavigate(history.current[history.current.length - 1]); + } + return { ok: true }; + }, + toast: (message) => { + setToast(message); + return { ok: true }; + }, + }); + beginFlowSession({ + target: props.target, + screenId: props.route, + serverConnected: props.serverConnected, + networkOnline: true, + accountSignedIn: false, + playbackState: 'idle', + downloadState: 'idle', + }); + let trace: InteractionTrace[]; + try { + const result = executeFlow(props.flow, { type: 'click', screenId: props.route, elementId }, dispatcher); + trace = toInteractionTraces(props.flow, result.trace); + } finally { + resetFlowSession(); + } props.onTrace(trace); }; diff --git a/desktop/src/productionFlowDispatcher.ts b/desktop/src/productionFlowDispatcher.ts new file mode 100644 index 000000000..152c2e75c --- /dev/null +++ b/desktop/src/productionFlowDispatcher.ts @@ -0,0 +1,11 @@ +import { createPhoneFlowDispatcher } from '../../mobile/src/productionFlowDispatcher'; +import type { FlowDispatcher } from '../../design/production-flow'; + +// The desktop shell renders the phone app, so it reuses the phone dispatcher with the +// desktop URL opener; playback commands ride the same player link and report a documented +// error when no playback is active. +export function createDesktopFlowDispatcher(): FlowDispatcher { + return createPhoneFlowDispatcher({ + openUrl: (url) => window.open(url, '_blank', 'noopener'), + }); +} diff --git a/docs/editor-guide.md b/docs/editor-guide.md index 1c3c70ac7..e1ff5fdf0 100644 --- a/docs/editor-guide.md +++ b/docs/editor-guide.md @@ -2,9 +2,9 @@ ## 1. Что это -Локальный визуальный редактор тем и макета OMP. Он показывает реальные экраны приложения и позволяет менять оформление через токены и расположение элементов через перетаскивание, а не через редактирование CSS вручную. +Локальный визуальный редактор тем, макета и логики OMP. Он показывает реальные экраны приложения и позволяет менять оформление через токены и расположение элементов через перетаскивание, а не через редактирование CSS вручную. Режим «Логика» добавляет визуальное создание потоков: события, условия, действия и переменные связаны на canvas, проверяются пошаговым debugger и попадают в production только через явный экспорт. -Редактор работает только на вашем компьютере. Он не изменяет production-сервер, телефон, телевизор или плеер. Production-приложение применяет макет только из проверенного экспорта (см. раздел 17). +Редактор работает только на вашем компьютере. Он не изменяет production-сервер, телефон, телевизор или плеер. Production-приложение применяет макет и логику только из проверенного экспорта (см. раздел 22). ## 2. Требования @@ -38,14 +38,27 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Редактор доступен только по loopback-адресу `127.0.0.1`. Для остановки нажмите `Ctrl+C` в терминале. -## 4. Основная рабочая область +## 4. Быстрый старт -- Верхняя панель содержит режимы, сравнение, undo/redo и историю снимков. +1. Запустите редактор: `npm run editor` и откройте адрес с токеном (см. раздел 3). +2. В верхней строке выберите платформу `Windows` и страницу `settings` (Настройки). Режимы «Логика» и «Симуляция» доступны только для desktop-платформ и только на этой странице. +3. Нажмите «Логика» в верхней панели. Холст показывает поток выбранной страницы. +4. Добавьте узлы кнопками «+ Триггер», «+ Условие», «+ Действие», «+ Результат». Соединяйте выходы слева узла со входом следующего: выберите ветку `true` или `false` у условия, `default` или `error` у действия. +5. Настройте выбранный узел в инспекторе справа: путь и оператор условия, тип действия, экран, сообщение. +6. Откройте «Шаблоны» на холсте и нажмите «Вставить» у готового потока, если хотите начать с проверенного примера. +7. Нажмите «Симуляция» на холсте: debugger выполнит поток на моках, покажет trace и переменные. +8. Экспортируйте через раздел «Релиз и экспорт» справа: «Показать diff», затем «Экспортировать». + +Подробности по каждому шагу - в разделах ниже. Ограничения и границы поведения собраны в разделе 23. + +## 5. Основная рабочая область + +- Верхняя панель содержит режимы «Дизайн», «Логика» и «Симуляция», сравнение, undo/redo и историю снимков. - Слева находятся страницы, слои макета и шаблоны. -- В центре находится артборд с реальным preview. +- В центре находится артборд с реальным preview или canvas потока в режиме «Логика». - Справа находятся инспектор свойств, история снимков и экспорт. -Доступные действия на холсте: +Доступные действия на холсте дизайна: - `V` - выбор элемента. - `H` - перемещение холста рукой. @@ -57,7 +70,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Стрелки, `Home` и `End` перемещают фокус по toolbar. Горячие клавиши работают и при фокусе внутри preview, но не перехватывают ввод в текстовых полях. -## 5. Выбор платформы, страницы и формата +## 6. Выбор платформы, страницы и формата Верхняя строка позволяет выбрать: @@ -71,9 +84,9 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Для TV всегда используется логический размер 1920x1080: на телевизоре с 4K интерфейс масштабируется из этого же логического макета. Для desktop доступны 1280x720 и 720x960. Для phone portrait используется 720x960, landscape - 960x720. -При смене платформы или страницы выбор элемента и состояние сбрасываются, чтобы не применить чужой шаблон к новому экрану. +При смене платформы или страницы выбор элемента и состояние сбрасываются, чтобы не применить чужой шаблон к новому экрану. Phone и TV в редакторе - это preview и цели production-потоков: node canvas для них не открывается, редактирование потоков ведётся на desktop-цели. -## 6. Редактирование элемента +## 7. Редактирование элемента 1. Выберите страницу слева. 2. Выберите шаблон или нажмите на элемент в preview. @@ -93,7 +106,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Панель показывает только те шаблоны, которые существуют на текущей платформе и странице. -## 7. Область переопределения +## 8. Область переопределения Селектор `Область` определяет, к какому уровню применяется изменение: @@ -105,7 +118,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Цепочка наследования показана в инспекторе. Кнопка `Сбросить override` удаляет только текущий уровень и не удаляет базовую тему. -## 8. Состояния +## 9. Состояния Селектор `Состояние` меняет состояние preview: @@ -118,7 +131,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Для сохранения отдельного оформления состояния выберите `state` в `Область`, затем нужное состояние и измените свойства. При переключении состояния preview применяются только overrides этого состояния. -## 9. Макет: flow и absolute +## 10. Макет: flow и absolute Элементы макета используют два способа размещения: @@ -131,7 +144,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Те же значения доступны числами в инспекторе макета справа: режим, порядок, выравнивание, отступы, размер и z-index. -## 10. Множественный выбор +## 11. Множественный выбор Несколько элементов выбираются так: @@ -146,7 +159,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Для flow-элементов одного контейнера групповое выравнивание записывается в контейнер, для absolute-элементов - в сами элементы. -## 11. Слои +## 12. Слои Панель `Слои` слева показывает дерево макета текущей страницы: @@ -159,7 +172,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Скрытые и заблокированные узлы пропускаются групповыми операциями и экспортом не теряют своё состояние. -## 12. Стили и пресеты +## 13. Стили и пресеты Раздел `Библиотека стилей` работает с оформлением выбранного элемента: @@ -172,16 +185,16 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Сброс рядом с каждым свойством удаляет только override этого свойства на текущем уровне. -## 13. Сравнение +## 14. Сравнение Верхняя панель предлагает два способа сравнения: - Кнопка `До/После` показывает исходную тему вместо текущей и обратно. В режиме `До` preview не редактируется. - Кнопка `Платформы` открывает рабочую область, где платформы идут рядом на одном артборде, а текущая страница подсвечивается. Повторный нажатие скрывает её. -Пока открыт снимок сравнения или режим `До`, инспекторы, слои и буфер слоёв работают в режиме чтения, чтобы правка не попала не в тот документ. +Пока открыт снимок сравнения или режим `До`, инспекторы, слои и буфер слоёв работают в режиме чтения, чтобы правка не попала не в тот документ. То же относится к canvas потока: при сравнении с базовой темой правка узлов выключена, кнопки добавления и удаления отключены, а копирование остаётся доступным. -## 14. Доступность +## 15. Доступность Панель `Доступность` в разделе экспорта показывает проверки текущего макета: @@ -191,7 +204,7 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Проблемы выводятся списком с уровнями `error` и `warning`. Экспорт с неподтверждёнными предупреждениями геометрии заблокирован: без отметки `Экспортировать с предупреждениями геометрии` кнопка экспорта остаётся неактивной. Ошибки доступности устраняйте до экспорта. -## 15. Снимки и история +## 16. Снимки и история Кнопка с иконкой снимка в верхней панели открывает историю. @@ -210,18 +223,90 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... Одновременно хранится не более 10 снимков. Пустое имя и превышение лимита показывают ошибку без удаления предыдущих снимков. -## 16. Логика и симуляция +## 17. Режим «Логика»: холст потока + +Режимы «Логика» и «Симуляция» доступны только при выбранной desktop-платформе (Windows, macOS или Linux) и только на странице `settings`. На других платформах и страницах кнопки режимов отключены, а режим автоматически возвращается к «Дизайн». Phone и TV показывают preview и production-потоки, node canvas для них не предусмотрен. + +В режиме «Логика» центральная область показывает canvas потока текущей страницы. Поток хранится в виде типизированного документа: узлы четырёх видов (`trigger`, `condition`, `action`, `result`), связи с ветками и шаги с условиями и действиями. Произвольный JavaScript, сетевые запросы и секреты в потоки вставить нельзя. + +Панель инструментов холста: + +- `+ Триггер`, `+ Условие`, `+ Действие`, `+ Результат` - добавляют узел в центр видимой области, каждый следующий узел со сдвигом, чтобы новые узлы не перекрывались. Клавиша `Insert` добавляет условие. +- `-` и `+` - масштаб от 0.4 до 2. +- `Авторазметка` - выравнивает узлы по колонкам. +- `Шаблоны` - открывает библиотеку готовых потоков (см. раздел 19). +- `Симуляция` - переходит к симуляции и debugger. +- При нескольких graph-правилах страницы - селектор `Правило потока`, переключающий редактируемый граф. + +Навигация по canvas: перетаскивание пустого места двигает вид, правый нижний угол показывает minimap с точками узлов, фон размечен сеткой. + +Добавление первого узла создаёт для страницы graph-правило с событием `screen.enter`. В одном графе может быть только один триггер; повторное добавление триггера покажет ошибку. У страницы может быть несколько graph-правил: тогда в панели инструментов появляется селектор `Правило потока`, переключающий, какой граф показывает и редактирует canvas (шаблон возврата позиции, например, создаёт два правила - на уход и на вход). Событие триггера показано в инспекторе и не редактируется, а его элемент переназначается полем инспектора (см. раздел 18). + +Связи строятся от выхода к входу: зажмите порт справа узла и отпустите над входом слева следующего узла. Ветки зависят от вида узла: + +- Триггер имеет ветку `default`. +- Условие имеет ветки `true` и `false`. +- Действие имеет ветки `default` и `error`. +- Результат - терминальный узел без выходов, он собирает итог выполнения. + +Панель не позволит создать петлю на сам узел, дубликат связи или цикл: такая попытка завершится ошибкой с кодом, а документ не изменится. Если подходящей ветки нет, выполнение просто завершается. + +Перетаскивание узла мышью двигает его, вся группа при мульти-выборе двигается вместе. Позиции узлов влияют только на вид canvas, на выполнение они не влияют. Один drag, одно добавление, одна вставка или одно удаление - один шаг undo. + +Клик по узлу открывает его инспектор справа и подсвечивает связанный экран или элемент в design-режиме. Кнопка `Показать в дизайне` в инспекторе переводит в режим «Дизайн» с выбранной целью узла: экран элемента для триггера, экран назначения для действий навигации. + +Пока не выбран ни один узел, инспектор показывает `Карту переходов` - список всех правил документа с экранами, условиями и действиями. В той же колонке остаётся простой редактор правил кнопки `settings-continue`: событие `click`, условие по подключению сервера и действия `navigate`, `back`, `toast`. Он нужен для совместимости со старыми документами и редактирует только одну кнопку; остальные потоки собираются на canvas. + +## 18. Условия, действия и переменные -Для desktop-настроек доступны дополнительные режимы: +Инспектор узла (`Узел: Триггер`, `Узел: Условие`, `Узел: Действие`, `Узел: Результат`) показывает только поля, относящиеся к выбранному узлу: -- `Логика` - правила переходов и действий. -- `Симуляция` - проверка правил на тестовых данных. +- `Метка` - имя узла на canvas. +- Триггер - уведомление с событием и поле `Элемент` с id элемента правила. Значение проверяется до сохранения тем же правилом безопасных id, что и схема документа: только строчные буквы, цифры и дефис, пустое или произвольное значение не сохранится (`INVALID_ELEMENT_ID`). `*` допустим только у правил с событием `timer`. +- Условие - `Путь`, `Оператор` и `Значение`. Пути: `server.connected`, `target`, `screen.current`, `network.online`, `account.signedIn`, `playback.state`, `download.state` и `variable.` для каждой переменной документа. Операторы: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `exists`. Значение проверяется до сохранения: для числовых операторов нужно число, для boolean-путей - только `true` или `false`; иначе поле покажет ошибку (`NUMBER_REQUIRED` и другие коды). +- Действие - `Действие` из списка типов: `navigate`, `back`, `openScreen`, `toast`, `dialog`, `playback.playPause`, `playback.stop`, `playback.nextEpisode`, `queue.add`, `queue.replace`, `server.connect`, `server.reconnect`, `server.retry`, `source.retry`, `source.next`, а также `variable.set`, когда в документе есть переменные. Для навигации выбирается `Экран` из маршрутов desktop, для `toast` и `dialog` - `Сообщение`, для `dialog` ещё `Заголовок`. Пустой текст `toast` не сохраняется (`TOAST_REQUIRED`). +- `variable.set` - выбор `Переменная` и `Значение` её типа: boolean переключателем, number числом, enum выбором из разрешённых значений, string текстом. -Правило состоит из события, условия и действия. Доступны условия по серверу, платформе и текущему экрану, действия `navigate`, `back` и `toast`. +Переменные документа типизированы (`boolean`, `string`, `number`, `enum`) и объявляются вместе с потоком: сегодня в документ они попадают из шаблонов (кнопка `Шаблоны` добавляет недостающие переменные автоматически). Переменные участвуют в условиях через путь `variable.` и меняются действием `variable.set`. Значения переменных живут только в контексте выполнения текущей сессии приложения и не сохраняются между запусками: позицию воспроизведения и историю страниц хранят существующие механизмы приложения, а не переменные потока. -В design-режиме клик по элементу только выбирает его и не запускает реальное действие. +Узел `result` без собственного шага - терминальный сборщик итога: итоговый route, переменные, `toast` и trace. Явный результат не обязателен: ветка может завершаться terminal action, такие действия помечены в схеме документа. -## 17. Экспорт и production +## 19. Шаблоны потоков + +Кнопка `Шаблоны` на canvas открывает библиотеку из пяти проверенных потоков. У каждого карточка с описанием и кнопкой `Вставить`: + +- `Подключение сервера` - кнопка продолжения: при подключённом сервере сразу открывает каталог, иначе подключает сервер. +- `Повтор при пропадании сети` - при событии `network.offline` показывает ошибку и повторяет подключение к серверу. +- `Очередь серий` - выбор серии добавляет её в очередь и запускает следующую серию. +- `Запасной источник` - пробует другой источник воспроизведения, при неудаче показывает ошибку. +- `Возврат позиции: пример variables` - конфигурируемый пример переменных и графа: на уходе со экрана сохраняет вручную заданные route, позицию и элемент в переменные, на входе возвращает к ним. Значения подставляются при вставке и сами не обновляются - живой restore в production требует отдельных typed adapters, поток сам по себе live-позицию не собирает. + +Вставка добавляет правила, узлы, связи и переменные шаблона в конец документа со свежими уникальными id, поэтому один шаблон можно вставлять несколько раз. Исходный шаблон от ваших правок не меняется. Вставленные правила привязываются к странице, открытой на canvas, и сразу показываются на нём; если шаблон создал больше одного graph-правила (возврат позиции создаёт два), переключайтесь между ними селектором `Правило потока` в панели холста. + +## 20. Симуляция и debugger + +Кнопка `Симуляция` на canvas или в инспекторе переключает редактор в режим «Симуляция». В нём доступны два инструмента. + +Простая симуляция - кнопка `Нажать «Продолжить»` выполняет клик по кнопке продолжения с учётом галочки `TorrServer подключён` и показывает итоговый экран, `toast` и trace сработавших правил. + +Debugger - панель под симуляцией для graph-правил текущего экрана: + +- Селектор `Правило` выбирает отлаживаемый поток. +- Блок `Моки` задаёт входные состояния без реальных вызовов: `Сервер подключён`, `Сеть онлайн`, `Аккаунт вошёл`, плеер (`idle`, `playing`, `paused`, `stopped`) и загрузка (`idle`, `active`, `completed`, `failed`). Моки не меняют настройки приложения. +- Кнопки: `Старт` выполняет поток до точки остановки или конца, `Пауза` останавливает выполнение, `Шаг over` выполняет до следующего действия, `Шаг into` - до следующего узла, `Стоп` прерывает выполнение, `Сброс` очищает trace и состояние, `Повтор и сравнение` запускает поток заново и сравнивает новый trace с предыдущим. +- `Точки остановки` - галочки на правило и на каждый узел. +- Блоки `Переменные` и `Trace` показывают текущие значения и журнал шагов: условие с результатом, ветку, действие и ошибку. +- Различие двух trace подсвечивается строкой `Первое различие: шаг N` с обеими записями. + +Смена страницы или формата сбрасывает сессию debugger, чтобы состояние не переходило между разными экранами. + +## 21. Проверки логики + +Документ проверяется до сохранения и перед экспортом. Проверки ловят: связи в несуществующий узел, self-loop, циклы, дубли веток, недостижимые узлы, отсутствие триггера или пути к терминальному действию, несколько триггеров в одном графе, несовпадение типов значений условий и `variable.set`, неизвестные переменные, пустые сообщения `toast` и `dialog`, навигацию по неизвестным маршрутам, дубли id правил и мёртвые правила. Проблемы выводятся с кодами (`CYCLE`, `UNREACHABLE_NODE`, `WRONG_VALUE_TYPE` и другие) в панели логики и в ответе экспорта. Ошибки логики блокируют экспорт: он завершится ошибкой `INVALID_FLOW` со списком проблем, а кнопка `Экспортировать` дополнительно остаётся неактивной, пока экспорт блокирован ошибками доступности, неподтверждённой геометрией или превью не черновой темы. + +Правила без graph продолжают работать по старой схеме в исходном порядке: если сработало старое правило, graph-правила этого события не выполняются. + +## 22. Экспорт и production Раздел `Релиз и экспорт` позволяет указать версию и канал `beta` или `stable`. @@ -239,13 +324,24 @@ OMP Design Editor: http://127.0.0.1:PORT/#token=... - `Страница` - только текущая страница и платформы, где она есть. - `Компонент` - один узел по node id и затронутые им платформы. -Кнопка `Показать diff` (только при запущенном сервере) показывает список файлов с действиями `create`, `update`, `unchanged` и файлы вне области, которые не изменятся. Сам diff только читает и ничего не пишет. +Кнопка `Показать diff` (только при запущенном сервере) показывает список файлов с действиями `create`, `update`, `unchanged` и файлы вне области, которые не изменятся. Для логики diff дополнительно раскрывает изменённые части документа: правила, узлы, связи и переменные с добавленными, удалёнными и изменёнными элементами. Сам diff только читает и ничего не пишет. + +Экспорт создаёт проверенные файлы темы, правил и CSS, делает бэкап изменяемых и удаляемых файлов и восстанавливает его при сбое. Он не создаёт production-релиз и не выполняет деплой. Для macOS и Linux пакет помечается как недоступный. Экспорт отклоняется с ошибкой и списком проблем, если не подтверждены предупреждения геометрии, есть ошибки доступности, превью показывает не черновую тему или проверки логики нашли ошибки. + +Production-приложение применяет макет из сгенерированного модуля `design/generated/layout.ts`, а логику потоков - из `design/generated/flow.ts`. Эти модули обновляются только явным экспортом: автосохранение черновика и простое открытие редактора production не меняют. Откат выполняется восстановлением файлов из бэкапа экспорта или возвратом коммита в `design/`. -Экспорт создаёт проверенные файлы темы, правил и CSS, делает бэкап изменяемых и удаляемых файлов и восстанавливает его при сбое. Он не создаёт production-релиз и не выполняет деплой. Для macOS и Linux пакет помечается как недоступный. +## 23. Ограничения -Production-приложение применяет макет из сгенерированного модуля `design/generated/layout.ts`. Этот модуль обновляется только явным экспортом: автосохранение черновика и простое открытие редактора production не меняют. Откат выполняется восстановлением файлов из бэкапа экспорта или возвратом коммита в `design/`. +- Production меняется только после явного экспорта. Отладка, моки, симуляция и автосохранение черновика не влияют на production-приложения. +- Переменные потоков живут в контексте выполнения текущей сессии и сбрасываются вместе с ним. Между запусками приложения они не сохраняются. +- Редактирование потоков - только desktop-цель и страница `settings`. Phone и TV в редакторе - это preview и цели production-потоков, а не поверхности node editing. +- На phone действие `queue.add` возвращает документированную ошибку: встроенный плеер принимает очередь целиком при запуске, а player-link протокол не имеет команды append. Используйте `queue.replace` с экрана торрента. +- На TV события `playback.paused` и `playback.finished` зависят от свежести snapshot зарегистрированного плеера: он обновляется по существующему периоду phone-link и на сменах состояния плеера. Без зарегистрированного плеера события паузы и завершения не приходят. +- Событие `download.failed` не отличает удаление торрента пользователем от потери загрузки: торрент, исчезнувший до завершения, всегда даёт `download.failed`. Правила на него должны учитывать текущий экран. +- Потоки выполняются синхронно и детерминированно: произвольный JavaScript, произвольные HTTP-запросы, асинхронные цепочки и параллельное выполнение не поддерживаются. Ошибки в асинхронных приложенческих API error-ветками не обрабатываются - они остаются в существующих обработчиках приложения. +- Один event - один trace: не более 32 шагов на событие, повторный запуск триггера ограничен cooldown, фоновые timer-потоки стартуют не более 16 и только после экспорта. -## 18. Безопасность +## 24. Безопасность - Preview использует тестовые данные и не выполняет реальные сетевые запросы. - Внешние fetch и XHR получают заглушку 503, same-origin запросы продолжают работать. @@ -254,7 +350,7 @@ Production-приложение применяет макет из сгенер - Не изменяйте `baseCommit` вручную. - Не публикуйте локальный токен редактора. -## 19. Если что-то не работает +## 25. Если что-то не работает - Проверьте, что сервер запущен через `npm run editor`. - Откройте адрес с токеном целиком. @@ -264,4 +360,8 @@ Production-приложение применяет макет из сгенер - Если элемент не выбирается, проверьте, не заблокирован ли слой. - Если правка недоступна, проверьте, не открыт ли режим `До` или сравнение снимка. - Если значение не сохраняется, проверьте сообщение об ошибке и нажмите `Повторить`. +- Если недоступны «Логика» и «Симуляция», проверьте, что выбрана desktop-платформа и страница `settings`. +- Если связь не создаётся, читайте код ошибки на canvas: петля, цикл, дубликат или неподходящая ветка не сохраняются. +- Если узел не удаляется через `Delete`, проверьте, не открыт ли режим сравнения с базовой темой. +- Если экспорт неактивен, проверьте ошибки доступности, подтверждение геометрии, что превью показывает черновую тему и список проблем логики. - Если страница показывает `package unavailable`, это ограничение пакета, а не ошибка темы. diff --git a/docs/screenshots/visual-flow-editor.png b/docs/screenshots/visual-flow-editor.png new file mode 100644 index 000000000..09ae6181d Binary files /dev/null and b/docs/screenshots/visual-flow-editor.png differ diff --git a/docs/superpowers/plans/2026-11-12-visual-flow-editor.md b/docs/superpowers/plans/2026-11-12-visual-flow-editor.md new file mode 100644 index 000000000..55d6f981b --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-visual-flow-editor.md @@ -0,0 +1,350 @@ +# Visual Flow Editor 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:** Реализовать визуальный node editor логики с типизированными событиями, условиями, действиями, debugger и общим preview/production runtime. + +**Architecture:** `FlowDocument.schemaVersion` остаётся 1. Legacy rules выполняются первыми через существующий `dispatchInteraction`, новые graph-правила запускаются только при отсутствии legacy match. Все вычисления выполняются синхронно через typed dispatcher, без eval и произвольного JavaScript. + +**Tech Stack:** TypeScript, Preact 11, Vitest, jsdom, существующие editor/design/runtime APIs. + +**Spec:** `docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md` + +## Global Constraints + +- Нет произвольного JavaScript, eval, HTML, HTTP или секретов в variables. +- Сохранить `schemaVersion: 1`, legacy `dispatchInteraction`, snapshots, drafts, rebase и export manifest. +- Production активирует flow только после явного export. Autosave production не меняет. +- Максимум 32 steps на event и 16 активных timer flows. Графы без циклов и self-loops. +- Один event создаёт один execution context и один trace. Variables живут только в текущей сессии. +- Node editor работает только на desktop. Phone и TV используются как preview/production flows. +- Один drag/edit/paste равен одному undo-шагу. Новые тексты используют только дефис. +- Не запускать deploy, release, NUC или устройства. +- Полный `npm test` локально на Windows не запускать, полная проверка выполняется в CI на Linux. + +## Review Focus + +- Legacy и graph правила в одном документе: legacy имеет приоритет и не меняет поведение. Проверяется Task 1 тестом `legacy rules keep precedence over graph rules`. +- Конфликт двух edges в одну ветку: экспорт должен отклонить неоднозначное выполнение. Проверяется Task 2 тестом `rejects ambiguous branch destination`. +- Read-only before/snapshot и clipboard: нельзя изменить скрытый draft, реальные actions не выполняются. Проверяется Task 3 и Task 4. +- Debugger после смены route/orientation: состояние и breakpoints не остаются от предыдущего документа. Проверяется Task 5. +- Повторный export и timers: registry сбрасывается, production не активируется до export. Проверяется Task 7. + +--- + +### Task 1: Graph Schema и совместимый interpreter + +**Files:** +- Modify: `design/schema.ts` +- Modify: `design/validation.ts` +- Modify: `design/defaults.ts` +- Create: `design/flow-graph.ts` +- Modify: `design/interaction.ts` +- Test: `tests/design/flow-graph.test.ts` +- Test: `tests/design/schema.test.ts` + +**Interfaces:** +- Produces: `FlowNodeView`, `FlowEdge`, `FlowVariable`, `InteractionStep`, `ExecutionContext`, `ExecutionResult`. +- Produces: `executeGraphRule(rule, event, dispatcher): ExecutionResult` и `executeFlow(flow, event, dispatcher): ExecutionResult`. + +- [ ] **Step 1: Написать failing tests** + +`legacy rules keep precedence over graph rules`, true/false/error ветки, variables по типам, max 32 steps, один trace, session reset, terminal action без обязательного result, отсутствие eval и network. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-graph.test.ts tests/design/schema.test.ts` +Expected: FAIL, graph types и executeFlow отсутствуют. + +- [ ] **Step 3: Реализовать schema и interpreter** + +Legacy вызывается первым существующим `dispatchInteraction`. Graph запускается только без legacy match. Dispatcher возвращает `{ok:boolean,error?:string}` и остаётся синхронным. Не добавлять async API в interpreter. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design` +Expected: все design tests PASS, старые документы без graph сохраняют форму. + +- [ ] **Step 5: Commit** + +```bash +git add design tests/design +git commit -m "feat: add typed flow graph interpreter" +``` + +### Task 2: Graph validation и rebase + +**Files:** +- Create: `design/flow-validation-graph.ts` +- Modify: `design/flow-validation.ts` +- Modify: `editor/design.mjs` +- Test: `tests/design/flow-validation-graph.test.ts` +- Test: `tests/editor/design.test.ts` + +**Interfaces:** +- Produces: `validateFlowGraph(flow, routeIds): FlowIssue[]`. +- Produces: `FlowIssue {code,nodeId?,edgeId?,message}` и `REBASE_CONFLICT` по node/edge/variable id. + +- [ ] **Step 1: Написать failing tests** + +`rejects ambiguous branch destination`, self-loop, cycle, missing node, wrong value type, unreachable result, independent additions by id и competing edits одного node/edge/variable. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-validation-graph.test.ts tests/editor/design.test.ts -t "ambiguous|cycle|rebase|flow"` +Expected: FAIL, graph validation отсутствует. + +- [ ] **Step 3: Реализовать validation и merge** + +Проверять edges, types, reachability, routes и messages. Rebase сливать независимые additions по id и отклонять competing changes одного узла, связи или переменной. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design/flow-validation-graph.test.ts tests/editor/design.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add design editor/design.mjs tests +git commit -m "feat: validate and rebase flow graphs" +``` + +### Task 3: Node Canvas и инспекторы + +**Files:** +- Create: `editor/src/flow/nodeOperations.ts` +- Create: `editor/src/components/FlowCanvas.tsx` +- Create: `editor/src/components/FlowNodeInspector.tsx` +- Modify: `editor/src/App.tsx` +- Modify: `editor/src/editor.css` +- Test: `tests/editor/flow-canvas.test.tsx` + +**Interfaces:** +- Produces: `addFlowNode`, `moveFlowNode`, `connectFlowNodes`, `removeFlowNodes`, `autoLayoutFlow`. +- `FlowCanvasProps {controller,screenId,readOnly,onSelectNode,onOpenSimulation}`. + +- [ ] **Step 1: Написать failing tests** + +Добавление узла, drag позиции, связь output-input, запрещённая связь, inspector полей, один undo и read-only non-draft режим. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-canvas.test.tsx` +Expected: FAIL, canvas отсутствует. + +- [ ] **Step 3: Реализовать node editor** + +Canvas с zoom/pan/grid/minimap, toolbar и `Insert`, drag из output в input, typed inspector, связь с Design mode. Не добавлять новую модель данных помимо Task 1. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-canvas.test.tsx tests/editor/app.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: edit logic flows on node canvas" +``` + +### Task 4: Multi-select, clipboard и auto-layout + +**Files:** +- Create: `editor/src/flow/selection.ts` +- Create: `editor/src/flow/clipboard.ts` +- Modify: `editor/src/components/FlowCanvas.tsx` +- Modify: `editor/src/components/FlowNodeInspector.tsx` +- Test: `tests/editor/flow-selection.test.ts` +- Test: `tests/editor/flow-clipboard.test.ts` + +**Interfaces:** +- Produces: `reduceFlowSelection`, `copyFlowSelection`, `pasteFlowSelection`, `autoLayoutFlow`. +- `FlowClipboard {schemaVersion:1,nodes,edges,variables}` с новыми ids и ограничениями. + +- [ ] **Step 1: Написать failing tests** + +Shift add/remove, групповое перемещение, copy/paste с новыми ids, отсутствие dangling edges, invalid clipboard, один undo и read-only guard. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-selection.test.ts tests/editor/flow-clipboard.test.ts` +Expected: FAIL, selection/clipboard отсутствуют. + +- [ ] **Step 3: Реализовать selection и clipboard** + +Групповые операции одной транзакцией, строгая валидация clipboard, новые ids, auto-layout без перекрытий. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-selection.test.ts tests/editor/flow-clipboard.test.ts tests/editor/flow-canvas.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: manage flow nodes and clipboard" +``` + +### Task 5: Debugger и trace comparison + +**Files:** +- Create: `editor/src/flow/debugger.ts` +- Create: `editor/src/components/FlowDebugger.tsx` +- Modify: `editor/src/components/SimulationPanel.tsx` +- Test: `tests/editor/flow-debugger.test.ts` +- Test: `tests/editor/flow-debugger-panel.test.tsx` + +**Interfaces:** +- Produces: `createDebugSession`, `startDebug`, `pauseDebug`, `stepOver`, `stepInto`, `stopDebug`, `resetDebug`, `compareTraces`. +- `DebugSession {status,event,context,breakpoints,currentNodeId,trace}`. + +- [ ] **Step 1: Написать failing tests** + +Start/pause/step/stop/reset, breakpoints, variables, error path, trace first difference, смена route/orientation и отсутствие реальных actions. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-debugger.test.ts tests/editor/flow-debugger-panel.test.tsx` +Expected: FAIL, debugger отсутствует. + +- [ ] **Step 3: Реализовать debugger** + +Использовать typed interpreter Task 1, mocks server/network/account/playback/download, не менять реальные настройки. Сбрасывать session при смене route/orientation. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-debugger.test.ts tests/editor/flow-debugger-panel.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: debug flow execution step by step" +``` + +### Task 6: Библиотека шаблонов + +**Files:** +- Create: `design/flow-templates.ts` +- Create: `editor/src/components/FlowTemplateLibrary.tsx` +- Modify: `editor/src/components/FlowCanvas.tsx` +- Test: `tests/design/flow-templates.test.ts` +- Test: `tests/editor/flow-template-library.test.tsx` + +**Interfaces:** +- Produces: `FLOW_TEMPLATES`, `instantiateFlowTemplate(id, context): FlowDocument`. +- Template ids: `connect-server`, `offline-retry`, `episode-queue`, `playback-fallback`, `restore-position`. + +- [ ] **Step 1: Написать failing tests** + +Все пять шаблонов создают валидные documents, новые ids, отсутствие изменения исходного шаблона, route/variables корректны. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-templates.test.ts tests/editor/flow-template-library.test.tsx` +Expected: FAIL, templates отсутствуют. + +- [ ] **Step 3: Реализовать templates** + +Использовать только существующие типы и actions, применять контекст без секретов, генерировать новые node/rule/edge ids. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design/flow-templates.test.ts tests/editor/flow-template-library.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add design editor tests +git commit -m "feat: add reusable logic flow templates" +``` + +### Task 7: Scoped export и production runtime + +**Files:** +- Create: `design/production-flow.ts` +- Create: `design/generated/flow.ts` +- Modify: `src/main.tsx` +- Modify: `mobile/src/main.tsx` +- Modify: `desktop/src/main.tsx` +- Modify: `editor/export.mjs` +- Modify: `editor/export.d.mts` +- Modify: `editor/src/client.ts` +- Modify: `editor/server.mjs` +- Modify: `editor/src/components/ExportPanel.tsx` +- Test: `tests/design/production-flow.test.ts` +- Test: `tests/editor/export-flow.test.ts` + +**Interfaces:** +- Produces: `installProductionFlow(root): () => void` и `createFlowExportPlan(draft,scope): ExportPlan`. +- Production использует generated `PRODUCTION_FLOW` и typed dispatcher. + +- [ ] **Step 1: Написать failing tests** + +Production не активируется до export, legacy/graph parity, publishers, timer reset при повторном export, scoped diff, manifest ownership и rollback. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/production-flow.test.ts tests/editor/export-flow.test.ts` +Expected: FAIL, production flow/export отсутствуют. + +- [ ] **Step 3: Реализовать export и production** + +Подключить publishers через существующие API, timer registry только после export, scoped flow diff с node/edge/variable ids, сохранить manifest/process lock/safePath. + +- [ ] **Step 4: Запустить GREEN и builds** + +Run: `npx vitest run tests/design/production-flow.test.ts tests/editor/export-flow.test.ts && npm run build && npm run build:mobile && npm run build:desktop && npm run build:editor && npm run editor:generate -- --check` +Expected: все команды exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add design editor src mobile desktop tests +git commit -m "feat: export reviewed flows into production" +``` + +### Task 8: Документация и acceptance + +**Files:** +- Modify: `docs/editor-guide.md` +- Modify: `CHANGELOG.md` +- Modify: `CHANGELOG.en.md` +- Modify: `AGENTS.md` только если карта проекта изменилась + +**Interfaces:** +- Consumes: все предыдущие public UI и export interfaces. + +- [ ] **Step 1: Обновить инструкцию** + +Описать node canvas, conditions, actions, variables, clipboard, debugger, templates, scoped export и production activation. + +- [ ] **Step 2: Запустить полный regression** + +Run: `npm run test:editor && npx tsc --noEmit && npm run build:editor && npm run editor:generate -- --check` +Expected: все команды exit 0 без unhandled errors. + +- [ ] **Step 3: Запустить production builds и formatting** + +Run: `npm run build && npm run build:mobile && npm run build:desktop && git diff --check` +Expected: все builds exit 0, diff check чистый. + +- [ ] **Step 4: Выполнить browser acceptance** + +Проверить desktop node editing, связи, clipboard, debugger, templates, read-only и export diff. Phone/TV проверять как preview/production flows. Не выполнять deploy и не менять реальные настройки. + +- [ ] **Step 5: Финальное ревью и PR** + +Проверить весь diff, исправить Critical/Important, создать один PR в `main` и дождаться `build`, `android`, `billing`, desktop smoke и Greptile. Не вливать без явного разрешения Andy. + +- [ ] **Step 6: Commit** + +```bash +git add docs CHANGELOG.md CHANGELOG.en.md AGENTS.md +git commit -m "docs: explain visual logic editing and export" +``` diff --git a/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md b/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md new file mode 100644 index 000000000..db3f9e5cb --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md @@ -0,0 +1,207 @@ +# Visual Flow Editor Design + +Дата: 2026-11-12 +Статус: ожидает подтверждения Andy +Ветка: `feat/visual-flow-editor` + +## 1. Назначение + +Реализовать визуальный редактор логики OMP Design Editor: ноды и связи на canvas, типизированные условия и действия, пошаговый debugger, проверки и безопасный экспорт в production. + +Подход: одна типизированная `FlowDocument` для редактора, симулятора и production. Произвольный JavaScript запрещён. + +## 2. Текущее состояние + +`InteractionRule` поддерживает только `click`, `server.connected`, `target`, `screen.current`, а действия ограничены `navigate`, `back`, `toast`. `dispatchInteraction` выбирает первое подходящее правило. `LogicEditor` редактирует правила списком, `SimulationPanel` запускает один click. + +Старые документы и поведение обязаны остаться рабочими без миграции пользовательских данных. + +## 3. Цели + +- Визуальное создание и связывание событий, условий, действий и результатов. +- Расширенные события, условия, переменные и документированные действия. +- Пошаговая симуляция с breakpoints, состоянием и traces. +- Проверка циклов, связей, типов и покрытия результатов. +- Общий runtime для preview и production после явного export. +- Совместимость со старыми `FlowDocument`, snapshots, drafts и rebase. + +## 4. Не входит + +- Произвольный JavaScript, eval, HTML или загружаемый пользовательский код. +- Произвольные HTTP-запросы и секреты в variables. +- Асинхронные цепочки и параллельное выполнение в первой версии. +- Автоматический deploy, release или запуск flows на устройствах Andy. +- Замена реальных приложений независимым движком скриптов. + +## 5. Модель данных + +### 5.1 Совместимость + +`FlowDocument.schemaVersion` остаётся 1. Добавляются optional поля `graph`, `variables` и `steps` в существующие правила. Старый `rules` остаётся источником поведения для старых flows. + +Старый `InteractionRule` считается одной цепочкой `trigger -> action`. Новый rule может содержать упорядоченные `steps` и ссылаться на condition graph. Graph metadata хранит представление, а не произвольную логику. + +### 5.2 GraphView + +- `FlowNodeView`: `id`, `kind`, `ruleId?`, `stepId?`, `x`, `y`, `label?`. +- `FlowEdge`: `id`, `from`, `to`, `branch: 'default' | 'true' | 'false' | 'error'`. +- `kind`: `trigger`, `condition`, `action`, `result`. +- Каждый узел ссылается на типизированную часть правила, не на DOM id. +- Удаление узла удаляет связанные edges и предупреждает о потерянных ветвях. +- Позиции узлов не влияют на выполнение. + +### 5.3 Variables + +- `FlowVariable`: `id`, `name`, `type: 'boolean' | 'string' | 'number' | 'enum'`, `initial`, `values?` для enum. +- Значения проходят ту же строгую валидацию, что документ. +- Секреты, токены, пароли и произвольные URL запрещены. +- Контекст содержит переменные пользователя и документированные read-only значения среды. + +### 5.4 Steps + +`InteractionStep` содержит `id`, `condition?` и `action`. Правило имеет trigger, condition и steps. Runtime выполняет steps по порядку. `result` содержит итоговый route, variables, toast и trace. + +## 6. События + +- `click` по elementId. +- `screen.enter` и `screen.leave`. +- `server.connected` и `server.disconnected`. +- `network.online` и `network.offline`. +- `account.signed-in` и `account.signed-out`. +- `playback.started`, `playback.paused`, `playback.stopped`, `playback.finished`. +- `download.started`, `download.completed`, `download.failed`. +- `timer` с ограниченным интервалом и отключённым фоновым запуском по умолчанию. + +События имеют payload только из разрешённых полей. Пользовательские события не создают сетевых вызовов. + +## 7. Условия + +- Группы `all`, `any`, `not`. +- Операторы `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `exists`. +- Пути `server.connected`, `target`, `screen.current`, `network.online`, `account.signedIn`, `playback.state`, `download.state`, `variable.`. +- Типы значений проверяются до сохранения: number только числа, boolean только true/false, enum только разрешённые значения. +- Cooldown и once per event ограничивают повторные запуски без изменения смысла условия. + +## 8. Действия + +- `navigate`, `back`, `openScreen`. +- `toast`, `dialog`. +- `playback.playPause`, `playback.stop`, `playback.nextEpisode`. +- `queue.add`, `queue.replace`. +- `server.connect`, `server.reconnect`, `server.retry`. +- `source.retry`, `source.next`. +- `variable.set` с типизированным значением. +- `link.open` только из allowlist текущего проекта и HTTPS. +- `telegram.open` только документированный канал/бот проекта. + +Действия используют существующие приложенческие API. Никаких динамических имен функций или eval. + +## 9. Runtime и execution policy + +- Синхронный детерминированный interpreter без фоновых promises. +- Один event создаёт один execution context и один trace. +- Не более 32 steps на event и не более 16 одновременно зарегистрированных timer flows. +- Циклические edges запрещены. Повторный trigger подчиняется cooldown. +- Ошибка action останавливает текущую ветку и переходит в `error`, если она существует, иначе завершает сценарий. +- `navigate` и другие side effects выполняются через typed dispatcher, а не напрямую из graph. +- Production активирует generated flow только после явного export. Autosave и editor preview production не меняют. + +## 10. Визуальный редактор + +- Canvas с zoom, pan, minimap и grid. +- Добавление узла через toolbar и `Insert`. +- Drag для перемещения узла и drag из output в input для создания связи. +- Multi-select, удаление, copy/paste, auto-layout и fit. +- Панель свойств только для выбранного узла с документированными полями. +- Цвета веток: default, true, false, error. +- Клик по узлу подсвечивает связанный экран/элемент в Design mode. +- Read-only режим для before/snapshot сравнения, как в остальном редакторе. +- Один drag/edit/paste равен одному undo-шагу. + +## 11. Debugger + +- Start, pause, continue, step over, step into, stop и reset. +- Breakpoints на узле и на ruleId. +- Состояние route, variables, server/network/account/playback/download и текущего узла. +- Trace показывает условие, ветку, action и ошибку, а не только итоговое действие. +- Моки server/network/account/playback/download без реальных вызовов и изменения настроек. +- Повтор и сравнение двух traces с подсветкой первого различия. +- Проверка timeout и error-ветки. + +## 12. Проверки + +- Нет edges в несуществующий узел или self-loop. +- Нет циклов и недостижимых действий. +- Нет нескольких неупорядоченных trigger paths к одному результату. +- Типы condition values и variable.set совпадают. +- Навигация только по разрешённым routes. +- Обязательные toast/dialog/message и destination не пустые. +- Каждый trigger имеет путь к result или явному terminal action. +- Экспорт содержит отчёт покрытия: достигнутые/недостижимые узлы и ветки. + +## 13. Шаблоны + +- Подключение сервера -> продолжить -> каталог. +- Нет сети -> ошибка -> повторить. +- Выбор серии -> очередь -> следующая серия. +- Ошибка воспроизведения -> другой источник. +- Возврат с сохранением route, позиции и выбранного элемента. + +Шаблоны создают новые уникальные node/rule/edge ids. Пользовательские изменения не меняют исходный шаблон. + +## 14. Export и rebase + +- Diff показывает добавленные/удалённые узлы, edges и изменённые поля. +- Rebase сливает rules, graph nodes, edges и variables по id. +- Competing changes одного узла, переменной или связи дают понятный `REBASE_CONFLICT` с путём. +- Независимые добавления и правки разных узлов сливаются без конфликта. +- Манифест, baseCommit, safePath, process lock и rollback сохраняются по существующей модели. + +## 15. Проверки поставки + +- Unit: schema, conditions, graph operations, runtime limits, actions, rebase и validation. +- Component: node editing, linking, clipboard, undo, read-only и template insertion. +- Debugger: breakpoints, step into/over, mocks, trace diff и error paths. +- Integration: старый flow не меняет поведение, preview/production совпадают, export защищён. +- Browser: desktop, phone, TV, zoom/pan, keyboard-only и read-only. +- Команды: `npm run test:editor`, `npx tsc --noEmit`, `npm run build:editor`, `npm run build`, `npm run build:mobile`, `npm run build:desktop`, `npm run editor:generate -- --check`, `git diff --check`. +- CI: `build`, `android`, `billing`, desktop smoke и Greptile обязательны. + +## 16. Этапы + +1. Совместимая graph schema, interpreter и проверки. +2. Node canvas, связи, clipboard и инспекторы. +3. Debugger, trace diff и шаблоны. +4. Export, production runtime, документация и acceptance. + +Один PR после выполнения всех этапов и зелёных проверок. Без автоматического deploy или release. + +## 17. Решения после независимой builder check + +### Execution graph + +- Каждое правило имеет один trigger и собственный направленный граф. Связи между разными правилами запрещены. +- `true` и `false` выбирают следующий узел по соответствующей ветке. Если подходящей ветки нет, выполнение завершается без действия. +- `error` выбирается только при ошибке валидации или синхронной ошибке dispatcher. +- Terminal action завершает ветку. Узел `result` не обязателен. Если он присутствует, это единственный терминальный узел ветки, который собирает итоговый route, variables, toast и trace. + +### Совместимость legacy + +- Старые правила выполняются первыми в исходном порядке с прежним `dispatchInteraction` и старой error-семантикой. +- Новые graph-правила выполняются только если ни одно legacy-правило не совпало. +- Cooldown, лимиты steps, graph validation и error-ветки к legacy-правилам не применяются. +- Смешанный документ не меняет порядок старых правил и не меняет их поведение при совпадении. + +### Синхронный dispatcher + +- Dispatcher возвращает `{ ok: boolean, error?: string }` для валидации и синхронных ошибок. +- Async приложенческие API вызываются через существующие adapters, interpreter их не ожидает. +- `dialog` и `telegram.open` являются fire-and-forget и не приостанавливают выполнение. +- Error-ветки не обещают обработку будущего отказа async API. Такие отказы остаются в существующих приложенческих обработчиках. + +### Production события и variables + +- Все заявленные типы событий получают publishers через существующие приложенческие API. Редактор использует те же typed events для моков. +- Timer registry сбрасывается при каждом export. До export фоновые timers не запускаются. +- Node editor работает только на desktop. Phone и TV относятся к preview и production flows, а не к отдельному мобильному интерфейсу редактора. +- Variables живут только в execution context текущей сессии. Позиция воспроизведения и история страниц сохраняются через существующие хранилища приложения. diff --git a/editor/design.d.mts b/editor/design.d.mts index 3b04cf7c3..d308f7a85 100644 --- a/editor/design.d.mts +++ b/editor/design.d.mts @@ -20,3 +20,11 @@ export class DesignError extends Error { export function getCommittedDesign(root: string): Promise; export function rebaseDraft(options: { root: string; draft: EditorDraft }): Promise; +// Exported for merge regression tests; production callers go through rebaseDraft. +export function mergeValue( + base: unknown, + local: unknown, + incoming: unknown, + path: string, + issues: Array<{ path: string }>, +): unknown; diff --git a/editor/design.mjs b/editor/design.mjs index c53efdad4..9c9ee2940 100644 --- a/editor/design.mjs +++ b/editor/design.mjs @@ -98,7 +98,22 @@ function competingParentIssues(layout) { return []; } -function mergeValue(base, local, incoming, path, issues) { if (equal(local, base)) return structuredClone(incoming); +function mergeKey(path) { + if (path === 'theme.releaseIntents') return 'target'; + if (path === 'theme.overrides' + || path === 'flow.rules' + || path === 'flow.variables' + || path === 'theme.stylePresets' + || path === 'theme.layout.placements' + || path === 'theme.layout.containers' + || path === 'theme.layout.clones') return 'id'; + // Records inside one flow rule (ordered steps, graph nodes and edges) carry unique ids. + if (/\.steps$/.test(path) || /\.graph\.nodes$/.test(path) || /\.graph\.edges$/.test(path)) return 'id'; + return null; +} + +// Exported for merge regression tests; rebaseDraft is the production entry point. +export function mergeValue(base, local, incoming, path, issues) { if (equal(local, base)) return structuredClone(incoming); if (equal(incoming, base) || equal(local, incoming)) return structuredClone(local); if (object(base) && object(local) && object(incoming)) { const result = {}; @@ -109,35 +124,37 @@ function mergeValue(base, local, incoming, path, issues) { if (equal(local, bas } return result; } - if (Array.isArray(base) && Array.isArray(local) && Array.isArray(incoming)) { - // Layout records and style presets carry unique record ids, so independent edits to - // different nodes, containers, clones or presets merge entry by entry instead of - // conflicting as whole arrays. - const key = path === 'theme.releaseIntents' ? 'target' - : path === 'theme.overrides' - || path === 'flow.rules' - || path === 'theme.stylePresets' - || path === 'theme.layout.placements' - || path === 'theme.layout.containers' - || path === 'theme.layout.clones' - ? 'id' - : null; - if (key) { - const merged = new Map(); - const allIds = new Set([...base, ...local, ...incoming].map((entry) => entry[key])); - for (const id of allIds) { - const value = mergeValue( - base.find((entry) => entry[key] === id), - local.find((entry) => entry[key] === id), - incoming.find((entry) => entry[key] === id), - `${path}[${id}]`, - issues, - ); - if (value !== undefined) merged.set(id, value); + const key = mergeKey(path); + if (key && (Array.isArray(base) || Array.isArray(local) || Array.isArray(incoming))) { + // Layout records, style presets, flow rules, variables, steps, graph nodes and edges carry + // unique record ids, so independent edits to different records merge entry by entry instead + // of conflicting as whole arrays. An absent (undefined) collection means "no records yet" or + // a deletion: additions on both sides still merge by id, and an edit on one side rejects + // against a deletion. A defined non-array value cannot come from a parsed document, so it + // rejects instead of being normalized to an empty collection. + for (const side of [base, local, incoming]) { + if (side !== undefined && !Array.isArray(side)) { + issues.push({ path }); + return structuredClone(local); } - const ids = mergeOrder(base, local, incoming, key, path, issues, new Set(merged.keys())); - return ids.map((id) => merged.get(id)); } + const baseEntries = Array.isArray(base) ? base : []; + const localEntries = Array.isArray(local) ? local : []; + const incomingEntries = Array.isArray(incoming) ? incoming : []; + const merged = new Map(); + const allIds = new Set([...baseEntries, ...localEntries, ...incomingEntries].map((entry) => entry[key])); + for (const id of allIds) { + const value = mergeValue( + baseEntries.find((entry) => entry[key] === id), + localEntries.find((entry) => entry[key] === id), + incomingEntries.find((entry) => entry[key] === id), + `${path}[${id}]`, + issues, + ); + if (value !== undefined) merged.set(id, value); + } + const ids = mergeOrder(baseEntries, localEntries, incomingEntries, key, path, issues, new Set(merged.keys())); + return ids.map((id) => merged.get(id)); } issues.push({ path }); return structuredClone(local); diff --git a/editor/export.d.mts b/editor/export.d.mts index 92d5eaaa8..4603a99ea 100644 --- a/editor/export.d.mts +++ b/editor/export.d.mts @@ -1,4 +1,5 @@ -import type { DesignTarget, ReleaseIntent, ThemeDocument } from '../design/schema'; +import type { DesignTarget, FlowDocument, ReleaseIntent, ThemeDocument } from '../design/schema'; +import type { FlowDocumentDiff } from '../design/production-flow'; import type { EditorDraft } from './shared/draft'; export type ExportScope = @@ -16,6 +17,9 @@ export interface ExportPlan { targets: DesignTarget[]; draft: EditorDraft; theme: ThemeDocument; + // The scoped flow view the plan ships: the full document for the all scope, the owned + // rules (with the variables they reference) for a scoped export. + flow: FlowDocument; files: ExportPlanFile[]; } @@ -24,6 +28,8 @@ export interface ExportDiffFile { action: 'create' | 'update' | 'unchanged'; before: string | null; after: string; + // Structured review diff for flow documents: added/removed/changed ids per section. + flow?: FlowDocumentDiff | null; } export interface ExportDiff { diff --git a/editor/export.mjs b/editor/export.mjs index 97823fb55..e1203abc3 100644 --- a/editor/export.mjs +++ b/editor/export.mjs @@ -7,9 +7,11 @@ import { promisify } from 'node:util'; import { DESIGN_ELEMENTS } from '../design/catalog.ts'; import { generateThemeCss } from '../design/theme.ts'; import { checkTheme } from '../design/theme-checks.ts'; -import { validateFlow } from '../design/flow-validation.ts'; +import { validateFlow, validateFlowGraph } from '../design/flow-validation.ts'; import { generateLayoutModule } from '../design/generate.ts'; -import { parseThemeDocument } from '../design/validation.ts'; +import { parseFlowDocument, parseThemeDocument } from '../design/validation.ts'; +import { createFlowExportPlan, diffFlowDocuments, flowRuleInScope } from '../design/production-flow.ts'; +import { FLOW_MODULE_OUTPUT, generateFlowModule } from '../design/flow-module.ts'; import { parseEditorDraft } from './shared/draft.ts'; import { DESIGN_TARGETS, DESIGN_FORMATS, DESIGN_STATES } from '../design/schema.ts'; import { DESKTOP_ROUTES } from '../desktop/src/shell/routes.ts'; @@ -157,7 +159,6 @@ function contexts() { function outputFiles(draft) { const files = [ { path: `design/themes/${draft.theme.id}.json`, content: json(draft.theme) }, - { path: `design/flows/${draft.flow.id}.json`, content: json(draft.flow) }, ...DESIGN_TARGETS.map((target) => ({ path: `design/generated/${target}.css`, content: generateThemeCss(draft.theme, target), @@ -287,18 +288,28 @@ export function createExportPlan(input, scopeInput) { throw new ExportError('INVALID_DRAFT', 400); } const scope = normalizeScope(scopeInput); + let flowPlan; + try { + flowPlan = createFlowExportPlan(draft, scope); + } catch (error) { + throw new ExportError(error && typeof error === 'object' && error.message === 'INVALID_EXPORT_SCOPE' ? 'INVALID_EXPORT_SCOPE' : 'INVALID_FLOW', 400); + } const theme = scope.kind === 'all' ? draft.theme : { ...draft.theme, layout: layoutForScope(draft.theme.layout, scope) }; + const routes = DESKTOP_ROUTES.map((route) => route.id); + const flowIssues = [ + ...validateFlow(flowPlan.flow, routes), + ...validateFlowGraph(flowPlan.flow, routes), + ]; + if (flowIssues.length) throw new ExportError('INVALID_FLOW', 400, flowIssues); if (scope.kind === 'all') { - const flowIssues = validateFlow(draft.flow, DESKTOP_ROUTES.map((route) => route.id)); - if (flowIssues.length) throw new ExportError('INVALID_FLOW', 400, flowIssues); const designIssues = checkTheme(draft.theme, contexts()); if (designIssues.length) throw new ExportError('DESIGN_ISSUES', 422, designIssues); } const targets = targetsForScope(scope, theme); const files = scope.kind === 'all' - ? outputFiles({ ...draft, theme }) + ? [...outputFiles({ ...draft, theme }), ...flowPlan.files].sort((a, b) => a.path.localeCompare(b.path)) : [ // A scoped export ships the selected style edits too: the theme file merges the // in-scope overrides with the base theme and the generated CSS for the scope targets @@ -310,8 +321,66 @@ export function createExportPlan(input, scopeInput) { path: safePath(`design/generated/${target}.css`), content: generateThemeCss(theme, target), })), + ...flowPlan.files, ].sort((a, b) => a.path.localeCompare(b.path)); - return { scope, targets, draft, theme, files }; + return { scope, targets, draft, theme, flow: flowPlan.flow, files }; +} + +// Scoped flow merge mirrors the layout merge: rules merge by id, so independent edits to +// other screens survive, a scoped rule the draft edited ships with its new trigger, and a +// scoped rule the draft dropped exports the deletion. Variables are global state: a scoped +// export may add or update the variables its rules reference, but never deletes base ones. +async function mergedFlowDocument(root, plan) { + const flowFile = plan.files.find((file) => file.path.startsWith('design/flows/')); + let baseFlow = null; + try { + baseFlow = parseFlowDocument(JSON.parse(await readFile(join(root, flowFile.path), 'utf8'))); + } catch (error) { + if (!(error && typeof error === 'object' && error.code === 'ENOENT')) { + throw new ExportError('INVALID_EXPORT_BASE', 409); + } + } + const scopedFlow = plan.flow; + const scopedIds = new Set(scopedFlow.rules.map((rule) => rule.id)); + const draftRules = new Map(plan.draft.flow.rules.map((rule) => [rule.id, rule])); + const mergedRules = []; + for (const baseRule of baseFlow?.rules ?? []) { + if (scopedIds.has(baseRule.id)) continue; + if (flowRuleInScope(baseRule, plan.scope)) { + // The scope no longer covers this rule: ship the draft record (its trigger moved), or + // drop it when the draft deleted the rule. + const draftRule = draftRules.get(baseRule.id); + if (draftRule) mergedRules.push(draftRule); + continue; + } + mergedRules.push(baseRule); + } + mergedRules.push(...scopedFlow.rules); + const baseVariables = baseFlow?.variables ?? []; + const scopedVariables = new Map((scopedFlow.variables ?? []).map((variable) => [variable.id, variable])); + const mergedVariables = [ + ...baseVariables.map((variable) => scopedVariables.get(variable.id) ?? variable), + ...(scopedFlow.variables ?? []).filter((variable) => !baseVariables.some((base) => base.id === variable.id)), + ]; + let mergedFlow; + try { + mergedFlow = parseFlowDocument({ + schemaVersion: 1, + id: scopedFlow.id, + baseCommit: baseFlow?.baseCommit ?? scopedFlow.baseCommit, + rules: mergedRules, + ...(mergedVariables.length ? { variables: mergedVariables } : {}), + }); + } catch { + throw new ExportError('INVALID_EXPORT_BASE', 409); + } + const routes = DESKTOP_ROUTES.map((route) => route.id); + const flowIssues = [ + ...validateFlow(mergedFlow, routes), + ...validateFlowGraph(mergedFlow, routes), + ]; + if (flowIssues.length) throw new ExportError('INVALID_FLOW', 422, flowIssues); + return mergedFlow; } async function exportContents(root, plan) { @@ -405,9 +474,21 @@ async function exportContents(root, plan) { const target = file.path.slice('design/generated/'.length, -'.css'.length); contents.set(file.path, generateThemeCss(mergedTheme, target)); } + if (plan.files.some((file) => file.path.startsWith('design/flows/'))) { + // The merged document drives both the flow json and the production module: the module + // is generated from the full merge (foreign rules included), so the runtime, the + // generator check and the exported json never drift apart after a scoped export. + const mergedFlow = await mergedFlowDocument(root, plan); + contents.set(flowFilePathOf(plan), json(mergedFlow)); + contents.set(FLOW_MODULE_OUTPUT, generateFlowModule(mergedFlow)); + } return contents; } +function flowFilePathOf(plan) { + return plan.files.find((file) => file.path.startsWith('design/flows/')).path; +} + export async function createExportDiff(root, plan) { const { stdout } = await execute('git', ['rev-parse', 'HEAD'], { cwd: root }); const baseCommit = stdout.trim(); @@ -431,6 +512,20 @@ export async function createExportDiff(root, plan) { after, }); } + // Flow files carry a structured review diff by rule, node, edge and variable ids. A + // foreign hand edit can make the base unparsable; the ownership checks guard that, and + // the file-level before/after stay available either way. + for (const file of files) { + if (!file.path.startsWith('design/flows/') || file.action !== 'update' || file.before === null) continue; + try { + file.flow = diffFlowDocuments( + parseFlowDocument(JSON.parse(file.before)), + parseFlowDocument(JSON.parse(file.after)), + ); + } catch { + // keep the plain before/after diff + } + } return { baseCommit, targets: plan.targets, files, foreignDirty }; } diff --git a/editor/src/App.tsx b/editor/src/App.tsx index bca40fd70..9b6afd7c1 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -25,6 +25,8 @@ import { LayoutInspector } from './components/LayoutInspector'; import { MultiSelectInspector } from './components/MultiSelectInspector'; import { LogicEditor } from './components/LogicEditor'; import { FlowMap } from './components/FlowMap'; +import { FlowCanvas, type FlowNodeSelection } from './components/FlowCanvas'; +import { FlowNodeInspector } from './components/FlowNodeInspector'; import { SimulationPanel } from './components/SimulationPanel'; import { ExportPanel } from './components/ExportPanel'; import { EditorIcon } from './components/EditorIcon'; @@ -85,6 +87,7 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; const [geometrySnapshot, setGeometrySnapshot] = useState(null); const [geometryConfirmed, setGeometryConfirmed] = useState(false); const [mode, setMode] = useState<'design' | 'logic' | 'simulate'>('design'); + const [flowSelection, setFlowSelection] = useState(null); const [serverConnected, setServerConnected] = useState(false); const [previewMode, setPreviewMode] = useState<'before' | 'after'>('after'); const [trace, setTrace] = useState([]); @@ -151,6 +154,34 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; ?? layoutNodes.find((entry) => entry.elementId === elementId); handleSelectNode(node ? { ids: [node.nodeId], anchorId: node.nodeId } : { ids: [], anchorId: null }); }; + // Canvas selection reports the node so the inspector can show its typed fields; the linked + // element or destination screen stays highlighted for the Design mode switch. + const handleFlowNodeSelected = (selection: FlowNodeSelection) => { + setFlowSelection(selection); + const rule = controller.draft.flow.rules.find((entry) => entry.id === selection.ruleId); + const node = rule?.graph?.nodes.find((entry) => entry.id === selection.nodeId); + if (!rule || !node) return; + let target = rule.screenId; + if (node.kind === 'trigger' && rule.elementId !== '*') target = rule.elementId; + if (node.kind === 'action' || node.kind === 'result') { + const step = rule.steps?.find((entry) => entry.id === node.stepId); + if (step?.action && (step.action.type === 'navigate' || step.action.type === 'openScreen')) target = step.action.screenId; + } + if (isDesignElementAvailable(target, draft.screenId, draft.target)) { + setSelected(target); + } else if (isEditorRoute(draft.target, target)) { + setView({ screenId: target as DesktopRouteId }); + } + }; + const handleFlowReveal = (selection: FlowNodeSelection) => { + handleFlowNodeSelected(selection); + setMode('design'); + }; + useEffect(() => { + if (!flowSelection) return; + const rule = draft.flow.rules.find((entry) => entry.id === flowSelection.ruleId); + if (!rule?.graph?.nodes.some((entry) => entry.id === flowSelection.nodeId)) setFlowSelection(null); + }, [draft.flow, flowSelection]); const handleMoveNode = (args: { nodeId: string; parentId: string; index: number; placement?: LayoutPlacement; siblings?: string[] }) => { const placement = getLayoutPlacement(draft.theme, layoutContext, args.nodeId); if (placement?.locked || placement?.hidden) return; @@ -654,36 +685,46 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; />
{routeLabel}{viewport.width} × {viewport.height}{!previewEditable && Базовая тема - редактирование выключено}
- setView({ screenId })} - onTrace={setTrace} - onNodesChange={handleNodesChange} - onGeometryChange={setGeometrySnapshot} - onSelectNode={handleSelectNode} - onMoveNode={handleMoveNode} - onPlaceNode={handlePlaceNode} - onPlaceNodes={handlePlaceNodes} - /> + {mode === 'logic' ? ( + setMode('simulate')} + /> + ) : ( + setView({ screenId })} + onTrace={setTrace} + onNodesChange={handleNodesChange} + onGeometryChange={setGeometrySnapshot} + onSelectNode={handleSelectNode} + onMoveNode={handleMoveNode} + onPlaceNode={handlePlaceNode} + onPlaceNodes={handlePlaceNodes} + /> + )}