Files
suaveplan/openspec/changes/form-detection/specs/form-detection/spec.md
2026-05-27 23:29:53 +01:00

10 KiB

Capability Spec Delta: form-detection (form-detection)

Package: @suaveplan/form-detection Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: DOM Traversal and Form Candidate Discovery

The package SHALL traverse the DOM to discover candidate form containers. A candidate is any <form> element or a loose grouping of related input fields that lack an explicit <form> ancestor (e.g., a <div> containing username and password inputs). The traversal SHALL respect the same-origin iframe boundary and SHALL descend into open shadow roots. The traversal SHALL stop at <template> elements and document fragments that are not attached to the active document.

Scenario: Login form inside <form> is discovered

  • WHEN the document contains a <form> with username and password inputs
  • THEN detectForms returns a FormCandidate with type: 'login' and both fields listed

Scenario: Registration form without <form> ancestor is discovered

  • WHEN the document contains a <div> with email, password, and confirm-password inputs but no wrapping <form>
  • THEN detectForms returns a FormCandidate with type: 'registration' and all three fields listed

Scenario: Same-origin iframe forms are included

  • WHEN the document contains a same-origin <iframe> with a login form
  • THEN detectForms called on the top-level document also returns the iframe's form candidate

Scenario: Cross-origin iframe forms are excluded

  • WHEN the document contains a cross-origin <iframe> with a login form
  • THEN detectForms does not return the iframe's form candidate

Scenario: Open shadow root inputs are discovered

  • WHEN a custom element hosts an open shadow root containing a password input
  • THEN detectForms descends into the shadow root and discovers the input

Verified by: src/detect/detect.test.ts::"discovers login form inside explicit form element" AND src/detect/detect.test.ts::"discovers registration fields without form ancestor" AND src/detect/detect.test.ts::"includes same-origin iframe forms" AND src/detect/detect.test.ts::"excludes cross-origin iframe forms" AND src/detect/detect.spec-conformance.test.ts::"descends into open shadow roots"


Requirement: Field Classification and Label Extraction

The package SHALL classify each discovered field into username, password, new-password, email, otp, credit-card, or unknown. Classification SHALL use a weighted combination of HTML attributes (type, name, id, autocomplete), label keywords, and surrounding text context. The package SHALL also extract the human-readable label for each field from, in order of precedence: associated <label for>, aria-label, aria-labelledby, placeholder, and surrounding text heuristics.

Scenario: Password input classified by type and autocomplete

  • WHEN an <input type="password" autocomplete="current-password"> is encountered
  • THEN classifyField returns password with high confidence

Scenario: Username input classified by name heuristic

  • WHEN an <input type="text" name="user"> is encountered with no autocomplete attribute
  • THEN classifyField returns username based on name-keyword matching

Scenario: OTP input classified by pattern

  • WHEN an <input inputmode="numeric" autocomplete="one-time-code" maxlength="6"> is encountered
  • THEN classifyField returns otp

Scenario: Label extracted from explicit label element

  • WHEN a field has <label for="field-id">Email address</label>
  • THEN extractLabel returns "Email address" with source label-element

Scenario: Label falls back to placeholder

  • WHEN a field has no <label>, aria-label, or aria-labelledby, but has placeholder="Enter your name"
  • THEN extractLabel returns "Enter your name" with source placeholder

Scenario: Ambiguous field returns unknown

  • WHEN an <input type="text"> has no distinguishing attributes, labels, or context
  • THEN classifyField returns unknown and extractLabel returns null

Verified by: src/classify/classify.test.ts::"classifies password input by type and autocomplete" AND src/classify/classify.test.ts::"classifies username input by name heuristic" AND src/classify/classify.test.ts::"classifies OTP input by pattern" AND src/label/label.test.ts::"extracts label from explicit label element" AND src/label/label.test.ts::"falls back to placeholder when no label exists" AND src/classify/classify.test.ts::"returns unknown for ambiguous field"


Requirement: Confidence Scoring

The package SHALL assign every FormCandidate a confidence score in the range [0.0, 1.0]. The score SHALL be a weighted aggregation of structural signals: field-type diversity, presence of expected field combinations, label-keyword matches, and attribute specificity. A candidate with all expected fields for its type and strong label matches SHALL score above 0.8. A candidate with only one weak signal SHALL score below 0.3.

Scenario: Perfect login form scores above 0.8

  • WHEN a form contains a username field, a password field, and a submit button with clear labels
  • THEN the candidate's confidence is greater than or equal to 0.8

Scenario: Weak candidate scores below 0.3

  • WHEN a lone password field exists with no username field and no supporting labels
  • THEN the candidate's confidence is less than or equal to 0.3

Scenario: Score is monotonic with added signals

  • WHEN a second confirming signal (e.g., matching label keyword) is added to a candidate
  • THEN the new confidence score is greater than or equal to the previous score

Scenario: Registration form with confirm password scores higher than without

  • WHEN a registration form contains email, password, and confirm-password fields versus email and password only
  • THEN the three-field candidate scores higher than the two-field candidate

Verified by: src/score/score.test.ts::"perfect login form scores at least 0.8" AND src/score/score.test.ts::"weak candidate scores at most 0.3" AND src/score/score.spec-conformance.test.ts::"confidence is monotonic with added signals" AND src/score/score.test.ts::"registration with confirm password scores higher than without"


Requirement: Mutation Observer Integration

The package SHALL expose FormObserver, a MutationObserver wrapper that re-evaluates form candidates when the DOM changes. The observer SHALL debounce re-evaluation by a configurable interval (default 150 ms) to batch rapid mutations. It SHALL emit onFormsDetected with the full set of current candidates after each debounced pass. Calling disconnect() SHALL stop observation and release the underlying MutationObserver.

Scenario: Observer detects dynamically injected login form

  • WHEN a login form is injected into the DOM after FormObserver.start()
  • THEN onFormsDetected eventually emits a candidate array containing the new login form

Scenario: Observer debounces rapid mutations

  • WHEN ten DOM mutations occur within 50 ms
  • THEN detectForms is called at most twice within that window due to debouncing

Scenario: Observer stops after disconnect

  • WHEN observer.disconnect() is called and then a new form is injected
  • THEN onFormsDetected does not emit again

Scenario: Observer re-evaluates removed forms

  • WHEN a previously detected form is removed from the DOM
  • THEN the next onFormsDetected emission no longer includes that candidate

Verified by: src/observer/observer.test.ts::"detects dynamically injected login form" AND src/observer/observer.test.ts::"debounces rapid mutations" AND src/observer/observer.test.ts::"stops emission after disconnect" AND src/observer/observer.test.ts::"removes candidate when form is removed from DOM"


Requirement: Tree-Shakeable Public API

The package SHALL be fully tree-shakeable. Importing a single function (e.g., detectForms) SHALL not pull in FormObserver or label-extraction utilities unless they are also imported. The package SHALL ship ES modules only and SHALL declare sideEffects: false in package.json. The bundled core (without observer) SHALL remain under 10 KB.

Scenario: Importing detectForms alone does not include observer

  • WHEN a consumer imports only detectForms and bundles with Rollup/Vite
  • THEN the output bundle does not contain FormObserver or MutationObserver references

Scenario: Bundle size under 10 KB

  • WHEN the core entry point (detectForms, classifyField, scoreConfidence) is bundled and minified
  • THEN the resulting gzip size is less than 10 KB

Verified by: src/spec-conformance.test.ts::"tree-shaking excludes unused modules" AND src/spec-conformance.test.ts::"core bundle gzip size is under 10KB"


Requirement: Performance Budget

The package SHALL complete a full detectForms pass on a document with 1,000 DOM nodes in under 16 ms on a modern laptop. The FormObserver debounce timer SHALL use an injectable Clock interface so tests can run without real timers. No synchronous operation within the package SHALL trigger forced synchronous layout (no offsetHeight, getComputedStyle, or similar reads during the detection pass).

Scenario: Detection completes within 16 ms

  • WHEN detectForms is called on a synthetic document with 1,000 nodes
  • THEN the elapsed wall time is less than 16 ms

Scenario: No forced synchronous layout

  • WHEN detectForms runs on a document with nested flex containers
  • THEN no offsetHeight, offsetWidth, getComputedStyle, or getBoundingClientRect calls are made

Scenario: Observer uses injectable clock

  • WHEN FormObserver is instantiated with a custom Clock that advances time programmatically
  • THEN debounced re-evaluation fires exactly when the clock reaches the debounce threshold

Verified by: src/detect/detect.spec-conformance.test.ts::"detects 1000 nodes in under 16ms" AND src/detect/detect.spec-conformance.test.ts::"does not trigger forced synchronous layout" AND src/observer/observer.test.ts::"debounce respects injectable clock"