@ihyeon/flow-qa 0.1.3 → 0.2.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.
@@ -12,6 +12,9 @@ export const locatorSchema = z.discriminatedUnion('by', [
12
12
  ]);
13
13
  export const stepSchema = z.discriminatedUnion('type', [
14
14
  z.object({ type: z.literal('open'), path: text.refine((v) => v.startsWith('/') && !v.startsWith('//') && !v.includes('\\'), 'Use an app-relative path starting with /.') }).strict(),
15
+ z.object({ type: z.literal('launchApp') }).strict(),
16
+ z.object({ type: z.literal('back') }).strict(),
17
+ z.object({ type: z.literal('swipe'), direction: z.enum(['up', 'down', 'left', 'right']) }).strict(),
15
18
  z.object({ type: z.literal('act'), instruction: text }).strict(),
16
19
  z.object({ type: z.literal('click'), locator: locatorSchema }).strict(),
17
20
  z.object({ type: z.literal('fill'), locator: locatorSchema, value: z.string().max(8192) }).strict(),
@@ -30,6 +33,7 @@ const caseSchema = z.object({
30
33
  }).strict();
31
34
  export const suiteSchema = z.object({
32
35
  schemaVersion: z.literal('flow-qa/suite-1'),
36
+ platform: z.enum(['web', 'mobile']).optional(),
33
37
  id: idSchema,
34
38
  title: text,
35
39
  source: z.object({ file: text, sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().optional(),
@@ -49,6 +53,13 @@ export const suiteSchema = z.object({
49
53
  ctx.addIssue({ code: 'custom', path: ['cases', index, 'requirementIds'], message: 'Unknown requirement ID.' });
50
54
  if (!test.questions.length && !test.steps.some((s) => s.type.startsWith('assert')))
51
55
  ctx.addIssue({ code: 'custom', path: ['cases', index, 'steps'], message: 'A runnable case needs an explicit assertion.' });
56
+ test.steps.forEach((step, stepIndex) => {
57
+ const invalid = suite.platform === 'mobile'
58
+ ? step.type === 'open' || step.type === 'assertUrl'
59
+ : step.type === 'launchApp' || step.type === 'back' || step.type === 'swipe';
60
+ if (invalid)
61
+ ctx.addIssue({ code: 'custom', path: ['cases', index, 'steps', stepIndex, 'type'], message: `The ${step.type} step is not available on ${suite.platform ?? 'web'} suites.` });
62
+ });
52
63
  });
53
64
  });
54
65
  export const modelSchema = z.object({
@@ -57,10 +68,19 @@ export const modelSchema = z.object({
57
68
  apiKeyEnv: z.string().regex(/^[A-Z][A-Z0-9_]*$/).optional(),
58
69
  }).strict();
59
70
  export const plannerSchema = modelSchema;
60
- export const targetSchema = z.object({
71
+ const webTargetSchema = z.object({
61
72
  url: httpURL,
62
73
  description: z.string().max(2000).default(''),
74
+ kind: z.literal('web').optional(),
63
75
  }).strict();
76
+ const mobileTargetSchema = z.object({
77
+ kind: z.literal('mobile'),
78
+ platform: z.enum(['ios', 'android']),
79
+ bundleId: z.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Use a native app ID without spaces or control characters.'),
80
+ device: z.string().trim().min(1).max(200).refine((value) => !/[\p{Cc}\p{Cf}]/u.test(value), 'Device names cannot contain control characters.'),
81
+ description: z.string().max(2000).default(''),
82
+ }).strict();
83
+ export const targetSchema = z.union([webTargetSchema, mobileTargetSchema]);
64
84
  export const configSchema = z.object({
65
85
  schemaVersion: z.literal('flow-qa/config-1'),
66
86
  targets: z.record(idSchema, targetSchema),
@@ -0,0 +1,20 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function targetKind(target) {
3
+ return target.kind === 'mobile' ? 'mobile' : 'web';
4
+ }
5
+ export function targetLabel(target) {
6
+ return target.kind === 'mobile'
7
+ ? `${target.platform} · ${target.bundleId} · ${target.device}`
8
+ : target.url;
9
+ }
10
+ export function targetFingerprint(target) {
11
+ const identity = target.kind === 'mobile'
12
+ ? { kind: 'mobile', platform: target.platform, bundleId: target.bundleId, device: target.device }
13
+ : { kind: 'web', url: target.url };
14
+ return createHash('sha256').update(JSON.stringify(identity)).digest('hex');
15
+ }
16
+ export function reportTarget(name, target) {
17
+ return target.kind === 'mobile'
18
+ ? { name, kind: 'mobile', platform: target.platform, bundleId: target.bundleId, device: target.device }
19
+ : { name, url: target.url, ...(target.kind === 'web' ? { kind: 'web' } : {}) };
20
+ }
@@ -55,10 +55,16 @@
55
55
  $('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
56
56
  if (name === 'history') renderHistory();
57
57
  }
58
+ function suitePlatform() { return app.draft?.platform || 'web'; }
59
+ function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
60
+ function selectedTarget() { return app.displayTargets.find((target) => target.name === $('target-select').value); }
61
+ function targetLabel(target) { return target?.kind === 'mobile' ? `${target.platform === 'ios' ? 'iOS' : 'Android'} · ${target.bundleId} · ${target.device}` : target?.url || ''; }
62
+ function targetDisplay(target) { return `${target?.name || t('app.noTarget')} · ${targetLabel(target)}`; }
63
+ function compatibleTarget() { const target = selectedTarget(); return target && targetPlatform(target) === suitePlatform(); }
58
64
  function readiness() {
59
65
  const active = app.state?.active;
60
66
  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;
67
+ $('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !$('review-checkbox').checked || !compatibleTarget() || !!active || app.busy;
62
68
  $('save-btn').disabled = !ready || app.busy;
63
69
  $('script-download').disabled = !ready;
64
70
  for (const id of ['stop-btn', 'global-stop-btn']) {
@@ -69,9 +75,14 @@
69
75
  $('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
76
  $('compile-btn').disabled = app.stale || app.busy;
71
77
  $('new-draft-btn').disabled = app.busy;
78
+ $('import-btn').disabled = app.busy;
79
+ $('import-json').disabled = app.busy;
80
+ $('import-markdown').disabled = app.busy;
72
81
  $('flow-input').disabled = app.busy;
73
82
  $('suite-editor').disabled = app.busy;
74
83
  $('prompt-btn').disabled = app.busy;
84
+ $('scenario-platform').disabled = app.busy;
85
+ $('mobile-setup-hint').hidden = suitePlatform() !== 'mobile' && $('scenario-platform').value !== 'mobile';
75
86
  $('run-readiness').textContent = t(!app.connected ? 'app.connectionRequired'
76
87
  : active ? (active.status === 'stopping' ? 'app.stopping' : active.kind === 'plan' ? 'app.planning' : 'app.running')
77
88
  : app.stale ? 'app.stale'
@@ -79,6 +90,7 @@
79
90
  : !ready ? 'app.needValidated'
80
91
  : !app.savedFile || !app.savedHash ? 'app.needSaved'
81
92
  : !$('target-select').value ? 'app.needTarget'
93
+ : !compatibleTarget() ? 'app.platformMismatch'
82
94
  : !$('review-checkbox').checked ? 'app.needReview' : 'app.ready');
83
95
  const cases = app.draft?.cases || [];
84
96
  const blocked = cases.filter((item) => item.questions?.length || (item.steps.some((step) => ['act', 'assertAgent'].includes(step.type)) && !app.state?.models?.agent?.keyReady)).length;
@@ -86,7 +98,7 @@
86
98
  const pill = $('draft-status');
87
99
  pill.textContent = t(app.stale ? 'app.regenerate' : app.dirty ? 'app.revalidate' : ready ? 'app.validated' : 'scenario.noDraft');
88
100
  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');
101
+ $('draft-source').textContent = t(app.origin === 'generated' ? 'app.sourceGenerated' : app.origin === 'library' ? (app.sourceFlow ? 'app.sourceLibraryFlow' : 'app.sourceLibrary') : app.origin === 'imported' ? (app.sourceFlow ? 'app.sourceImportedFlow' : 'app.sourceImported') : app.origin === 'manual' ? (app.sourceFlow ? 'app.sourceManualFlow' : 'app.sourceManual') : 'scenario.sourceNone');
90
102
  $('script-preview').textContent = ready ? app.script : t('scenario.scriptPlaceholder');
91
103
  }
92
104
  function locatorText(locator) {
@@ -98,6 +110,9 @@
98
110
  const target = locatorText(step.locator);
99
111
  switch (step.type) {
100
112
  case 'open': return t('app.stepOpen', { path: step.path });
113
+ case 'launchApp': return t('app.stepLaunchApp');
114
+ case 'back': return t('app.stepBack');
115
+ case 'swipe': return t('app.stepSwipe', { direction: step.direction });
101
116
  case 'click': return t('app.stepClick', { target });
102
117
  case 'fill': return t('app.stepFill', { target, value: step.value });
103
118
  case 'assertVisible': return t(step.visible === false ? 'app.stepHidden' : 'app.stepVisible', { target });
@@ -137,23 +152,24 @@
137
152
  function setDraft(suite, script, origin, sourceFlow = '', savedFile = '', savedHash = '') {
138
153
  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
154
  $('suite-editor').value = JSON.stringify(suite, null, 2);
155
+ $('scenario-platform').value = suite.platform || 'web';
140
156
  $('review-checkbox').checked = false;
141
157
  summarize(suite); readiness();
142
158
  }
143
159
  function targetOptions() {
144
160
  const selected = $('target-select').value;
145
- const previousURL = app.displayTargets.find((item) => item.name === selected)?.url;
161
+ const previousFingerprint = app.displayTargets.find((item) => item.name === selected)?.targetFingerprint;
146
162
  app.displayTargets = (app.state?.targets || []).map((target) => ({ ...target }));
147
163
  replace($('target-select'), el('option', '', t('scenario.selectTarget')));
148
164
  $('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); }
165
+ for (const target of app.state?.targets || []) { const option = el('option', '', targetDisplay(target)); option.value = target.name; $('target-select').append(option); }
150
166
  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;
167
+ if (previousFingerprint !== app.displayTargets.find((item) => item.name === selected)?.targetFingerprint) $('review-checkbox').checked = false;
152
168
  targetDescription();
153
169
  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); }
170
+ for (const target of app.state?.targets || []) { const row = el('div', 'settings-entry'); row.append(el('strong', '', target.name), document.createTextNode(` · ${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}`)); list.append(row); }
155
171
  }
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(); }
172
+ function targetDescription() { const target = selectedTarget(); $('target-description').textContent = target ? `${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}` : t('scenario.targetHint'); readiness(); }
157
173
  function modelDescription(role) {
158
174
  const model = app.state?.models?.[role] || {};
159
175
  const explanation = t(role === 'planner' ? 'app.modelPlannerHelp' : 'app.modelAgentHelp');
@@ -192,7 +208,7 @@
192
208
  const runs = app.state?.runs || [];
193
209
  if (!runs.length) { host.append(el('p', 'empty', t(app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty'))); return; }
194
210
  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 })));
211
+ 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: targetDisplay(run.target), passed: run.passedCount ?? 0, total: run.caseCount ?? 0, id: run.runId })));
196
212
  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
213
  }
198
214
  }
@@ -226,16 +242,38 @@
226
242
  const flow = $('flow-input').value.trim();
227
243
  if (!flow) throw localizedError('app.enterFlow');
228
244
  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;
245
+ const request = ++app.planRequest; const platform = $('scenario-platform').value;
230
246
  app.planning = true; renderActive(); noticeKey('app.planStarted');
231
247
  try {
232
- const result = await api('/api/plan', { flow });
248
+ const result = await api('/api/plan', { flow, platform });
233
249
  if (request !== app.planRequest) { noticeKey('app.planCancelledLate'); return; }
234
250
  setDraft(result.suite, result.script, 'generated', flow); noticeKey('app.planComplete');
235
251
  } catch (error) { if (request !== app.planRequest) noticeKey('app.planCancelledDraft'); else throw error; }
236
252
  finally { app.planning = false; await refresh(false).catch(() => {}); }
237
253
  }); }
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'); }); }
254
+ 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, platform: $('scenario-platform').value }); download('flow-qa-prompt.md', result.prompt, 'text/markdown'); noticeKey('app.promptDownloaded'); }); }
255
+ function editableFlow(flow) { return flow.replace(/\r\n?/g, '\n'); }
256
+ async function importFiles() {
257
+ const files = [...$('import-json').files, ...$('import-markdown').files];
258
+ let imported = false;
259
+ await work(async () => {
260
+ const jsonFiles = files.filter((file) => file.name.toLowerCase().endsWith('.json'));
261
+ const markdownFiles = files.filter((file) => file.name.toLowerCase().endsWith('.md'));
262
+ if (files.length > 2 || jsonFiles.length !== 1 || markdownFiles.length > 1 || jsonFiles.length + markdownFiles.length !== files.length) throw localizedError('app.importSelection');
263
+ if (jsonFiles[0].size > 256 * 1024 || (markdownFiles[0] && markdownFiles[0].size > 128 * 1024)) throw localizedError('app.importTooLarge');
264
+ let suite;
265
+ try { suite = JSON.parse(await jsonFiles[0].text()); } catch { throw localizedError('app.invalidJson'); }
266
+ const flow = markdownFiles[0] ? await markdownFiles[0].text() : undefined;
267
+ const body = { suite, ...(flow === undefined ? {} : { flow }) };
268
+ if (new TextEncoder().encode(JSON.stringify(body)).byteLength > 256 * 1024) throw localizedError('app.importTooLarge');
269
+ const result = await api('/api/import', body);
270
+ $('flow-input').value = flow || '';
271
+ setDraft(result.suite, result.script, 'imported', flow || '');
272
+ noticeKey('app.importComplete');
273
+ imported = true;
274
+ });
275
+ if (imported) { $('import-json').value = ''; $('import-markdown').value = ''; }
276
+ }
239
277
  async function compile() { await work(async () => {
240
278
  if (app.stale) throw localizedError('app.compileStale');
241
279
  let suite; try { suite = JSON.parse($('suite-editor').value); } catch { throw localizedError('app.invalidJson'); }
@@ -248,8 +286,8 @@
248
286
  setDraft(result.suite, result.script, app.origin === 'none' ? 'manual' : app.origin, sourceFlow, app.dirty ? '' : app.savedFile, app.dirty ? '' : app.savedHash);
249
287
  noticeKey('app.compileComplete');
250
288
  }); }
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); }); }
289
+ 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.origin === 'imported' ? editableFlow(app.sourceFlow) === $('flow-input').value : 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); }); }
290
+ async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !$('review-checkbox').checked || !compatibleTarget()) throw localizedError('app.runNotReady'); const target = selectedTarget(); const result = await api('/api/run', { suite: app.savedFile, suiteHash: app.savedHash, targetName: target.name, targetFingerprint: target.targetFingerprint }); noticeKey('app.runStarted', { id: result.operationId }); await refresh(false); }); }
253
291
  async function stop() {
254
292
  if (app.stopping || !app.state?.active) return;
255
293
  if (app.state.active.kind === 'plan' && app.planning) app.planRequest += 1;
@@ -271,9 +309,9 @@
271
309
  }
272
310
  function renderReport() {
273
311
  const report = app.report; if (!report) return;
274
- $('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${report.target?.name || ''}`;
312
+ $('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${targetDisplay(report.target)}`;
275
313
  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);
314
+ const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: targetDisplay(report.target) }), t('app.reportStarted', { date: formatDate(report.startedAt) }), t('app.reportFinished', { date: formatDate(report.finishedAt) })]) meta.append(el('span', '', value)); host.append(meta);
277
315
  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
316
  if (report.errors?.length) host.append(el('p', 'report-errors', report.errors.join('\n')));
279
317
  for (const item of report.cases || []) {
@@ -302,7 +340,8 @@
302
340
  }
303
341
  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
342
  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'); }); }
343
+ function targetFields() { const mobile = $('target-kind').value !== 'web'; for (const [id, required] of [['target-url-field', !mobile], ['target-bundle-field', mobile], ['target-device-field', mobile]]) { const field = $(id); field.hidden = !required; field.querySelector('input').required = required; } }
344
+ async function saveTarget(event) { event.preventDefault(); const form = event.currentTarget; await work(async () => { const values = new FormData(form); const kind = String(values.get('kind') || 'web'); const body = { name: String(values.get('name') || '').trim(), description: String(values.get('description') || '').trim() }; if (kind === 'web') body.url = String(values.get('url') || '').trim(); else { body.kind = 'mobile'; body.platform = kind; body.bundleId = String(values.get('bundleId') || '').trim(); body.device = String(values.get('device') || '').trim(); } await api('/api/targets', body); form.reset(); targetFields(); await refresh(true); noticeKey('app.targetSaved'); }); }
306
345
  function renderLanguage() {
307
346
  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
347
  const focusedModel = document.activeElement?.closest?.('.model-form');
@@ -310,6 +349,8 @@
310
349
  $('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[app.view]) });
311
350
  const emptyTarget = $('target-select').querySelector('option[value=""]');
312
351
  if (emptyTarget) emptyTarget.textContent = t('scenario.selectTarget');
352
+ for (const option of $('target-select').options) { const target = app.displayTargets.find((item) => item.name === option.value); if (target) option.textContent = targetDisplay(target); }
353
+ const targetList = $('target-list'); for (const [row, target] of [...targetList.children].map((node, index) => [node, app.displayTargets[index]])) { if (target) row.replaceChildren(el('strong', '', target.name), document.createTextNode(` · ${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}`)); }
313
354
  modelForms();
314
355
  for (const edited of editedModels) {
315
356
  const form = [...document.querySelectorAll('.model-form')].find((item) => item.dataset.role === edited.role);
@@ -339,16 +380,19 @@
339
380
  noticeKey('app.newDraftNotice');
340
381
  $('suite-editor').focus();
341
382
  });
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(); } });
383
+ $('flow-input').addEventListener('input', () => { if (app.origin === 'imported' ? $('flow-input').value !== editableFlow(app.sourceFlow) : (app.sourceFlow || app.boundSource) && $('flow-input').value.trim() !== editableFlow(app.sourceFlow).trim()) { app.stale = true; app.savedHash = ''; $('review-checkbox').checked = false; readiness(); } });
384
+ $('scenario-platform').addEventListener('change', () => { app.planRequest += 1; if (app.draft) { app.stale = true; app.savedHash = ''; } $('review-checkbox').checked = false; readiness(); });
343
385
  $('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
386
  $('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; targetDescription(); });
345
387
  $('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);
388
+ $('import-btn').addEventListener('click', importFiles);
346
389
  $('global-stop-btn').addEventListener('click', stop);
347
390
  $('reconnect-btn').addEventListener('click', () => refresh(true).catch(showError));
348
391
  $('script-download').addEventListener('click', () => { if (app.draft && !app.dirty && !app.stale) download(`${app.draft.id || 'flow-qa'}.test.mjs`, app.script, 'text/javascript'); });
349
392
  $('report-md').addEventListener('click', downloadReportMarkdown);
350
393
  $('report-json').addEventListener('click', () => { if (app.report) download(`${app.selectedRun}.json`, JSON.stringify(app.report, null, 2), 'application/json'); });
351
394
  $('target-form').addEventListener('submit', saveTarget);
395
+ $('target-kind').addEventListener('change', targetFields); targetFields();
352
396
  $('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
353
397
  i18n.onChange(renderLanguage);
354
398
  let refreshing = false;
@@ -23,6 +23,10 @@
23
23
  'scenario.eyebrow': ['01 / SCENARIO', '01 / 시나리오'],
24
24
  'scenario.headingSecond': ['keep the evidence.', '검증을 남깁니다.'],
25
25
  'scenario.intro': ['Define the expected behavior, review the cases, then run them against a selected target.', '기대 동작을 정의하고 케이스를 검토한 뒤, 선택한 대상에서 실행하세요.'],
26
+ 'scenario.platform': ['Case platform', '케이스 플랫폼'],
27
+ 'scenario.web': ['Web', '웹'],
28
+ 'scenario.mobile': ['Mobile app', '모바일 앱'],
29
+ 'scenario.mobileSetup': ['For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.', 'iOS는 Xcode, 시뮬레이터 또는 서명된 기기와 앱 서명을 준비하세요. Android는 Android SDK와 에뮬레이터 또는 승인된 기기를 준비하세요. 설정만으로 기기 접근이 검증되지는 않습니다.'],
26
30
  'scenario.flowTitle': ['Flow to test', '검증할 흐름'],
27
31
  'scenario.flowHelp': ['Describe the action and the result you expect to see.', '어떤 행동에 어떤 결과가 보여야 하는지 적어주세요.'],
28
32
  'scenario.flowLabel': ['Scenario and expected behavior', '시나리오와 기대 동작'],
@@ -32,6 +36,10 @@
32
36
  'scenario.prompt': ['Download generation prompt', '생성 프롬프트 다운로드'],
33
37
  'scenario.reviewTitle': ['Review cases', '케이스 검토'],
34
38
  'scenario.reviewHelp': ['Open each case to inspect its actions and expected results.', '각 케이스를 펼쳐 행동과 기대 결과를 살펴보세요.'],
39
+ 'scenario.importFiles': ['Import case files', '케이스 파일 가져오기'],
40
+ 'scenario.importJson': ['TC JSON', 'TC JSON'],
41
+ 'scenario.importMarkdown': ['Source Markdown (optional)', '원본 Markdown (선택)'],
42
+ 'scenario.importHint': ['Select one TC JSON and, if needed, its source Markdown separately. Import creates a copy to save and review.', 'TC JSON 1개와 필요한 경우 원본 Markdown을 각각 선택하세요. 검토하고 저장할 복사본을 가져옵니다.'],
35
43
  'scenario.noDraft': ['No draft', '초안 없음'],
36
44
  'scenario.summaryEmpty': ['No cases to review yet. Load a local example from the library or generate cases from the scenario.', '아직 검토할 케이스가 없습니다. 보관함의 로컬 예제를 불러오거나 왼쪽 시나리오에서 생성하세요.'],
37
45
  'scenario.sourceNone': ['Source: none', '원본: 없음'],
@@ -98,7 +106,14 @@
98
106
  'settings.savedHere': ['Your display choices are saved in this browser.', '선택한 화면 설정은 이 브라우저에 저장됩니다.'],
99
107
  'settings.reset': ['Reset to defaults', '기본값으로 재설정'],
100
108
  'settings.targets': ['Run targets', '실행 대상'],
101
- 'settings.targetsHelp': ['Add an allowed HTTPS or local address.', '허용된 HTTPS 주소 또는 로컬 주소를 등록하세요.'],
109
+ 'settings.targetsHelp': ['Add a web address or a native app and device.', '웹 주소 또는 네이티브 앱과 기기를 등록하세요.'],
110
+ 'settings.targetType': ['Target type', '대상 유형'],
111
+ 'settings.ios': ['iOS app', 'iOS 앱'],
112
+ 'settings.android': ['Android app', 'Android 앱'],
113
+ 'settings.targetUrl': ['URL', 'URL'],
114
+ 'settings.bundleId': ['App bundle ID / package name', '앱 번들 ID / 패키지 이름'],
115
+ 'settings.device': ['Device name or ID', '기기 이름 또는 ID'],
116
+ 'settings.targetSafety': ['Saving a target does not connect a device or install an app.', '대상을 저장해도 기기가 연결되거나 앱이 설치되지는 않습니다.'],
102
117
  'settings.targetName': ['Target name', '대상 이름'],
103
118
  'settings.targetNamePlaceholder': ['e.g. local-demo', '예: local-demo'],
104
119
  'settings.description': ['Description', '설명'],
@@ -126,6 +141,7 @@
126
141
  'app.needValidated': ['Validated cases required.', '검증된 케이스가 필요합니다.'],
127
142
  'app.needSaved': ['Save cases or reload them from the library before running.', '실행 전 케이스를 저장하거나 보관함에서 다시 불러오세요.'],
128
143
  'app.needTarget': ['Select a run target.', '실행 대상을 선택하세요.'],
144
+ 'app.platformMismatch': ['The case platform and target type must match.', '케이스 플랫폼과 대상 유형이 일치해야 합니다.'],
129
145
  'app.needReview': ['Review the contents, then select the checkbox.', '내용 검토 후 확인란을 선택하세요.'],
130
146
  'app.ready': ['Ready to run.', '실행할 준비가 됐습니다.'],
131
147
  'app.caseReadiness': ['{total} total · {ready} runnable · {blocked} blocked before run{hint}', '전체 {total}개 · 실행 가능 {ready}개 · 사전 차단 {blocked}개{hint}'],
@@ -136,6 +152,8 @@
136
152
  'app.sourceGenerated': ['Source: AI generated from this scenario · JSON can be reviewed', '원본: 현재 시나리오에서 AI 생성 · JSON 검토 가능'],
137
153
  'app.sourceLibraryFlow': ['Source: saved cases and linked scenario', '원본: 저장된 케이스와 연결된 시나리오'],
138
154
  'app.sourceLibrary': ['Source: saved cases · no linked scenario', '원본: 저장된 케이스 · 연결된 시나리오 없음'],
155
+ 'app.sourceImportedFlow': ['Source: imported copy with source Markdown · save and review required', '원본: Markdown과 함께 가져온 복사본 · 저장 및 검토 필요'],
156
+ 'app.sourceImported': ['Source: imported case copy · save and review required', '원본: 가져온 케이스 복사본 · 저장 및 검토 필요'],
139
157
  'app.sourceManualFlow': ['Source: current scenario · manually written JSON', '원본: 현재 시나리오 · 직접 작성한 JSON'],
140
158
  'app.sourceManual': ['Source: manually written JSON · no linked scenario', '원본: 직접 작성한 JSON · 연결된 시나리오 없음'],
141
159
  'app.locatorRole': ['role', '역할'],
@@ -144,6 +162,9 @@
144
162
  'app.locatorTestId': ['test ID', '테스트 ID'],
145
163
  'app.locatorName': ['name “{name}”', '이름 “{name}”'],
146
164
  'app.stepOpen': ['Open page: {path}', '페이지 열기: {path}'],
165
+ 'app.stepLaunchApp': ['Launch app', '앱 실행'],
166
+ 'app.stepBack': ['Go back', '뒤로 가기'],
167
+ 'app.stepSwipe': ['Swipe {direction}', '{direction} 방향으로 스와이프'],
147
168
  'app.stepClick': ['Click: {target}', '클릭: {target}'],
148
169
  'app.stepFill': ['Fill: {target} → “{value}”', '입력: {target} → “{value}”'],
149
170
  'app.stepVisible': ['Expected: {target} visible', '기대 결과: {target} 표시됨'],
@@ -196,8 +217,8 @@
196
217
  'app.connected': ['Local server connected', '로컬 서버 연결됨'],
197
218
  'app.refreshSettings': ['Refresh settings', '설정 새로고침'],
198
219
  'app.runtimeDisconnected': ['Connect to the server to check the runtime.', '서버 연결 후 실행 환경을 확인할 수 있습니다.'],
199
- 'app.runtimeInstalled': ['Browser installed · Fixed-action cases can run without a model.', '브라우저 설치 확인됨 · 고정 조작 케이스는 모델 없이 실행할 수 있습니다.'],
200
- 'app.runtimeMissing': ['Browser installation required. Run {command} in a terminal. Automatic installation on first run can fail depending on network access.', '브라우저 설치가 필요합니다. 터미널에서 {command}을 실행하세요. 첫 실행의 자동 설치는 네트워크 환경에 따라 실패할 수 있습니다.'],
220
+ 'app.runtimeInstalled': ['Web browser installed · Fixed-action web cases can run without a model. Mobile device access is checked during a run.', '웹 브라우저 설치 확인됨 · 고정 조작 웹 케이스는 모델 없이 실행할 수 있습니다. 모바일 기기 접근은 실행 중 확인합니다.'],
221
+ 'app.runtimeMissing': ['Web browser installation required for web runs. Run {command} in a terminal. Mobile runs check device access at runtime.', '웹 실행에는 브라우저 설치가 필요합니다. 터미널에서 {command}을 실행하세요. 모바일 실행은 실행 중 기기 접근을 확인합니다.'],
201
222
  'app.plannerUnconfigured': ['AI planner is not connected. Configure a model in Connections or download the prompt.', 'AI 플래너가 연결되지 않았습니다. 연결 설정에서 모델을 지정하거나 프롬프트를 내려받으세요.'],
202
223
  'app.plannerKeyMissing': ['The configured key environment variable is missing on the server. Add it and restart the server.', '설정한 키 환경 변수가 서버에 없습니다. 키를 주입한 뒤 서버를 재시작하세요.'],
203
224
  'app.plannerCliOnly': ['This model can be used from the CLI. Connect an OpenAI-compatible model to generate cases in the UI.', '이 모델은 CLI에서 사용할 수 있습니다. UI에서 생성하려면 OpenAI 호환 모델을 연결하세요.'],
@@ -213,6 +234,9 @@
213
234
  'app.promptDownloaded': ['Prompt downloaded. Paste the generated JSON and validate it.', '프롬프트를 다운로드했습니다. 생성된 JSON을 붙여 넣고 검증하세요.'],
214
235
  'app.compileStale': ['Scenario changed. Generate cases again or reload them from the library.', '시나리오가 변경됐습니다. 다시 생성하거나 보관함의 케이스를 불러오세요.'],
215
236
  'app.invalidJson': ['Check the JSON syntax. Use double quotes around property names and strings, and check commas and brackets.', 'JSON 구문을 확인하세요. 속성 이름과 문자열은 큰따옴표로 감싸고 쉼표·괄호를 확인해 주세요.'],
237
+ 'app.importSelection': ['Select exactly one TC JSON and at most one source Markdown file.', 'TC JSON 파일 1개와 원본 Markdown 파일 최대 1개를 선택하세요.'],
238
+ 'app.importTooLarge': ['The selected files are too large to import. JSON must be at most 256 KiB, Markdown at most 128 KiB, and the request at most 256 KiB.', '선택한 파일이 너무 큽니다. JSON은 최대 256 KiB, Markdown은 최대 128 KiB, 요청 전체는 최대 256 KiB여야 합니다.'],
239
+ 'app.importComplete': ['Case copy imported. Review the cases and source, then save before running.', '케이스 복사본을 가져왔습니다. 케이스와 원본을 검토한 뒤 실행 전에 저장하세요.'],
216
240
  'app.compileComplete': ['JSON validated and script generated. The linked source scenario was kept.', 'JSON 검증과 스크립트 생성이 완료됐습니다. 연결된 원본 시나리오는 유지됩니다.'],
217
241
  'app.savedSuite': ['Cases saved to a separate file: {file}. Review them again before running.', '케이스를 별도 파일로 저장했습니다: {file}. 실행 전 내용을 다시 검토하세요.'],
218
242
  'app.runNotReady': ['Save or reload cases, select a target, and review the contents before running.', '케이스 저장 또는 다시 불러오기, 대상 선택, 직접 검토가 필요합니다.'],
@@ -48,6 +48,8 @@
48
48
  <section class="work-section flow-section" aria-labelledby="flow-title">
49
49
  <div class="section-head"><span class="section-number">01</span><div><h2 id="flow-title" data-i18n="scenario.flowTitle">Flow to test</h2><p data-i18n="scenario.flowHelp">Describe the action and the result you expect to see.</p></div></div>
50
50
  <div class="section-body">
51
+ <label for="scenario-platform" class="field-label" data-i18n="scenario.platform">Case platform</label>
52
+ <select id="scenario-platform"><option value="web" data-i18n="scenario.web">Web</option><option value="mobile" data-i18n="scenario.mobile">Mobile app</option></select>
51
53
  <label for="flow-input" class="field-label" data-i18n="scenario.flowLabel">Scenario and expected behavior</label>
52
54
  <textarea id="flow-input" rows="11" placeholder="For example: When a user enters a title and selects Add, the same title appears in the list. An empty title shows an error." data-i18n-placeholder="scenario.flowPlaceholder"></textarea>
53
55
  <p class="hint" data-i18n="scenario.flowHint">Generate cases when an AI planner is ready. You can also download the prompt and write them yourself without a model connection.</p>
@@ -59,6 +61,8 @@
59
61
  <div class="section-head"><span class="section-number">02</span><div><h2 id="draft-title" data-i18n="scenario.reviewTitle">Review cases</h2><p data-i18n="scenario.reviewHelp">Open each case to inspect its actions and expected results.</p></div><span id="draft-status" class="status-pill" data-i18n="scenario.noDraft">No draft</span></div>
60
62
  <div class="section-body">
61
63
  <div class="draft-topline"><span id="draft-source" data-i18n="scenario.sourceNone">Source: none</span><button type="button" class="text-button" id="library-shortcut"><span data-i18n="scenario.fromLibrary">Load from library</span><span aria-hidden="true">↗</span></button></div>
64
+ <div class="draft-actions import-actions"><label for="import-json"><span data-i18n="scenario.importJson">TC JSON</span><input id="import-json" type="file" accept=".json"></label><label for="import-markdown"><span data-i18n="scenario.importMarkdown">Source Markdown (optional)</span><input id="import-markdown" type="file" accept=".md"></label><button id="import-btn" type="button" class="button quiet" data-i18n="scenario.importFiles">Import case files</button></div>
65
+ <p class="hint" data-i18n="scenario.importHint">Select one TC JSON and, if needed, its source Markdown separately. Import creates a copy to save and review.</p>
62
66
  <div id="draft-summary" class="summary" aria-live="polite"><p class="summary-empty" data-i18n="scenario.summaryEmpty">No cases to review yet. Load a local example from the library or generate cases from the scenario.</p></div>
63
67
  <div class="draft-actions"><button id="save-btn" type="button" class="button" data-i18n="scenario.save">Save cases separately</button><span data-i18n="scenario.saveHint">Only saved cases can be run.</span></div>
64
68
  <details id="technical-details" class="technical-details">
@@ -76,7 +80,7 @@
76
80
  <section class="execution-section" aria-labelledby="run-title">
77
81
  <div class="section-head"><span class="section-number">03</span><div><h2 id="run-title" data-i18n="scenario.runTitle">Review, then run</h2><p data-i18n="scenario.runHelp">Select a target and confirm you have reviewed the cases.</p></div></div>
78
82
  <div class="execution-grid">
79
- <div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p></div>
83
+ <div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p><p id="mobile-setup-hint" class="hint" hidden data-i18n="scenario.mobileSetup">For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.</p></div>
80
84
  <div class="execution-confirm"><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
81
85
  </div>
82
86
  </section>
@@ -106,7 +110,7 @@
106
110
  <div class="hds-footer"><p id="preferences-status" aria-live="polite">Your display choices are saved in this browser.</p><button id="reset-preferences" type="button" data-i18n="settings.reset">Reset to defaults</button></div>
107
111
  </section>
108
112
  <div class="settings-grid">
109
- <section class="settings-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="settings.targets">Run targets</h2><p data-i18n="settings.targetsHelp">Add an allowed HTTPS or local address.</p></div></div><form class="section-body form-stack" id="target-form"><label><span data-i18n="settings.targetName">Target name</span><input name="name" required placeholder="e.g. local-demo" data-i18n-placeholder="settings.targetNamePlaceholder" pattern="[A-Za-z0-9][A-Za-z0-9_-]*"></label><label>URL<input name="url" type="url" required placeholder="https://example.com"></label><label><span data-i18n="settings.description">Description</span><textarea name="description" rows="3" placeholder="What this target is for" data-i18n-placeholder="settings.descriptionPlaceholder"></textarea></label><button class="button primary" type="submit" data-i18n="settings.saveTarget">Save target</button><div id="target-list" class="settings-list"></div></form></section>
113
+ <section class="settings-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="settings.targets">Run targets</h2><p data-i18n="settings.targetsHelp">Add a web address or a native app and device.</p></div></div><form class="section-body form-stack" id="target-form"><label><span data-i18n="settings.targetName">Target name</span><input name="name" required placeholder="e.g. local-demo" data-i18n-placeholder="settings.targetNamePlaceholder" pattern="[A-Za-z0-9][A-Za-z0-9_\-]*"></label><label><span data-i18n="settings.targetType">Target type</span><select name="kind" id="target-kind"><option value="web" data-i18n="scenario.web">Web</option><option value="ios" data-i18n="settings.ios">iOS app</option><option value="android" data-i18n="settings.android">Android app</option></select></label><label id="target-url-field"><span data-i18n="settings.targetUrl">URL</span><input name="url" type="url" required placeholder="https://example.com"></label><label id="target-bundle-field" hidden><span data-i18n="settings.bundleId">App bundle ID / package name</span><input name="bundleId" autocomplete="off"></label><label id="target-device-field" hidden><span data-i18n="settings.device">Device name or ID</span><input name="device" autocomplete="off"></label><label><span data-i18n="settings.description">Description</span><textarea name="description" rows="3" placeholder="What this target is for" data-i18n-placeholder="settings.descriptionPlaceholder"></textarea></label><button class="button primary" type="submit" data-i18n="settings.saveTarget">Save target</button><p class="hint" data-i18n="settings.targetSafety">Saving a target does not connect a device or install an app.</p><div id="target-list" class="settings-list"></div></form></section>
110
114
  <section class="settings-section"><div class="section-head"><span class="section-number">02</span><div><h2 data-i18n="settings.models">Model connections</h2><p data-i18n="settings.modelsHelp">Manage planner and run agent connections separately.</p></div></div><div class="section-body"><p class="hint" data-i18n="settings.modelsHint">Do not enter or store API key values here. Specify only the name of an environment variable in the server process.</p><div id="model-forms"></div></div></section>
111
115
  </div>
112
116
  </section>
@@ -108,6 +108,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
108
108
  .blocked-title { margin-top: 21px !important; color: var(--accent-text); }
109
109
  .draft-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; margin: calc(var(--space-factor) * 16px) 0; }
110
110
  .draft-actions span { color: var(--muted); font-size: 12px; }
111
+ .import-actions label { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
112
+ .import-actions input { min-width: 0; max-width: 100%; }
111
113
  .technical-details { border-top: 1px solid var(--line); }
112
114
  .technical-details > summary { display: flex; justify-content: space-between; gap: 10px; padding: 15px 0; color: var(--ink); font-size: 13px; font-weight: 750; cursor: pointer; list-style: none; }
113
115
  .technical-details > summary::-webkit-details-marker { display: none; }
@@ -148,7 +150,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
148
150
  .preferences-section .hds-group { padding-block: calc(var(--space-factor) * 22px); }
149
151
  .settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(25px, 3vw, 44px); }
150
152
  .form-stack, .model-form { display: grid; gap: calc(var(--space-factor) * 15px); }
151
- .form-stack label input, .form-stack label textarea, .model-form label input { margin-top: 8px; }
153
+ .form-stack label input, .form-stack label select, .form-stack label textarea, .model-form label input { margin-top: 8px; }
152
154
  .form-stack > button { justify-self: start; }
153
155
  .settings-list { margin-top: 11px; border-top: 1px solid var(--line); }
154
156
  .settings-list:empty { display: none; }