@ihyeon/flow-qa 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +72 -0
  3. package/README.md +71 -2
  4. package/THIRD_PARTY_NOTICES.md +9 -0
  5. package/dist/bin/flow-qa.mjs +3 -0
  6. package/dist/examples/demo/server.js +31 -0
  7. package/dist/src/cli.js +285 -0
  8. package/dist/src/compiler.js +62 -0
  9. package/dist/src/engine.js +246 -0
  10. package/dist/src/i18n.js +98 -0
  11. package/dist/src/planner.js +112 -0
  12. package/dist/src/report.js +315 -0
  13. package/dist/src/schema.js +71 -0
  14. package/dist/src/ui/entry.js +109 -0
  15. package/dist/src/ui/evidence.js +85 -0
  16. package/dist/src/ui/public/app.js +360 -0
  17. package/dist/src/ui/public/display.js +56 -0
  18. package/dist/src/ui/public/fonts/dm-sans.woff2 +0 -0
  19. package/dist/src/ui/public/fonts/noto-kr-subset.woff2 +0 -0
  20. package/dist/src/ui/public/i18n.js +290 -0
  21. package/dist/src/ui/public/index.html +117 -0
  22. package/dist/src/ui/public/style.css +200 -0
  23. package/dist/src/ui/server.js +785 -0
  24. package/dist/src/workspace.js +88 -0
  25. package/docs/architecture.md +38 -0
  26. package/docs/releases.md +53 -0
  27. package/docs/ui.md +71 -0
  28. package/licenses/DM-Sans-LICENSE.txt +93 -0
  29. package/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  30. package/node_modules/@hyeonse/design-system/.github/workflows/ci.yml +21 -0
  31. package/node_modules/@hyeonse/design-system/.npmignore +5 -0
  32. package/node_modules/@hyeonse/design-system/AGENTS.md +10 -0
  33. package/node_modules/@hyeonse/design-system/LICENSE +21 -0
  34. package/node_modules/@hyeonse/design-system/README.md +67 -0
  35. package/node_modules/@hyeonse/design-system/THIRD_PARTY_NOTICES.md +5 -0
  36. package/node_modules/@hyeonse/design-system/dist/components.css +105 -0
  37. package/node_modules/@hyeonse/design-system/dist/preferences.d.ts +21 -0
  38. package/node_modules/@hyeonse/design-system/dist/preferences.js +69 -0
  39. package/node_modules/@hyeonse/design-system/dist/react.d.ts +21 -0
  40. package/node_modules/@hyeonse/design-system/dist/react.js +100 -0
  41. package/node_modules/@hyeonse/design-system/dist/tokens.css +75 -0
  42. package/node_modules/@hyeonse/design-system/docs/api.md +33 -0
  43. package/node_modules/@hyeonse/design-system/docs/assets/fonts/dm-sans.woff2 +0 -0
  44. package/node_modules/@hyeonse/design-system/docs/assets/fonts/noto-kr-subset.woff2 +0 -0
  45. package/node_modules/@hyeonse/design-system/docs/audit.md +29 -0
  46. package/node_modules/@hyeonse/design-system/docs/foundations.md +30 -0
  47. package/node_modules/@hyeonse/design-system/docs/verification.md +13 -0
  48. package/node_modules/@hyeonse/design-system/index.html +51 -0
  49. package/node_modules/@hyeonse/design-system/licenses/DM-Sans-LICENSE.txt +93 -0
  50. package/node_modules/@hyeonse/design-system/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  51. package/node_modules/@hyeonse/design-system/licenses/arla6ka-MIT.txt +21 -0
  52. package/node_modules/@hyeonse/design-system/package.json +35 -0
  53. package/node_modules/@hyeonse/design-system/scripts/audit-tokens.mjs +48 -0
  54. package/node_modules/@hyeonse/design-system/scripts/build.mjs +12 -0
  55. package/node_modules/@hyeonse/design-system/showcase.css +75 -0
  56. package/node_modules/@hyeonse/design-system/showcase.mjs +63 -0
  57. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/SKILL.md +18 -0
  58. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/api.md +7 -0
  59. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/build.md +7 -0
  60. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/foundations.md +7 -0
  61. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/mapping.md +7 -0
  62. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/migration.md +5 -0
  63. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/review-docs.md +5 -0
  64. package/node_modules/@hyeonse/design-system/src/components.css +105 -0
  65. package/node_modules/@hyeonse/design-system/src/preferences.ts +87 -0
  66. package/node_modules/@hyeonse/design-system/src/react.tsx +161 -0
  67. package/node_modules/@hyeonse/design-system/src/tokens.css +75 -0
  68. package/node_modules/@hyeonse/design-system/tests/audit.test.mjs +30 -0
  69. package/node_modules/@hyeonse/design-system/tests/preferences.test.mjs +86 -0
  70. package/node_modules/@hyeonse/design-system/tsconfig.json +16 -0
  71. package/package.json +73 -4
@@ -0,0 +1,360 @@
1
+ (() => {
2
+ 'use strict';
3
+ const $ = (id) => document.getElementById(id);
4
+ const i18n = window.FlowQAI18n;
5
+ const t = i18n.t;
6
+ const names = { scenario: 'nav.scenario', library: 'nav.library', history: 'nav.history', settings: 'nav.settings' };
7
+ const app = { state: null, connected: false, displayTargets: [], draft: null, script: '', savedFile: '', savedHash: '', origin: 'none', sourceFlow: '', boundSource: null, stale: false, dirty: false, busy: false, planning: false, planRequest: 0, stopping: false, report: null, reportMarkdown: '', selectedRun: '', view: 'scenario' };
8
+
9
+ function el(tag, className, value) {
10
+ const node = document.createElement(tag);
11
+ if (className) node.className = className;
12
+ if (value !== undefined) node.textContent = String(value);
13
+ return node;
14
+ }
15
+ function replace(node, ...children) { node.replaceChildren(...children); }
16
+ function notice(message, error = false, translation = null) {
17
+ const node = $('notice');
18
+ node.textContent = message;
19
+ node.className = error ? 'notice error' : 'notice';
20
+ node.hidden = !message;
21
+ app.notice = translation ? { ...translation, error } : null;
22
+ if (message) node.scrollIntoView({ block: 'nearest' });
23
+ }
24
+ function noticeKey(key, params = {}, error = false) { notice(t(key, params), error, { key, params }); }
25
+ function localizedError(key, params = {}) { const error = new Error(t(key, params)); error.translation = { key, params }; return error; }
26
+ function showError(error) { if (error?.translation) noticeKey(error.translation.key, error.translation.params, true); else notice(error instanceof Error ? error.message : String(error), true); }
27
+ function formatDate(value) { const d = new Date(value); return Number.isNaN(d.getTime()) ? String(value || '') : d.toLocaleString(i18n.locale() === 'ko' ? 'ko-KR' : 'en-US'); }
28
+ function download(filename, value, mime = 'text/plain') {
29
+ const url = URL.createObjectURL(new Blob([value], { type: `${mime};charset=utf-8` }));
30
+ const link = document.createElement('a'); link.href = url; link.download = filename; link.click();
31
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
32
+ }
33
+ async function api(path, body) {
34
+ const options = { headers: { 'Accept-Language': i18n.locale() } };
35
+ if (body !== undefined) { options.method = 'POST'; options.headers['content-type'] = 'application/json'; options.headers['x-flowqa-token'] = app.state?.csrfToken || ''; options.body = JSON.stringify(body); }
36
+ const response = await fetch(path, options);
37
+ let result;
38
+ try { result = await response.json(); } catch { throw localizedError('app.responseUnreadable', { status: response.status }); }
39
+ if (!response.ok) {
40
+ const issues = Array.isArray(result?.issues) ? result.issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`).join('\n') : '';
41
+ if (!result?.error && !issues) throw localizedError('app.requestFailed', { status: response.status });
42
+ throw new Error(`${result?.error || t('app.requestFailed', { status: response.status })}${issues ? `\n${issues}` : ''}`);
43
+ }
44
+ return result;
45
+ }
46
+ function view(name) {
47
+ app.view = name;
48
+ for (const key of Object.keys(names)) $('view-' + key).hidden = key !== name;
49
+ document.querySelectorAll('[data-view]').forEach((button) => {
50
+ const chosen = button.dataset.view === name;
51
+ button.classList.toggle('selected', chosen);
52
+ if (chosen) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
53
+ });
54
+ $('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[name]) });
55
+ $('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
56
+ if (name === 'history') renderHistory();
57
+ }
58
+ function readiness() {
59
+ const active = app.state?.active;
60
+ const ready = !!app.draft && !!app.script && !app.stale && !app.dirty;
61
+ $('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !$('review-checkbox').checked || !$('target-select').value || !!active || app.busy;
62
+ $('save-btn').disabled = !ready || app.busy;
63
+ $('script-download').disabled = !ready;
64
+ for (const id of ['stop-btn', 'global-stop-btn']) {
65
+ $(id).hidden = !active;
66
+ $(id).disabled = !app.connected || !active || active.status === 'stopping' || app.stopping;
67
+ $(id).textContent = t(active?.kind === 'plan' ? 'app.stopPlan' : 'scenario.stop');
68
+ }
69
+ $('plan-btn').disabled = !app.connected || !app.state?.models?.planner?.configured || app.state.models.planner.kind !== 'openai-compatible' || !app.state.models.planner.keyReady || !!active || app.busy;
70
+ $('compile-btn').disabled = app.stale || app.busy;
71
+ $('new-draft-btn').disabled = app.busy;
72
+ $('flow-input').disabled = app.busy;
73
+ $('suite-editor').disabled = app.busy;
74
+ $('prompt-btn').disabled = app.busy;
75
+ $('run-readiness').textContent = t(!app.connected ? 'app.connectionRequired'
76
+ : active ? (active.status === 'stopping' ? 'app.stopping' : active.kind === 'plan' ? 'app.planning' : 'app.running')
77
+ : app.stale ? 'app.stale'
78
+ : app.dirty ? 'app.dirty'
79
+ : !ready ? 'app.needValidated'
80
+ : !app.savedFile || !app.savedHash ? 'app.needSaved'
81
+ : !$('target-select').value ? 'app.needTarget'
82
+ : !$('review-checkbox').checked ? 'app.needReview' : 'app.ready');
83
+ const cases = app.draft?.cases || [];
84
+ const blocked = cases.filter((item) => item.questions?.length || (item.steps.some((step) => ['act', 'assertAgent'].includes(step.type)) && !app.state?.models?.agent?.keyReady)).length;
85
+ $('case-readiness').textContent = ready ? t('app.caseReadiness', { total: cases.length, ready: cases.length - blocked, blocked, hint: blocked ? t('app.blockedHint') : '' }) : '';
86
+ const pill = $('draft-status');
87
+ pill.textContent = t(app.stale ? 'app.regenerate' : app.dirty ? 'app.revalidate' : ready ? 'app.validated' : 'scenario.noDraft');
88
+ pill.className = 'status-pill' + (ready ? ' ready' : app.stale || app.dirty ? ' dirty' : '');
89
+ $('draft-source').textContent = t(app.origin === 'generated' ? 'app.sourceGenerated' : app.origin === 'library' ? (app.sourceFlow ? 'app.sourceLibraryFlow' : 'app.sourceLibrary') : app.origin === 'manual' ? (app.sourceFlow ? 'app.sourceManualFlow' : 'app.sourceManual') : 'scenario.sourceNone');
90
+ $('script-preview').textContent = ready ? app.script : t('scenario.scriptPlaceholder');
91
+ }
92
+ function locatorText(locator) {
93
+ if (!locator) return '';
94
+ const labels = { role: 'app.locatorRole', text: 'app.locatorText', label: 'app.locatorLabel', testId: 'app.locatorTestId' };
95
+ return `${labels[locator.by] ? t(labels[locator.by]) : locator.by} “${locator.value}”${locator.name ? ` · ${t('app.locatorName', { name: locator.name })}` : ''}`;
96
+ }
97
+ function stepText(step) {
98
+ const target = locatorText(step.locator);
99
+ switch (step.type) {
100
+ case 'open': return t('app.stepOpen', { path: step.path });
101
+ case 'click': return t('app.stepClick', { target });
102
+ case 'fill': return t('app.stepFill', { target, value: step.value });
103
+ case 'assertVisible': return t(step.visible === false ? 'app.stepHidden' : 'app.stepVisible', { target });
104
+ case 'assertText': return t(step.match === 'contains' ? 'app.stepTextContains' : 'app.stepTextEquals', { target, expected: step.expected });
105
+ case 'assertUrl': return t('app.stepUrl', { expected: step.expected });
106
+ case 'act': return t('app.stepAct', { instruction: step.instruction });
107
+ case 'assertAgent': return t('app.stepAssertAgent', { instruction: step.instruction });
108
+ default: return step.type;
109
+ }
110
+ }
111
+ function summarize(suite, preserveOpen = false) {
112
+ const host = $('draft-summary');
113
+ const openCases = preserveOpen ? [...host.querySelectorAll('.case-review')].map((node) => node.open) : null;
114
+ replace(host);
115
+ if (!suite) { host.append(el('p', 'summary-empty', t('scenario.summaryEmpty'))); return; }
116
+ const title = el('h3', '', suite.title || t('app.untitledSuite')); host.append(title);
117
+ const metrics = el('div', 'summary-grid');
118
+ for (const [number, key] of [[suite.cases?.length || 0, 'app.cases'], [suite.requirements?.length || 0, 'app.requirements'], [suite.cases?.reduce((n, item) => n + (item.questions?.length || 0), 0) || 0, 'app.questions']]) {
119
+ const item = el('div', 'summary-stat'); item.append(el('strong', '', number), el('span', '', t(key))); metrics.append(item);
120
+ }
121
+ host.append(metrics);
122
+ const reqTitle = el('h3', '', t('app.requirements')); host.append(reqTitle);
123
+ const reqList = el('ol', 'compact-list'); for (const req of suite.requirements || []) reqList.append(el('li', '', `${req.id} · ${req.text}`)); host.append(reqList);
124
+ host.append(el('h3', 'case-title', t('app.caseSteps')));
125
+ for (const [index, item] of (suite.cases || []).entries()) {
126
+ const detail = el('details', 'case-review'); detail.open = openCases ? !!openCases[index] : index === 0;
127
+ detail.append(el('summary', '', `${item.id} · ${item.title}${item.questions?.length ? ` · ${t('app.caseBlocked')}` : ''}`));
128
+ detail.append(el('p', 'hint', t('app.linkedRequirements', { ids: (item.requirementIds || []).join(', ') || t('app.none') })));
129
+ if (item.preconditions?.length) { detail.append(el('h4', '', t('app.preconditions'))); const list = el('ul', 'compact-list'); for (const value of item.preconditions) list.append(el('li', '', value)); detail.append(list); }
130
+ const steps = el('ol', 'compact-list case-steps');
131
+ for (const step of item.steps || []) steps.append(el('li', step.type.startsWith('assert') ? 'expected-step' : '', stepText(step)));
132
+ detail.append(steps);
133
+ if (item.questions?.length) { detail.append(el('h4', 'blocked-title', t('app.blockingQuestions'))); const list = el('ul', 'compact-list'); for (const value of item.questions) list.append(el('li', '', value)); detail.append(list); }
134
+ host.append(detail);
135
+ }
136
+ }
137
+ function setDraft(suite, script, origin, sourceFlow = '', savedFile = '', savedHash = '') {
138
+ app.draft = suite; app.script = script; app.origin = origin; app.sourceFlow = sourceFlow; app.boundSource = suite.source ? { ...suite.source } : null; app.savedFile = savedFile; app.savedHash = savedHash; app.stale = false; app.dirty = false;
139
+ $('suite-editor').value = JSON.stringify(suite, null, 2);
140
+ $('review-checkbox').checked = false;
141
+ summarize(suite); readiness();
142
+ }
143
+ function targetOptions() {
144
+ const selected = $('target-select').value;
145
+ const previousURL = app.displayTargets.find((item) => item.name === selected)?.url;
146
+ app.displayTargets = (app.state?.targets || []).map((target) => ({ ...target }));
147
+ replace($('target-select'), el('option', '', t('scenario.selectTarget')));
148
+ $('target-select').firstChild.value = '';
149
+ for (const target of app.state?.targets || []) { const option = el('option', '', target.name); option.value = target.name; $('target-select').append(option); }
150
+ if ((app.state?.targets || []).some((x) => x.name === selected)) $('target-select').value = selected;
151
+ if (previousURL !== app.displayTargets.find((item) => item.name === selected)?.url) $('review-checkbox').checked = false;
152
+ targetDescription();
153
+ const list = $('target-list'); replace(list);
154
+ for (const target of app.state?.targets || []) { const row = el('div', 'settings-entry'); row.append(el('strong', '', target.name), document.createTextNode(` · ${target.url}${target.description ? ` · ${target.description}` : ''}`)); list.append(row); }
155
+ }
156
+ function targetDescription() { const target = app.displayTargets.find((item) => item.name === $('target-select').value); $('target-description').textContent = target ? `${target.url}${target.description ? ` · ${target.description}` : ''}` : t('scenario.targetHint'); readiness(); }
157
+ function modelDescription(role) {
158
+ const model = app.state?.models?.[role] || {};
159
+ const explanation = t(role === 'planner' ? 'app.modelPlannerHelp' : 'app.modelAgentHelp');
160
+ if (!model.configured) return `${explanation} ${t('app.modelUnconnected')}`;
161
+ if (model.kind === 'external') return `${explanation} ${t('app.modelExternal')}`;
162
+ const keyState = !model.apiKeyEnv ? 'app.modelNoKey' : model.keyReady ? 'app.modelKeyReady' : 'app.modelKeyMissing';
163
+ return `${explanation} ${model.model || t('app.modelGeneric')} · ${t(keyState)} · ${t('app.modelUnverified')}`;
164
+ }
165
+ function modelForms() {
166
+ const host = $('model-forms'); replace(host);
167
+ for (const role of ['planner', 'agent']) {
168
+ const model = app.state?.models?.[role] || {};
169
+ const section = el('section', 'model-block'); section.dataset.role = role; section.append(el('h3', '', t(role === 'planner' ? 'app.modelPlanner' : 'app.modelAgent')));
170
+ section.append(el('p', 'model-description', modelDescription(role)));
171
+ if (model.kind === 'external') { host.append(section); continue; }
172
+ const form = el('form', 'model-form'); form.dataset.role = role;
173
+ for (const [name, titleKey, placeholder, value] of [['baseURL', 'app.modelBaseUrl', 'https://example.com/v1', model.baseURL || ''], ['model', 'app.modelName', t('app.modelIdentifier'), model.model || ''], ['apiKeyEnv', 'app.modelKeyEnv', 'OPENAI_API_KEY', model.apiKeyEnv || '']]) {
174
+ const labelNode = el('label'); labelNode.append(el('span', '', t(titleKey))); const input = el('input'); input.name = name; input.value = value; input.placeholder = placeholder; input.required = name !== 'apiKeyEnv'; input.autocomplete = 'off'; if (name === 'baseURL') input.type = 'url'; if (name === 'apiKeyEnv') input.pattern = '[A-Z][A-Z0-9_]*'; labelNode.append(input); form.append(labelNode);
175
+ }
176
+ const actions = el('div', 'actions'); const save = el('button', 'button primary', t('app.saveConnection')); save.type = 'submit'; actions.append(save);
177
+ if (model.configured) { const clear = el('button', 'button', t('app.clearConnection')); clear.type = 'button'; clear.addEventListener('click', () => clearModel(role)); actions.append(clear); }
178
+ form.append(actions); form.addEventListener('submit', saveModel); section.append(form); host.append(section);
179
+ }
180
+ }
181
+ function renderLibrary() {
182
+ const host = $('library-list'); replace(host);
183
+ const suites = app.state?.suites || [];
184
+ if (!suites.length) { host.append(el('p', 'empty', t('app.libraryEmpty'))); return; }
185
+ for (const suite of suites) {
186
+ const row = el('div', 'list-row'); const copy = el('div'); copy.append(el('strong', '', suite.title || suite.id), el('small', '', t('app.libraryMeta', { id: suite.id, count: suite.caseCount, questions: suite.questionCount, file: suite.file })));
187
+ const button = el('button', 'button', t('app.load')); button.type = 'button'; button.addEventListener('click', () => loadSuite(suite.file)); row.append(copy, button); host.append(row);
188
+ }
189
+ }
190
+ function renderHistory() {
191
+ const host = $('history-list'); replace(host);
192
+ const runs = app.state?.runs || [];
193
+ if (!runs.length) { host.append(el('p', 'empty', t(app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty'))); return; }
194
+ for (const run of runs) {
195
+ const row = el('div', 'list-row'); const copy = el('div'); copy.append(el('strong', '', `${run.suiteId} · ${i18n.status(run.status)}`), el('small', '', t('app.runMeta', { date: formatDate(run.startedAt), target: run.target?.name || t('app.noTarget'), passed: run.passedCount ?? 0, total: run.caseCount ?? 0, id: run.runId })));
196
+ const button = el('button', 'button', t('app.viewReport')); button.type = 'button'; button.disabled = run.status === 'running' || run.status === 'stopping'; button.addEventListener('click', () => loadReport(run.runId)); row.append(copy, button); host.append(row);
197
+ }
198
+ }
199
+ function renderActive() {
200
+ const active = app.state?.active;
201
+ $('active-indicator').textContent = !app.connected ? t('app.disconnected') : active ? `${t(active.status === 'stopping' ? 'app.activeStopping' : active.kind === 'plan' ? 'app.activePlanning' : 'app.activeRunning')}${active.kind === 'run' ? ` · ${active.title}` : ''}` : t(app.planning ? 'app.requestingPlan' : 'top.waiting');
202
+ $('connection-dot').classList.toggle('ready', app.connected);
203
+ $('connection-label').textContent = t(app.connected ? 'app.connected' : 'app.disconnected');
204
+ $('reconnect-btn').textContent = t(app.connected ? 'app.refreshSettings' : 'top.reconnect');
205
+ const runtime = app.state?.runtime;
206
+ $('runtime-status').textContent = !app.connected ? t('app.runtimeDisconnected') : runtime?.browserInstalled ? t('app.runtimeInstalled') : t('app.runtimeMissing', { command: runtime?.browserInstallCommand || 'npx playwright install chromium' });
207
+ $('planner-readiness').textContent = t(!app.state?.models?.planner?.configured ? 'app.plannerUnconfigured' : !app.state.models.planner.keyReady ? 'app.plannerKeyMissing' : app.state.models.planner.kind !== 'openai-compatible' ? 'app.plannerCliOnly' : 'app.plannerReady');
208
+ readiness();
209
+ }
210
+ async function refresh(full = false) {
211
+ const previousActive = app.state?.active;
212
+ let state;
213
+ try { state = await api('/api/state'); } catch (error) { app.connected = false; renderActive(); throw error; }
214
+ const initialize = !app.state;
215
+ const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
216
+ const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
217
+ app.state = state; app.connected = true;
218
+ renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) renderHistory();
219
+ if (full || initialize) { targetOptions(); modelForms(); }
220
+ if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
221
+ if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
222
+ }
223
+ async function work(fn) { if (app.busy) return; app.busy = true; readiness(); notice(''); try { await fn(); } catch (error) { showError(error); } finally { app.busy = false; readiness(); } }
224
+ async function loadSuite(file) { await work(async () => { const result = await api(`/api/suite?file=${encodeURIComponent(file)}`); $('flow-input').value = result.flow || ''; setDraft(result.suite, result.script, 'library', result.flow || '', file, result.suiteHash || ''); noticeKey('app.suiteLoaded'); view('scenario'); }); }
225
+ async function generate() { await work(async () => {
226
+ const flow = $('flow-input').value.trim();
227
+ if (!flow) throw localizedError('app.enterFlow');
228
+ if (!app.state?.models?.planner?.configured || app.state.models.planner.kind !== 'openai-compatible' || !app.state.models.planner.keyReady) throw localizedError('app.configurePlanner');
229
+ const request = ++app.planRequest;
230
+ app.planning = true; renderActive(); noticeKey('app.planStarted');
231
+ try {
232
+ const result = await api('/api/plan', { flow });
233
+ if (request !== app.planRequest) { noticeKey('app.planCancelledLate'); return; }
234
+ setDraft(result.suite, result.script, 'generated', flow); noticeKey('app.planComplete');
235
+ } catch (error) { if (request !== app.planRequest) noticeKey('app.planCancelledDraft'); else throw error; }
236
+ finally { app.planning = false; await refresh(false).catch(() => {}); }
237
+ }); }
238
+ async function prompt() { await work(async () => { const flow = $('flow-input').value.trim(); if (!flow) throw localizedError('app.enterFlow'); const result = await api('/api/prompt', { flow }); download('flow-qa-prompt.md', result.prompt, 'text/markdown'); noticeKey('app.promptDownloaded'); }); }
239
+ async function compile() { await work(async () => {
240
+ if (app.stale) throw localizedError('app.compileStale');
241
+ let suite; try { suite = JSON.parse($('suite-editor').value); } catch { throw localizedError('app.invalidJson'); }
242
+ if (suite && typeof suite === 'object' && !Array.isArray(suite)) {
243
+ if (app.boundSource) suite.source = { ...app.boundSource };
244
+ else delete suite.source;
245
+ }
246
+ const result = await api('/api/compile', { suite });
247
+ const sourceFlow = app.sourceFlow || (!app.boundSource ? $('flow-input').value.trim() : '');
248
+ setDraft(result.suite, result.script, app.origin === 'none' ? 'manual' : app.origin, sourceFlow, app.dirty ? '' : app.savedFile, app.dirty ? '' : app.savedHash);
249
+ noticeKey('app.compileComplete');
250
+ }); }
251
+ async function save() { await work(async () => { if (!app.draft || app.dirty || app.stale) throw localizedError('app.needValidated'); const body = { suite: app.draft }; if (!app.boundSource && app.sourceFlow && app.sourceFlow === $('flow-input').value.trim()) body.flow = app.sourceFlow; const result = await api('/api/save', body); const saved = await api(`/api/suite?file=${encodeURIComponent(result.file)}`); $('flow-input').value = saved.flow || ''; setDraft(saved.suite, saved.script, 'library', saved.flow || '', result.file, saved.suiteHash || ''); noticeKey('app.savedSuite', { file: result.file }); await refresh(false); }); }
252
+ async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !$('review-checkbox').checked || !$('target-select').value) throw localizedError('app.runNotReady'); const result = await api('/api/run', { suite: app.savedFile, suiteHash: app.savedHash, targetName: $('target-select').value, targetURL: app.displayTargets.find((target) => target.name === $('target-select').value)?.url }); noticeKey('app.runStarted', { id: result.operationId }); await refresh(false); }); }
253
+ async function stop() {
254
+ if (app.stopping || !app.state?.active) return;
255
+ if (app.state.active.kind === 'plan' && app.planning) app.planRequest += 1;
256
+ app.stopping = true; readiness();
257
+ try { await api('/api/stop', {}); noticeKey('app.stopRequested'); await refresh(false); }
258
+ catch (error) { showError(error); }
259
+ finally { app.stopping = false; readiness(); }
260
+ }
261
+ async function loadReport(runId) { await work(async () => { const result = await api(`/api/report?runId=${encodeURIComponent(runId)}`); app.report = result.report; app.reportMarkdown = result.markdown; app.selectedRun = runId; renderReport(); $('report-panel').hidden = false; $('report-panel').scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }
262
+ async function downloadReportMarkdown() {
263
+ const runId = app.selectedRun; const locale = i18n.locale();
264
+ if (!runId || !app.report) return;
265
+ try {
266
+ const result = await api(`/api/report?runId=${encodeURIComponent(runId)}`);
267
+ if (runId !== app.selectedRun || locale !== i18n.locale()) return;
268
+ app.reportMarkdown = result.markdown;
269
+ download(`${runId}.md`, result.markdown, 'text/markdown');
270
+ } catch (error) { if (runId === app.selectedRun && locale === i18n.locale()) showError(error); }
271
+ }
272
+ function renderReport() {
273
+ const report = app.report; if (!report) return;
274
+ $('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${report.target?.name || ''}`;
275
+ const host = $('report-content'); replace(host);
276
+ const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: report.target?.name || '' }), t('app.reportStarted', { date: formatDate(report.startedAt) }), t('app.reportFinished', { date: formatDate(report.finishedAt) })]) meta.append(el('span', '', value)); host.append(meta);
277
+ if (report.usage) host.append(el('p', 'hint', t('app.usage', { tokens: report.usage.modelTokens ?? t('app.unavailable'), cost: typeof report.usage.estimatedCostUsd === 'number' ? `$${report.usage.estimatedCostUsd.toFixed(4)}` : t('app.unavailable') })));
278
+ if (report.errors?.length) host.append(el('p', 'report-errors', report.errors.join('\n')));
279
+ for (const item of report.cases || []) {
280
+ const section = el('section', 'case-report'); section.append(el('h3', '', `${item.id} · ${item.title} · ${i18n.status(item.status)}`));
281
+ if (item.reason) section.append(el('p', '', t('app.reason', { reason: item.reason })));
282
+ section.append(el('p', '', t('app.expected'))); const expected = el('ul', 'compact-list'); for (const check of item.expected || []) expected.append(el('li', '', check)); section.append(expected);
283
+ for (const [attemptIndex, attempt] of (item.attempts || []).entries()) {
284
+ section.append(el('p', '', t('app.attempt', { index: attempt.index + 1, status: i18n.status(attempt.status), duration: attempt.durationMs, error: attempt.error ? ` · ${attempt.error.code}: ${attempt.error.message}` : '' })));
285
+ const steps = el('ul', 'compact-list'); for (const step of attempt.steps || []) steps.append(el('li', '', `${i18n.status(step.status)} · ${step.label}${step.error ? ` · ${step.error.code}: ${step.error.message}` : ''}`)); section.append(steps);
286
+ for (const error of attempt.secondaryErrors || []) section.append(el('p', 'report-errors', t('app.secondaryError', { code: error.code, message: error.message })));
287
+ const artifacts = el('div', 'evidence-list');
288
+ for (const [artifactIndex, artifact] of (attempt.artifacts || []).entries()) {
289
+ const row = el('div', 'evidence-item'); row.append(el('span', '', i18n.artifact(artifact.kind)));
290
+ if (artifact.path && ['complete', 'not-required'].includes(artifact.redaction) && /\.(png|jpe?g|webp|txt|json|log)$/i.test(artifact.path)) {
291
+ const query = new URLSearchParams({ runId: app.selectedRun, caseId: item.id, attempt: String(attemptIndex), artifact: String(artifactIndex) });
292
+ const preview = el('a', 'text-button', t('app.viewEvidence')); preview.href = `/api/artifact?${query}`; preview.target = '_blank'; preview.rel = 'noopener noreferrer';
293
+ const save = el('a', 'text-button', t('app.download')); save.href = `/api/artifact?${query}&download=1`; save.download = '';
294
+ row.append(preview, save);
295
+ } else row.append(el('span', 'hint', t('app.evidenceUnavailable')));
296
+ artifacts.append(row);
297
+ }
298
+ section.append(artifacts);
299
+ }
300
+ host.append(section);
301
+ }
302
+ }
303
+ async function saveModel(event) { event.preventDefault(); const form = event.currentTarget; await work(async () => { const values = new FormData(form); const body = { role: form.dataset.role, baseURL: String(values.get('baseURL') || '').trim(), model: String(values.get('model') || '').trim() }; const apiKeyEnv = String(values.get('apiKeyEnv') || '').trim(); if (apiKeyEnv) body.apiKeyEnv = apiKeyEnv; await api('/api/models', body); await refresh(true); noticeKey('app.modelSaved'); }); }
304
+ async function clearModel(role) { await work(async () => { await api('/api/models', { role, clear: true }); await refresh(true); noticeKey('app.modelCleared'); }); }
305
+ async function saveTarget(event) { event.preventDefault(); const form = event.currentTarget; await work(async () => { const values = new FormData(form); await api('/api/targets', { name: String(values.get('name') || '').trim(), url: String(values.get('url') || '').trim(), description: String(values.get('description') || '').trim() }); form.reset(); await refresh(true); noticeKey('app.targetSaved'); }); }
306
+ function renderLanguage() {
307
+ const editedModels = [...document.querySelectorAll('.model-form')].map((form) => ({ role: form.dataset.role, values: Object.fromEntries([...form.querySelectorAll('input')].map((input) => [input.name, input.value])) }));
308
+ const focusedModel = document.activeElement?.closest?.('.model-form');
309
+ const focus = focusedModel ? { role: focusedModel.dataset.role, name: document.activeElement.name, start: document.activeElement.selectionStart, end: document.activeElement.selectionEnd } : null;
310
+ $('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[app.view]) });
311
+ const emptyTarget = $('target-select').querySelector('option[value=""]');
312
+ if (emptyTarget) emptyTarget.textContent = t('scenario.selectTarget');
313
+ modelForms();
314
+ for (const edited of editedModels) {
315
+ const form = [...document.querySelectorAll('.model-form')].find((item) => item.dataset.role === edited.role);
316
+ if (form) for (const [name, value] of Object.entries(edited.values)) { const input = form.elements.namedItem(name); if (input) input.value = value; }
317
+ }
318
+ if (focus) {
319
+ const form = [...document.querySelectorAll('.model-form')].find((item) => item.dataset.role === focus.role);
320
+ const input = form?.elements.namedItem(focus.name);
321
+ if (input) { input.focus(); if (Number.isInteger(focus.start)) input.setSelectionRange(focus.start, focus.end); }
322
+ }
323
+ summarize(app.draft, true);
324
+ renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderReport();
325
+ app.reportMarkdown = '';
326
+ if (app.notice) $('notice').textContent = t(app.notice.key, app.notice.params);
327
+ else if (!$('notice').hidden) notice(''); // A server error has no stable code to translate; the next request uses the selected language.
328
+ }
329
+ function wire() {
330
+ document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => view(button.dataset.view)));
331
+ $('nav-toggle').addEventListener('click', () => { const open = $('sidebar').classList.toggle('nav-open'); $('nav-toggle').setAttribute('aria-expanded', String(open)); });
332
+ $('library-shortcut').addEventListener('click', () => view('library'));
333
+ $('new-draft-btn').addEventListener('click', () => {
334
+ if (app.busy) return;
335
+ app.draft = null; app.script = ''; app.savedFile = ''; app.savedHash = '';
336
+ app.origin = 'none'; app.sourceFlow = ''; app.boundSource = null; app.stale = false; app.dirty = false;
337
+ $('suite-editor').value = ''; $('review-checkbox').checked = false;
338
+ summarize(null); readiness();
339
+ noticeKey('app.newDraftNotice');
340
+ $('suite-editor').focus();
341
+ });
342
+ $('flow-input').addEventListener('input', () => { if ((app.sourceFlow || app.boundSource) && $('flow-input').value.trim() !== app.sourceFlow.trim()) { app.stale = true; app.savedHash = ''; $('review-checkbox').checked = false; readiness(); } });
343
+ $('suite-editor').addEventListener('input', () => { app.dirty = true; app.script = ''; app.savedFile = ''; app.savedHash = ''; if (app.origin === 'none') app.origin = 'manual'; $('review-checkbox').checked = false; summarize(null); readiness(); });
344
+ $('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; targetDescription(); });
345
+ $('plan-btn').addEventListener('click', generate); $('prompt-btn').addEventListener('click', prompt); $('compile-btn').addEventListener('click', compile); $('save-btn').addEventListener('click', save); $('run-btn').addEventListener('click', run); $('stop-btn').addEventListener('click', stop);
346
+ $('global-stop-btn').addEventListener('click', stop);
347
+ $('reconnect-btn').addEventListener('click', () => refresh(true).catch(showError));
348
+ $('script-download').addEventListener('click', () => { if (app.draft && !app.dirty && !app.stale) download(`${app.draft.id || 'flow-qa'}.test.mjs`, app.script, 'text/javascript'); });
349
+ $('report-md').addEventListener('click', downloadReportMarkdown);
350
+ $('report-json').addEventListener('click', () => { if (app.report) download(`${app.selectedRun}.json`, JSON.stringify(app.report, null, 2), 'application/json'); });
351
+ $('target-form').addEventListener('submit', saveTarget);
352
+ $('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
353
+ i18n.onChange(renderLanguage);
354
+ let refreshing = false;
355
+ const poll = async () => { if (document.hidden || refreshing) return; refreshing = true; try { await refresh(false); } catch { /* Connection state offers explicit retry; avoid repeated alerts. */ } finally { refreshing = false; } };
356
+ document.addEventListener('visibilitychange', poll);
357
+ setInterval(poll, 2000);
358
+ }
359
+ i18n.applyStatic(); wire(); readiness(); refresh(true).catch(showError);
360
+ })();
@@ -0,0 +1,56 @@
1
+ import { defaultPreferences, parsePreferences, serializePreferences, validatePreferences, resolvedTheme, effectiveMotion } from '/design-system/preferences.js';
2
+
3
+ const storageKey = 'flowqa-display-preferences';
4
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
5
+ const systemReduced = window.matchMedia('(prefers-reduced-motion: reduce)');
6
+ const panel = document.getElementById('display-preferences');
7
+ const toggle = document.getElementById('theme-toggle');
8
+ const { t, onChange } = window.FlowQAI18n;
9
+ const themeNames = { system: 'display.themeSystemShort', light: 'display.themeLightShort', dark: 'display.themeDarkShort' };
10
+ const accentNames = { cobalt: 'settings.accentCobalt', forest: 'settings.accentForest', coral: 'settings.accentCoral' };
11
+ let preferences;
12
+ try { preferences = parsePreferences(localStorage.getItem(storageKey)); }
13
+ catch { preferences = { ...defaultPreferences }; }
14
+
15
+ function apply() {
16
+ const root = document.documentElement;
17
+ root.dataset.theme = resolvedTheme(preferences.theme, systemDark.matches);
18
+ root.dataset.themePreference = preferences.theme;
19
+ root.dataset.accent = preferences.accent;
20
+ root.dataset.density = preferences.density;
21
+ root.dataset.corners = preferences.corners;
22
+ root.dataset.motion = effectiveMotion(preferences.motion, systemReduced.matches);
23
+ for (const input of panel.querySelectorAll('input[type="radio"]')) {
24
+ input.checked = preferences[input.name] === input.value;
25
+ }
26
+ const theme = t(themeNames[preferences.theme]);
27
+ toggle.textContent = t('display.themeButton', { theme });
28
+ toggle.setAttribute('aria-label', t('display.currentTheme', { theme }));
29
+ document.getElementById('preferences-status').textContent = `${theme} · ${t(accentNames[preferences.accent])}${systemReduced.matches ? ` · ${t('display.motionSystemReduced')}` : ''}`;
30
+ }
31
+
32
+ function update(next) {
33
+ preferences = validatePreferences(next);
34
+ try { localStorage.setItem(storageKey, serializePreferences(preferences)); } catch { /* Current tab still works without storage. */ }
35
+ apply();
36
+ }
37
+
38
+ panel.addEventListener('change', (event) => {
39
+ const input = event.target;
40
+ if (!(input instanceof HTMLInputElement) || input.type !== 'radio' || !Object.hasOwn(defaultPreferences, input.name)) return;
41
+ update({ ...preferences, [input.name]: input.value });
42
+ });
43
+ toggle.addEventListener('click', () => {
44
+ const themes = ['system', 'light', 'dark'];
45
+ update({ ...preferences, theme: themes[(themes.indexOf(preferences.theme) + 1) % themes.length] });
46
+ });
47
+ document.getElementById('reset-preferences').addEventListener('click', () => update({ ...defaultPreferences }));
48
+ window.addEventListener('storage', (event) => {
49
+ if (event.key !== storageKey && event.key !== null) return;
50
+ preferences = parsePreferences(event.newValue);
51
+ apply();
52
+ });
53
+ systemDark.addEventListener('change', apply);
54
+ systemReduced.addEventListener('change', apply);
55
+ onChange(apply);
56
+ apply();