diff --git a/.changepacks/changepack_log_Fq8ZpW2nKe4TbR6yLx3Hd.json b/.changepacks/changepack_log_Fq8ZpW2nKe4TbR6yLx3Hd.json new file mode 100644 index 00000000..2877f11e --- /dev/null +++ b/.changepacks/changepack_log_Fq8ZpW2nKe4TbR6yLx3Hd.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Field focus marks the box the user sees. A Figma field component set (input, text field, textarea, search, select, combobox, dropdown) now generates its focus variant as _focusWithin on the container instead of an unreachable _focus (DEVUP_CODEGEN_FIELD_FOCUS_WITHIN). devup_ui_validate reports input-focus-container, input-focus-duplicate-outline and input-focus-layout-shift as browser-required review items (info) and input-focus-outline-removed and input-focus-moved-to-container as warnings; a container's _focusWithin now counts as the field's focus style. devup_runtime_audit adds the input-focus check (planned by auditPlan): every visible input, textarea and select is focused by click and by Tab and fails an inner default outline inside the container, a ring that does not surround the container, focus landing on the container and any size or position change; disabled fields are listed and skipped. Contract identifier field-focus-on-visual-container. Field states ease instead of snapping: a generated field component without a Figma prototype transition gets transition 160ms ease-out on the props its states change (transitionProperty is now written in CSS kebab-case, e.g. border-color), devup_ui_validate reports input-state-transition (info, browser-required), and the input-focus runtime check fails a focus state no transition covers.", + "date": "2026-11-12T00:00:00.000000000Z" +} diff --git a/.changepacks/changepack_log_Uv5MsA9cJr2GhN7wQe4Kp.json b/.changepacks/changepack_log_Uv5MsA9cJr2GhN7wQe4Kp.json new file mode 100644 index 00000000..d3332629 --- /dev/null +++ b/.changepacks/changepack_log_Uv5MsA9cJr2GhN7wQe4Kp.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "The plan gates can no longer be self-confirmed. devup_screen_plan confirm through the tool requires userApproval {via: question-tool | user-message | delegated, userAnswer: the user's verbatim answer, approvedUrls: every URL of the table shown}; a missing or placeholder receipt, or URLs that differ from what confirm builds, confirms nothing and returns the table and expectedUrls to show. devup_project_plan confirm requires the same receipt (without URLs). The receipt is written to the plan and printed in confirm, status and the finalReport (delegated or missing receipts are reported as not reviewed by the user). Draft nextAction carries mustAskUser and the receipt template; the server instructions, the devup and devup-implementation-plan skills and the OpenCode command state the hard stop. Contract identifier user-approved-plan-before-implementation (developmentContract v6). A confirmed plan without the user's receipt writes no page: devup_figma_export page writes return status approval_required until confirm records the approval. Every devup_screen_plan reply and every refused export carry a procedure block (step of 5, the one next call, waitForUser, forbiddenNow) computed from the plan on disk. The site:motion question now recommends Predict from the design (motion source predicted, Devup UI static keyframes), and status adds motion and field-state-motion completion items that stay missing until the sources carry them.", + "date": "2026-11-12T00:00:00.000000000Z" +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index 95f8c6e7..1304b5f9 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -9,3 +9,4 @@ include!("variant/structural_branches.rs"); include!("variant/instance_booleans.rs"); include!("variant/tree_signature.rs"); +include!("variant/field_focus.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs index ad2665e3..29fba0f6 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs @@ -569,19 +569,32 @@ fn transition( .trim_start_matches("EASE_") .to_ascii_lowercase() .replace('_', "-"); - let properties = props + Some(( + format!("{}ms ease-{easing}", layout::format_number(duration)), + css_properties(props), + )) +} + +/// The CSS `transition-property` list for Devup props: `bg` is `background`, +/// and camelCase props are written in kebab-case (`borderColor` -> +/// `border-color`), the only spelling the browser accepts there. +fn css_properties(props: &BTreeSet) -> String { + props .iter() .map(|prop| match prop.as_str() { - "bg" => "background", - "boxShadow" => "box-shadow", - value => value, + "bg" => "background".to_owned(), + value => value.chars().fold(String::new(), |mut out, c| { + if c.is_ascii_uppercase() { + out.push('-'); + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + out + }), }) .collect::>() - .join(","); - Some(( - format!("{}ms ease-{easing}", layout::format_number(duration)), - properties, - )) + .join(",") } fn render_component( diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/field_focus.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/field_focus.rs new file mode 100644 index 00000000..3a4824a0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/field_focus.rs @@ -0,0 +1,48 @@ +// A field component's focus state belongs on its container. + +/// Whether a component set draws a text field, search box, textarea or +/// select by its Figma name. Its frame is the visual container; the focusable +/// control is the native element the implementation renders inside it. +pub(crate) fn is_field_component(name: &str) -> bool { + let name = name.to_lowercase().replace(['-', '_', ' '], ""); + ["input", "textfield", "textarea", "searchbar", "searchbox", "searchfield", "select", "combobox", "dropdown", "입력", "검색"] + .iter() + .any(|word| name.contains(word)) +} + +/// A field's state change (focus ring, invalid border, disabled fill) eases +/// in instead of snapping. Figma prototype reactions on the field win; this is +/// the default when the design names none: short, ease-out, and only on the +/// props the states change, so layout never animates. +pub(crate) const FIELD_STATE_TRANSITION: &str = "160ms ease-out"; + +fn field_transition( + set_name: &str, + designed: Option<(String, String)>, + props: &BTreeSet, +) -> Option<(String, String)> { + if designed.is_some() || props.is_empty() || !is_field_component(set_name) { + return designed; + } + Some((FIELD_STATE_TRANSITION.to_owned(), css_properties(props))) +} + +/// A field container never receives focus itself, so a captured `focus` +/// state on it would never apply: it moves to `_focusWithin`, which matches +/// while the control inside it is focused. A root already rendered as the +/// native control keeps `_focus`. Returns whether anything moved. +fn field_focus_within( + set_name: &str, + root: &Tree, + selectors: &mut BTreeMap>, +) -> Option<()> { + let native = root.props.get("as").is_some_and(|tag| { + ["input", "textarea", "select"].iter().any(|native| tag.contains(native)) + }); + if native || !is_field_component(set_name) { + return None; + } + let focus = selectors.remove("_focus")?; + selectors.entry("_focusWithin".to_owned()).or_default().extend(focus); + Some(()) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs index 6a8d3ec0..f3db1469 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs @@ -248,9 +248,26 @@ pub(super) fn generate_variant_component_set( } } } - let transition = transition(snapshot, &default.node_id, &transition_props); + let set_name = set.typed_view().name().unwrap_or("Component"); + let field_focus = field_focus_within(set_name, &root, &mut selectors).map(|_| Diagnostic { + code: "DEVUP_CODEGEN_FIELD_FOCUS_WITHIN".to_owned(), + message: "The focus variant of this field component is applied with _focusWithin on its container, because the container is not what receives focus: the , +
+ + +
+
after
+
+
+
+
+
+
+ + + diff --git a/harness/runtime-audit/input-focus-checks.mjs b/harness/runtime-audit/input-focus-checks.mjs new file mode 100644 index 00000000..9186d6cb --- /dev/null +++ b/harness/runtime-audit/input-focus-checks.mjs @@ -0,0 +1,316 @@ +// input-focus: a field's focus must mark the box the user sees. +// +// A text field is often an with no border of its own inside a +// bordered container that also holds an icon, prefix or button. Focus lands +// on the , so unless the container reacts to :focus-within, the only +// thing that shows is the browser's default outline inside the box - or +// nothing. Each visible input, textarea and select is focused twice, by mouse +// click and by keyboard Tab, and judged on what the browser actually paints: +// focus stays on the field, an indicator surrounds the visual container, no +// inner outline shows inside that container, nothing moves or resizes, and +// the box eases into its focus state (a CSS transition covers what changes) +// instead of snapping. + +/** Text-entry and choice fields; buttons, checkboxes and the like are not fields. */ +export const FIELD_SELECTOR = [ + 'textarea', 'select', + 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"])' + + ':not([type="file"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"])', +].join(', '); + +const MAX_FIELDS = 20; +const SETTLE_MS = 450; +/** Sub-pixel rounding is not movement. */ +const SHIFT_PX = 0.5; +const PROBE = 'data-devup-focus-probe'; + +/** Paint that marks focus: border, ring, outline, background. */ +const INDICATOR = [ + 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'boxShadow', 'outlineStyle', 'outlineWidth', 'outlineColor', 'backgroundColor', +]; + +const RECT = ['x', 'y', 'width', 'height']; + +/** + * Marks every visible field in scope and finds the box each one is drawn in. + * The container is the field itself when it paints a border, background or + * shadow; otherwise the nearest of three ancestors that does and is at most a + * row taller than the field (an icon or button beside it, not a whole form). + */ +function markFields(root, { selector, probe, max }) { + const transparent = (color) => !color || color === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(color); + const paints = (element) => { + const style = getComputedStyle(element); + const border = ['Top', 'Right', 'Bottom', 'Left'].some((side) => parseFloat(style[`border${side}Width`]) > 0 + && style[`border${side}Style`] !== 'none' && !transparent(style[`border${side}Color`])); + return border || !transparent(style.backgroundColor) || style.boxShadow !== 'none'; + }; + const visible = (element) => { + if (element.checkVisibility && !element.checkVisibility({ visibilityProperty: true, opacityProperty: true })) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + for (const old of document.querySelectorAll(`[${probe}]`)) old.removeAttribute(probe); + const fields = []; + for (const field of [...(root.matches?.(selector) ? [root] : []), ...root.querySelectorAll(selector)]) { + if (fields.length >= max || !visible(field)) continue; + const rect = field.getBoundingClientRect(); + let container = field; + if (!paints(field)) { + let node = field.parentElement; + for (let depth = 0; node && depth < 3 && node !== document.body; depth += 1, node = node.parentElement) { + const box = node.getBoundingClientRect(); + if (box.height > Math.max(rect.height * 2, rect.height + 48)) break; + if (paints(node)) { container = node; break; } + } + } + const index = fields.length; + field.setAttribute(probe, `field-${index}`); + if (container !== field) container.setAttribute(probe, `box-${index}`); + const label = field.labels?.[0]?.textContent?.trim() + || field.getAttribute('aria-label') + || (field.getAttribute('aria-labelledby') && document.getElementById(field.getAttribute('aria-labelledby'))?.textContent?.trim()) + || field.getAttribute('placeholder') || field.name || ''; + fields.push({ + index, + tag: field.tagName.toLowerCase(), + type: field.type ?? '', + name: label.slice(0, 60), + labelled: Boolean(label), + disabled: field.disabled || field.matches(':disabled'), + invalid: field.getAttribute('aria-invalid') === 'true' || field.matches(':invalid'), + separateContainer: container !== field, + containerTag: container.tagName.toLowerCase(), + radius: getComputedStyle(container).borderRadius, + }); + } + return fields; +} + +/** Computed paint and geometry of a field, its container and the next box in flow. */ +function snapshotField({ probe, index, indicator, rect: keys }) { + const field = document.querySelector(`[${probe}="field-${index}"]`); + const container = document.querySelector(`[${probe}="box-${index}"]`) ?? field; + const paint = (element) => { + const style = getComputedStyle(element); + return Object.fromEntries(indicator.map((property) => [property, style[property]])); + }; + const rect = (element) => { + if (!element) return null; + const box = element.getBoundingClientRect(); + return Object.fromEntries(keys.map((key) => [key, box[key] + (key === 'y' ? scrollY : key === 'x' ? scrollX : 0)])); + }; + let neighbour = container.nextElementSibling; + while (neighbour && !neighbour.getClientRects().length) neighbour = neighbour.nextElementSibling; + const active = document.activeElement; + const motion = (element) => { + const style = getComputedStyle(element); + return { property: style.transitionProperty, duration: style.transitionDuration }; + }; + return { + field: { paint: paint(field), rect: rect(field) }, + container: { paint: paint(container), rect: rect(container), motion: motion(container) }, + neighbour: rect(neighbour), + focus: active === field ? 'field' : active === container && container !== field ? 'container' + : active && active !== document.body ? (active.tagName.toLowerCase() + (active.id ? `#${active.id}` : '')) : 'none', + }; +} + +/** Tabbable elements in document order, as Tab walks them (no positive tabindex reordering). */ +function focusBefore({ probe, index }) { + const field = document.querySelector(`[${probe}="field-${index}"]`); + const tabbable = [...document.querySelectorAll('a[href], button, input, select, textarea, iframe, summary, [tabindex], [contenteditable="true"]')] + .filter((element) => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length + && !(element.tagName === 'INPUT' && element.type === 'hidden')); + const at = tabbable.indexOf(field); + document.activeElement?.blur?.(); + if (at > 0) { + const previous = tabbable[at - 1]; + previous.focus({ preventScroll: true }); + // A tab stop that wraps the field is its container taking focus. + if (previous.contains(field)) return 'container'; + return document.activeElement === previous ? 'previous' : 'none'; + } + if (at !== 0) return 'unreachable'; + // The first tab stop: Tab from a blurred element continues after it, so + // navigation restarts from a fixed, focusable-by-script marker at the top. + const start = document.createElement('span'); + start.tabIndex = -1; + start.setAttribute(probe, 'start'); + start.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;overflow:hidden'; + document.body.prepend(start); + start.focus({ preventScroll: true }); + return 'start'; +} + +/** + * Focuses each field by click and by Tab and records paint and geometry + * before and after. Disabled fields are listed, never focused. + * + * @param {import('playwright').Page} page + * @param {import('playwright').Locator} scope + */ +export async function observeInputFocus(page, scope, { timeout = 10_000 } = {}) { + const fields = await scope.evaluate(markFields, { selector: FIELD_SELECTOR, probe: PROBE, max: MAX_FIELDS }); + const snap = (index) => page.evaluate(snapshotField, { probe: PROBE, index, indicator: INDICATOR, rect: RECT }); + const results = []; + for (const field of fields) { + if (field.disabled) { + results.push({ ...field, modes: [] }); + continue; + } + const locator = page.locator(`[${PROBE}="field-${field.index}"]`); + const modes = []; + // Mouse: a click inside the field, where a user would put the caret. + try { + await page.evaluate(() => document.activeElement?.blur?.()); + await locator.scrollIntoViewIfNeeded({ timeout }); + await page.mouse.move(0, 0); + // An eased state needs time to return to rest before it is measured. + await page.waitForTimeout(SETTLE_MS); + const before = await snap(field.index); + await locator.click({ timeout, position: { x: 4, y: 4 }, noWaitAfter: true }); + // The pointer leaves so a hover style is not mistaken for focus. + await page.mouse.move(0, 0); + await page.waitForTimeout(SETTLE_MS); + const after = await snap(field.index); + if (field.tag === 'select') await page.keyboard.press('Escape'); + modes.push({ mode: 'click', before, after }); + } catch (error) { + modes.push({ mode: 'click', error: String(error?.message ?? error).split('\n')[0] }); + } + // Keyboard: focus the tabbable element before the field, then Tab. + try { + await page.mouse.move(0, 0); + const start = await page.evaluate(focusBefore, { probe: PROBE, index: field.index }); + if (start === 'unreachable') { + modes.push({ mode: 'tab', unreachable: true }); + } else { + await page.waitForTimeout(SETTLE_MS); + const before = await snap(field.index); + await page.keyboard.press('Tab'); + await page.waitForTimeout(SETTLE_MS); + const after = await snap(field.index); + await page.evaluate((probe) => document.querySelector(`[${probe}="start"]`)?.remove(), PROBE); + modes.push({ mode: 'tab', before, after, ...(start === 'container' ? { containerTabStop: true } : {}) }); + } + } catch (error) { + modes.push({ mode: 'tab', error: String(error?.message ?? error).split('\n')[0] }); + } + await page.evaluate(() => document.activeElement?.blur?.()); + results.push({ ...field, modes }); + } + await page.evaluate((probe) => { for (const element of document.querySelectorAll(`[${probe}]`)) element.removeAttribute(probe); }, PROBE) + .catch(() => {}); + return { fields: results }; +} + +const visibleOutline = (paint) => paint.outlineStyle !== 'none' && parseFloat(paint.outlineWidth) > 0; +const changed = (before, after) => INDICATOR.filter((property) => before[property] !== after[property]); +const moved = (before, after) => Boolean(before && after) && RECT.some((key) => Math.abs(before[key] - after[key]) > SHIFT_PX); + +/** `borderTopColor` -> `border-top-color`. */ +const kebab = (property) => property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + +/** + * The changed paint properties no CSS transition eases: a property is eased + * when `transition-property` names it, its shorthand (`border-color`, + * `border`, `outline`) or `all`, with a duration above zero. `outline-style` + * is discrete and never needs one. Missing motion data (an older snapshot) + * eases everything, so it never fails on its own. + */ +export function snappedProperties(changedProperties, motion) { + if (!motion) return []; + const names = String(motion.property ?? '').split(',').map((name) => name.trim()); + const durations = String(motion.duration ?? '').split(',').map((value) => parseFloat(value) || 0); + const duration = (at) => durations[at % Math.max(durations.length, 1)] ?? 0; + return changedProperties.filter((property) => property !== 'outlineStyle').filter((property) => { + const css = kebab(property); + const sideless = css.replace(/-(top|right|bottom|left)(?=-|$)/, ''); + return !names.some((name, at) => duration(at) > 0 && (name === 'all' || name === css || name === sideless + || css.startsWith(`${name}-`) || sideless.startsWith(`${name}-`))); + }); +} + +/** Problems with one focus of one field; empty when it passes. */ +export function judgeFocus(field, mode) { + const { before, after } = mode; + if (after.focus === 'container') { + return ['focus moved to the container instead of the field']; + } + if (after.focus !== 'field') { + return [`focus did not reach the field (active: ${after.focus})`]; + } + const problems = mode.containerTabStop ? ['Tab stops on a container wrapping the field before the field itself'] : []; + const boxChange = changed(before.container.paint, after.container.paint); + const innerOutline = field.separateContainer && visibleOutline(after.field.paint) + && (changed(before.field.paint, after.field.paint).length > 0); + const innerRing = field.separateContainer && before.field.paint.boxShadow !== after.field.paint.boxShadow; + if (!field.separateContainer) { + if (boxChange.length === 0) problems.push('no focus indicator: nothing on the field changes'); + } else if (boxChange.length === 0) { + problems.push(innerOutline || innerRing + ? `focus marks only the inner ${field.tag}, not the <${field.containerTag}> box around it` + : `no focus indicator: neither the ${field.tag} nor its <${field.containerTag}> box changes`); + } else if (innerOutline || innerRing) { + problems.push(`a second ${innerOutline ? 'outline' : 'ring'} shows on the inner ${field.tag} inside the <${field.containerTag}> box's focus style`); + } + const snapped = boxChange.length ? snappedProperties(boxChange, after.container.motion) : []; + if (snapped.length) { + problems.push(`the focus state snaps with no transition on ${[...new Set(snapped.map(kebab).map((name) => name.replace(/-(top|right|bottom|left)(?=-|$)/, '')))].join(', ')}`); + } + if (moved(before.container.rect, after.container.rect) || moved(before.field.rect, after.field.rect)) { + problems.push('the field or its box changes size or position when focused'); + } else if (moved(before.neighbour, after.neighbour)) { + problems.push('the element after the field moves when it is focused'); + } + return problems; +} + +/** Fails any field whose focus, by click or by Tab, does not mark its box cleanly. */ +export function evaluateInputFocus(observed) { + const fields = observed.fields ?? []; + const enabled = fields.filter((field) => !field.disabled); + if (fields.length === 0) return { status: 'pass', reason: 'no input, textarea or select to check', measured: { checked: 0, fields: [] } }; + const rows = []; + const failures = []; + const unjudged = []; + for (const field of enabled) { + for (const mode of field.modes) { + const where = `<${field.tag}> "${field.name || field.index}" by ${mode.mode}`; + if (mode.error || mode.unreachable) { + unjudged.push(`${where}: ${mode.error ?? 'not reachable with Tab'}`); + rows.push({ index: field.index, mode: mode.mode, status: 'inconclusive', reason: mode.error ?? 'not reachable with Tab' }); + continue; + } + const problems = judgeFocus(field, mode); + if (problems.length) failures.push(`${where}: ${problems.join(', ')}`); + rows.push({ + index: field.index, mode: mode.mode, status: problems.length ? 'fail' : 'pass', problems, + changed: changed(mode.before.container.paint, mode.after.container.paint), + }); + } + } + const measured = { + checked: enabled.length, + disabled: fields.length - enabled.length, + fields: fields.map(({ modes, ...field }) => ({ + ...field, + modes: rows.filter((row) => row.index === field.index).map(({ index, ...row }) => row), + })), + }; + if (failures.length) { + return { status: 'fail', reason: `${failures.length} focus problem(s): ${failures.slice(0, 4).join('; ')}`, measured }; + } + if (unjudged.length) { + return { status: 'inconclusive', reason: `could not focus: ${unjudged.slice(0, 4).join('; ')}`, measured }; + } + return { + status: 'pass', + reason: `focus by click and Tab marks the box of all ${enabled.length} field(s), eased, with no inner outline or movement` + + (measured.disabled ? `; ${measured.disabled} disabled field(s) not focused` : ''), + measured, + }; +} diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index c7deb8a7..2efca212 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -21,6 +21,7 @@ import { assertSelection } from './selection-checks.mjs'; import { installMotionProbe, runMotionCheck } from './motion-checks.mjs'; import { runDataBindingCheck } from './data-checks.mjs'; import { sourceDigest } from './source-digest.mjs'; +import { evaluateInputFocus, observeInputFocus } from './input-focus-checks.mjs'; import { ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, evaluateInteraction, evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout, evaluateStylesApplied, evaluateNoLowContrastText, evaluateRenderedCompleteness, evaluateViewTransition, summarize, @@ -1056,7 +1057,7 @@ async function runViewport(browser, plan, viewport, out, deadline, references, r record.checks.push({ ...item, ...evaluateClippedText(await clippedText(page, check.subject)) }); continue; } - if (check.kind === 'interaction') continue; + if (check.kind === 'interaction' || check.kind === 'input-focus') continue; if (check.kind === 'capture') { const target = locate(page, check.subject); const count = await target.count(); @@ -1111,6 +1112,21 @@ async function runViewport(browser, plan, viewport, out, deadline, references, r ? { status: 'inconclusive', reason: `the subject could not be ${check.state === 'focus' ? 'focused' : 'hovered'}: ${observed.error.split('\n')[0]}`, measured: {} } : evaluateInteraction(check, observed)) }); } + // Fields focused by click and by Tab, after the interaction checks so + // their hover and focus have been left. + for (const [index, check] of screen.checks.entries()) { + if (check.kind !== 'input-focus' || (check.widths?.length && !check.widths.includes(viewport.width))) continue; + const item = { index, kind: check.kind, label: check.label }; + const scope = check.subject ? locate(page, check.subject) : page.locator('body'); + if ((await scope.count()) !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + const observed = await observeInputFocus(page, scope, { timeout: stepTimeout }).catch((error) => ({ error: String(error?.message ?? error) })); + record.checks.push({ ...item, ...(observed.error + ? { status: 'inconclusive', reason: `fields could not be focused: ${observed.error.split('\n')[0]}`, measured: {} } + : evaluateInputFocus(observed)) }); + } // Links on this page, so a dynamic route ([slug]) can be audited at the // URLs the site itself links to. const anchors = await page.evaluate(() => [...document.querySelectorAll('a[href]')].flatMap((anchor) => { diff --git a/harness/runtime-audit/test/input-focus.test.mjs b/harness/runtime-audit/test/input-focus.test.mjs new file mode 100644 index 00000000..cf17eaa9 --- /dev/null +++ b/harness/runtime-audit/test/input-focus.test.mjs @@ -0,0 +1,163 @@ +// input-focus: a field's focus must mark the box the user sees, by click and +// by Tab, with no inner default outline, no movement and focus left on the +// field. Unit cases pin the judgement; the browser case runs fixtures/fields.html. + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { evaluateInputFocus, judgeFocus, observeInputFocus, snappedProperties } from '../input-focus-checks.mjs'; +import { runPlan } from '../run.mjs'; + +const FIXTURES = new URL('../fixtures/', import.meta.url); + +const paint = (overrides = {}) => ({ + borderTopColor: 'rgb(196, 196, 196)', borderRightColor: 'rgb(196, 196, 196)', borderBottomColor: 'rgb(196, 196, 196)', borderLeftColor: 'rgb(196, 196, 196)', + borderTopWidth: '1px', borderRightWidth: '1px', borderBottomWidth: '1px', borderLeftWidth: '1px', + boxShadow: 'none', outlineStyle: 'none', outlineWidth: '0px', outlineColor: 'rgb(0, 0, 0)', backgroundColor: 'rgb(255, 255, 255)', + ...overrides, +}); +const rect = (x = 0, y = 0, width = 320, height = 40) => ({ x, y, width, height }); +const state = ({ field = {}, container = {}, focus = 'field', fieldRect = rect(28, 0, 280, 40), boxRect = rect(), neighbour = rect(0, 64) } = {}) => ({ + field: { paint: paint({ borderTopWidth: '0px', ...field }), rect: fieldRect }, + container: { paint: paint(container), rect: boxRect }, + neighbour, + focus, +}); +const ring = { borderTopColor: 'rgb(0, 87, 187)', boxShadow: 'rgb(0, 87, 187) 0px 0px 0px 2px' }; +const outline = { outlineStyle: 'auto', outlineWidth: '1px', outlineColor: 'rgb(16, 16, 16)' }; +const wrapped = { tag: 'input', separateContainer: true, containerTag: 'div' }; +const own = { tag: 'input', separateContainer: false, containerTag: 'input' }; + +test('a field in a box passes only when the box shows focus and the field adds no outline', () => { + const before = state(); + assert.deepEqual(judgeFocus(wrapped, { before, after: state({ container: ring }) }), []); + assert.match(judgeFocus(wrapped, { before, after: state({ field: outline }) })[0], /marks only the inner input, not the
box/); + assert.match(judgeFocus(wrapped, { before, after: state({ container: ring, field: outline }) })[0], /second outline shows on the inner input/); + assert.match(judgeFocus(wrapped, { before, after: state() })[0], /no focus indicator: neither the input nor its
box changes/); +}); + +test('a field that is its own box passes on its own ring; focus elsewhere or movement fails', () => { + const before = state({ container: {} }); + assert.deepEqual(judgeFocus(own, { before, after: state({ container: ring }) }), []); + assert.match(judgeFocus(own, { before, after: state() })[0], /nothing on the field changes/); + assert.match(judgeFocus(wrapped, { before, after: state({ container: ring, focus: 'container' }) })[0], /moved to the container/); + assert.match(judgeFocus(wrapped, { before, after: state({ container: ring, focus: 'button' }) })[0], /did not reach the field/); + assert.match(judgeFocus(own, { before, after: state({ container: { ...ring, borderTopWidth: '3px' }, boxRect: rect(0, 0, 324, 44) }) })[0], /changes size or position/); + assert.match(judgeFocus(own, { before, after: state({ container: ring, neighbour: rect(0, 66) }) })[0], /element after the field moves/); + assert.match(judgeFocus(wrapped, { before, after: state({ container: ring }), containerTabStop: true })[0], /Tab stops on a container/); +}); + +test('a focus state that changes paint no transition covers snaps', () => { + const changedProps = ['borderTopColor', 'borderLeftColor', 'boxShadow', 'outlineStyle']; + assert.deepEqual(snappedProperties(changedProps, { property: 'all', duration: '0s' }), ['borderTopColor', 'borderLeftColor', 'boxShadow']); + assert.deepEqual(snappedProperties(changedProps, { property: 'all', duration: '0.16s' }), []); + assert.deepEqual(snappedProperties(changedProps, { property: 'border-color, box-shadow', duration: '0.16s' }), []); + assert.deepEqual(snappedProperties(changedProps, { property: 'border', duration: '0.2s' }), ['boxShadow']); + assert.deepEqual(snappedProperties(changedProps, { property: 'box-shadow, border-color', duration: '0.2s, 0s' }), ['borderTopColor', 'borderLeftColor']); + assert.deepEqual(snappedProperties(changedProps, undefined), []); + const snaps = { mode: 'click', before: state(), after: state({ container: ring }) }; + snaps.after.container.motion = { property: 'all', duration: '0s' }; + assert.match(judgeFocus(wrapped, snaps).join(), /snaps with no transition on border-color, box-shadow/); +}); + +test('the evaluation fails on any field and mode, counts disabled fields, and is inconclusive when nothing could be judged', () => { + const pass = { mode: 'click', before: state(), after: state({ container: ring }) }; + const fail = { mode: 'tab', before: state(), after: state({ field: outline }) }; + const fields = [ + { ...wrapped, index: 0, name: 'Search', modes: [pass, { ...pass, mode: 'tab' }] }, + { ...own, index: 1, name: 'Locked', disabled: true, modes: [] }, + ]; + const ok = evaluateInputFocus({ fields }); + assert.equal(ok.status, 'pass', ok.reason); + assert.match(ok.reason, /all 1 field\(s\).*1 disabled field/); + assert.equal(ok.measured.disabled, 1); + const bad = evaluateInputFocus({ fields: [{ ...fields[0], modes: [pass, fail] }] }); + assert.equal(bad.status, 'fail'); + assert.match(bad.reason, / "Search" by tab: focus marks only the inner input/); + assert.equal(evaluateInputFocus({ fields: [{ ...fields[0], modes: [{ mode: 'tab', unreachable: true }] }] }).status, 'inconclusive'); + assert.equal(evaluateInputFocus({ fields: [] }).status, 'pass'); +}); + +function serve() { + const server = createServer(async (request, response) => { + const name = new URL(request.url, 'http://local').pathname.slice(1); + try { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(await readFile(new URL(name, FIXTURES))); + } catch { + response.writeHead(404).end(); + } + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))); +} + +test('in a real browser, click and Tab focus are judged per field layout', async () => { + const server = await serve(); + const { chromium } = await import('playwright'); + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ viewport: { width: 800, height: 900 } }); + await page.goto(`http://127.0.0.1:${server.address().port}/fields.html`); + const judge = async (id) => evaluateInputFocus(await observeInputFocus(page, page.locator(`#${id}`))); + const results = {}; + for (const id of ['own', 'wrapped-good', 'wrapped-bad', 'snap', 'wrapped-dup', 'textarea', 'states', 'shift', 'moved', 'select']) { + results[id] = await judge(id); + } + const summary = Object.fromEntries(Object.entries(results).map(([id, result]) => [id, result.status])); + assert.deepEqual(summary, { + own: 'pass', 'wrapped-good': 'pass', 'wrapped-bad': 'fail', snap: 'fail', 'wrapped-dup': 'fail', textarea: 'pass', + states: 'pass', shift: 'fail', moved: 'fail', select: 'pass', + }, JSON.stringify(results, null, 2)); + // Both modes ran on every enabled field. + for (const [id, result] of Object.entries(results)) { + for (const field of result.measured.fields.filter((field) => !field.disabled)) { + assert.deepEqual(field.modes.map((mode) => mode.mode), ['click', 'tab'], id); + } + } + assert.equal(results['wrapped-good'].measured.fields[0].containerTag, 'div'); + assert.match(results['wrapped-bad'].reason, /by click: focus marks only the inner input, not the
box/); + assert.match(results['wrapped-bad'].reason, /by tab: focus marks only the inner input/); + assert.match(results['wrapped-dup'].reason, /second outline shows on the inner input/); + assert.match(results.snap.reason, /by click: the focus state snaps with no transition on border-color, box-shadow/); + assert.doesNotMatch(results['wrapped-good'].reason, /snaps/); + assert.match(results.shift.reason, /changes size or position/); + assert.match(results.moved.reason, /Tab stops on a container/); + assert.equal(results.states.measured.disabled, 1); + assert.ok(results.states.measured.fields.some((field) => field.invalid && !field.disabled)); + // The probe leaves no marker behind and the page keeps its values. + assert.equal(await page.locator('[data-devup-focus-probe]').count(), 0); + assert.equal(await page.locator('#states input').first().inputValue(), 'not-an-email'); + } finally { + await browser.close(); + server.close(); + } +}); + +test('the executor runs an input-focus check from a plan at its widths', async () => { + const server = await serve(); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const out = await mkdtemp(join(tmpdir(), 'devup-input-focus-')); + try { + const plan = join(out, 'plan.json'); + await writeFile(plan, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: 'input-focus', plan: { + baseUrl, allowedOrigins: [baseUrl], viewports: [{ width: 800, height: 900 }, { width: 400, height: 900 }], + screens: [{ id: 'fields', routePath: '/fields.html', checks: [ + { kind: 'input-focus', label: 'good box', subject: { css: '#wrapped-good' }, widths: [800] }, + { kind: 'input-focus', label: 'bad box', subject: { css: '#wrapped-bad' }, widths: [800] }, + { kind: 'input-focus', label: 'missing', subject: { css: '#nothing' }, widths: [800] }, + ] }], + } })); + const report = await runPlan({ plan, out, maxMinutes: 2 }); + const [wide, narrow] = report.runs; + assert.deepEqual(wide.screens[0].checks.map((check) => [check.label, check.status]), [ + ['good box', 'pass'], ['bad box', 'fail'], ['missing', 'inconclusive'], + ], JSON.stringify(wide.screens[0].checks, null, 2)); + assert.equal(narrow.screens[0].checks.filter((check) => check.kind === 'input-focus').length, 0); + } finally { + server.close(); + await rm(out, { recursive: true, force: true }); + } +});