Skip to content

UN-4287 [FEAT] Add the v1 text inputs on one field frame - #2416

Open
athul-rs wants to merge 75 commits into
mainfrom
UN-4287-v1-text-inputs
Open

athul-rs wants to merge 75 commits into
mainfrom
UN-4287-v1-text-inputs

Conversation

@athul-rs

@athul-rs athul-rs commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Frontend v1 text inputs (P1-T03), in frontend/src/v1/components/ui/form/, all drawn by one FieldFrame (border, radius, padding, focus ring and the hover, invalid, disabled and read-only states):
    • Input: height 32, with leading and trailing icons and prefix and suffix text.
    • Textarea, and PromptTextarea: at least 104 tall, grows with its text to a maximum, then scrolls. Enter submits, Shift+Enter breaks the line, and an IME composition never submits.
    • SearchField: Escape clears it when it has a value; on an empty field Escape reaches the dialog or popover around it. A Clear button does the same for the mouse.
    • SecretField: masked by default; Reveal (aria-pressed) and Copy, which copies the real value and reports the result through onCopyResult.
    • Field, Label, HelperText, ErrorText: Field wires the label, aria-describedby and aria-invalid for whichever control it holds; ErrorText is announced (role="alert").
  • lib/compose-refs.ts, which merges a component's own ref with the caller's, including React 19 ref cleanups.

Why

  • P1-T03 (UN-4287): the shared text controls every v1 form uses (P1-T06 form system, settings, prompt cards).

How

  • Built on the design tokens PR UN-4299 [FEAT] v1 design tokens, themes and style isolation (P0-T04) #2399: ui: classes only, no raw colours or lengths. Styling follows the kit's Input Group frames (216:8125) and the search and prompt spec frames (676:9483, 676:9513, 676:9543).
  • SearchField inside a Radix dialog: Radix calls preventDefault on Escape in the capture phase when the dialog chooses not to close. The field ignores that, so a dialog that skips dismissal for targets carrying data-escape-clears gets "Escape clears the field, then closes the dialog", which a test with a real Radix Dialog checks. Only a preventDefault from the caller's own onKeyDown cancels the clear.
  • SecretField sets autoComplete="new-password", data-1p-ignore, data-lpignore and turns off spellcheck, autocapitalise and autocorrect, so password managers and browser spellcheck services leave the value alone; callers cannot override these. Copy on an empty field writes nothing and reports {status: "empty"}.
  • onCopyResult is required: no toast component exists yet (P1-T13 builds it), so the caller raises the toast. This is the seam to swap when it lands.
  • Where field-sizing: content is unsupported (Firefox), PromptTextarea sizes itself from scrollHeight through the --prompt-height custom property, and re-fits after a form reset and, through a ResizeObserver, when it is shown after being hidden or its width changes.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. Everything is new under frontend/src/v1/, which no v0 route renders. No v0 file, dependency or build file changes.

Database Migrations

  • None.

Env Config

  • None.

Relevant Docs

  • Task spec: P1-T03 on the Frontend v1 Confluence space. Designs repo pinned at 83e4fff.

Related Issues or PRs

Dependencies Versions

  • None.

Notes on Testing

  • lint:v1, typecheck:v1 and biome check src/v1 clean. test:v1: 118 vitest tests (83 on the base) and 59 boundary fixtures. The same checks pass on a merge with the newest scaffold commits (65 fixtures).
  • Every behavioural guard was mutation-checked (Shift+Enter, IME composition including Safari's keyCode 229, Escape clear and bubbling, the Radix dialog case, read-only and disabled locks from props and from Field, copy result handling, empty copy, privacy attributes, ref cleanups, the fallback re-fit): removing each one fails a test.
  • Reviewed locally with the team's 19-lens review (no Critical or High; the Mediums are fixed in this branch).

Design questions (no frame answers them)

  1. Input value text and radius: the dev-spec board says 13/18 and radius 8, the kit and search frame 14/20 and radius 10, the prompt frame 13/20 and radius 8. This PR uses 14/20, radius 8.
  2. Dark-mode field fill: the kit names dark:input/30, which no token carries. This PR uses --background in every theme.
  3. Invalid focus ring: --destructive at 50% (one focus treatment) or the kit's raw red at 20%? Disabled fill #e5e5e540 has no token.
  4. Hover and read-only states for inputs, the helper and error text under a control, a required marker, SecretField's Reveal and Copy, and SearchField's Clear have no frame.
  5. PromptTextarea's maximum height has no frame; 240 is a placeholder.

Screenshots

  • None yet; no v1 route renders these components.

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

jaseemjaskp and others added 30 commits October 26, 2026 17:51
…N-4270)

Adds frontend/src/v1/ as an isolated tree for the v1 frontend (P0-T01):

- Folder taxonomy from the spec, a public entry (src/v1/index.ts, imported
  by v0 as `@v1`) and a cloud overlay placeholder.
- src/v1/tsconfig.json: strict, no JS, unused locals/params are errors,
  `@v1` alias only. v0's tsconfig/jsconfig exclude src/v1.
- Biome overrides for the two-way import boundary and v1 bans (no `../`,
  `@/`, prop-types, antd, component CSS; noExplicitAny).
- scripts/check-v1.mjs for what Biome cannot express: TypeScript-only
  files, the folder taxonomy and import contract, PropTypes, `ant-`.
- typecheck:v1, lint:v1 and test:v1 scripts; v0 scripts are unchanged.
- test-fixtures/v1-boundary/ proves each rule fails and passes as
  specified, run with node --test so the v0 vitest suite never sees it.
- .gitignore re-includes src/v1/lib/ (the Python `lib/` rule matched it).

v0 build output, test counts, typecheck and Biome results are unchanged.
…270)

check-v1.mjs:
- Scan comments with a string/template-aware tokenizer; a `/*` or `//`
  inside a string or line comment no longer hides the imports after it.
- Read template-literal specifiers, import.meta.glob and `import(..., opts)`.
- Flag specifiers that leave src/v1 (`/`, `..`, `@/`, `src/`) itself, not
  only via Biome, which skips template literals.
- Check `@import`/`url()` and `ant-` in styles/ CSS; report symlinks;
  catch built `ant-` names; treat .test/.spec .mts/.cts as test files;
  ignore .DS_Store; drop the unused --root option.

biome.json: also ban `..`, `./../**`, `/**`, `antd/**`, `prop-types/**`.
tsconfig: skipLibCheck off so v1's own .d.ts files are checked.
.gitignore: re-include lib/ at any depth under src/v1.

Fixtures: a generated matrix over every folder-contract pair, every import
form, every outside spelling, a symlink case, and cases for each new rule;
tsc cases assert error codes; output matching excludes the command text and
spawn errors fail loudly. Comment and README corrections.
… (UN-4270)

- ci-frontend-lint: the typecheck job also runs `typecheck:v1` and
  `lint:v1`, so the strict v1 tsconfig and the scripts/check-v1.mjs checks
  (folder contract, TypeScript-only files, PropTypes, `ant-`) gate PRs now
  rather than from P0-T10.
- Pages may no longer import app/, test/ or the v1 entry, only the shared
  folders (components, shell, services, stores, hooks, lib, styles,
  plugins). Test files in a page may still import test/. Contract table,
  README and the fixture matrix updated together.
…(UN-4270)

check-v1.mjs: split the comment scanner into small helpers (cognitive
complexity 40 -> under 15), drop the nested ternary and nested template
literal, use String.raw for the import regexes, keep CLI flags in a Set.

Fixture runner:
- Cases and the expected folder contract are parsed text tables instead of
  repeated object literals (the 10% duplication).
- Sorts use an explicit localeCompare comparator.
- The contract matrix is built by a helper.
- Spawns /bin/sh by absolute path and no longer needs `git init` (Biome only
  needs the ignore file). The git check-ignore call keeps a PATH lookup, with
  the reason and a NOSONAR marker.

Fixture cases: re-exports rewritten as small functions and read-only props,
without changing what each case exercises.
…nnot see (UN-4270)

Addresses Greptile review on #2380:
- import.meta.glob([...]): every pattern in the array is checked, not only
  the first, so an allowed first pattern no longer hides a forbidden one.
- check-v1 --code also scans v0 (src/ outside v1, including the cloud
  src/plugins overlay) for template-literal imports, globs, require(),
  @/v1, src/v1, relative paths and CSS @import that reach into v1 other
  than through `@v1`. Biome still covers plain imports.

Fixtures: Greptile's example as a case, a glob array with a forbidden later
pattern, and a generated test of every v0 -> v1 form (denied and allowed).
The v0 scan reports nothing on the current tree.
…i 5.2 both declare containSubset

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… per-browser preference

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… match the v1 Tailwind root

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…heme mapping and the focus utility

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…so biome ci passes

src/v1/styles/tokens.css is written by scripts/build-v1-tokens.mjs and checked byte-for-byte by styles/tokens.test.ts; the formatter would rewrap it and break the check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…sts and on missing variants

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…line styles, CSS imports and !important fail lint

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… and the fixtures

v0's root auto-detected sources across the whole frontend, so the v1 tree, scripts/check-v1-styles.mjs and test-fixtures/ added 12 unprefixed utilities and 8 theme variables to v0's stylesheet. With these three @source not lines the v0 build is byte-identical to the base again (239/239 files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…longer cancel each other

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…our arbitrary values and !important after a variant

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…cope variants, shadows and the root fill to v1

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…the theme test exercises the mode effect

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ge, the pixel escape and the current unprefixed-class rationale

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t on reset, honour a cancelled Escape in dialogs

- Inside a Field, the Field's id now wins over a control's own id, so the
  label always names the control. Set `id` on the Field instead.
- An uncontrolled SearchField returns to its defaultValue when its form
  resets; the reset restored only the DOM value, not the field's state.
- The caller's onKeyDown can cancel the Escape clear inside a Radix dialog.
  The dialog prevents the event first, so the clear now checks whether the
  caller itself called preventDefault.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
frontend.Dockerfile.dockerignore drops **/compose* (meant for docker-compose
files), so src/v1/lib/compose-refs.ts never reached the image and bun run build
failed with ENOENT in the Docker build only. No other tracked frontend source
matches the ignore patterns.

@ritwik-g ritwik-g left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Athul, this is careful work. Escape and IME handling, the ref cleanup and the field-sizing fallback are all thought through, and the tests check real behaviour. #2465 builds on this API and #2466 sits beside it, so I checked the exported API against both PRs, against the merged v1 code (#2447, #2426) and against the P1-T03 spec. A few things should change before it merges:

  1. SecretField copy should use what is already on main. The toast (@v1/lib/notify, #2426) and the shared copy helper (copyText / CopyButton, #2447) are both in this branch's merge base. The spec says Copy "confirms through the toast contract". A required onCopyResult and a second public CopyResult type would mean a breaking change later.
  2. Acceptance and spec: the gallery and the a11y scan. #2447 and #2466 both ship a *.gallery.tsx with a markup snapshot file and an axe pass for each variant, as the stand-in until P1-T01 lands. This PR has neither, and the ticket's acceptance is "Gallery shows default, hover, focus, invalid, disabled and read-only states" plus an accessibility scan.
  3. Contract gaps that #2466 has already worked around. The frame recipe can't be reused by a trigger that is itself the frame, so #2466 copies it as a string. Field gives no label id, and the hook's input type isn't exported.
  4. Two behaviour bugs. A revealed SecretField stays in clear text when it becomes disabled. A controlled SearchField wired with onChange quietly loses Clear and Escape.
  5. Tests use a private setup (render + userEvent.setup() + hand-rolled clipboard and cleanup) instead of the P0-T08 harness (renderV1, which returns user).

The PR also merges cleanly onto today's main, and biome lint --error-on-warnings, check-v1, check-v1-styles, check-v1-icons and tsc all pass on the merge.

…e, label id and frame focus for other controls

- SecretField copies with copyText and confirms through the toast, as CopyButton
  does; onCopy is optional and uses the shared CopyResult. The forked type goes.
- A revealed secret is masked again when the field becomes disabled.
- SearchField takes no onChange, so onValueChange is the one change callback;
  Escape on a non-empty editable field blocks the browser's own search clear.
- form.gallery.tsx lists every variant; markup.test.tsx snapshots them to
  __snapshots__/markup.md and runs axe on each.
- Field gives its label an id; useFieldControl returns it as labelId.
  fieldFrameVariants takes focus: "self" for a trigger that is the frame.
  FieldControlInput and FieldContextValue are exported.
- isEscapeClearTarget(event) for overlays; the rule is in the v1 README.
- Form tests use renderV1; precedence tests for control vs Field state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The native onCopy (a ClipboardEvent handler) stayed in the props and
intersected with the new one, so a caller's callback was typed
ClipboardEvent | CopyResult. Omit the native prop; a test fails typecheck
without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@ritwik-g ritwik-g left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed fc94c05. Thanks for turning these round quickly; seven of the nine threads are fixed, and every new test fails under the matching mutation. test:v1 (472/472), typecheck:v1 and lint:v1 pass.

Two small things are left before this is ready, both inline:

  1. Add "onCopy" to SecretField's Omit; right now a typed callback doesn't compile.
  2. Stop a spread onChange from replacing SearchField's handler; this matters for #2465's {...field} pattern.

Also:

  • The branch now conflicts with main in frontend/src/v1/README.md, where #2433 added a "Version switch and route registry" section in the same place. Please merge main and keep both sections; pre-commit.ci is erroring on the mergeable check.

athul-rs and others added 2 commits November 11, 2026 20:43
The caller's props spread first, so `{...field}` or `{...register()}` cannot
replace the field's onChange. A form reset restores the default only after
the dispatch finishes and only if no listener cancelled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A spread from {...field} or {...register()} carries an onChange the type
leaves out. The field now calls it on typing, and clears through a real
input event so react-hook-form sees the clear too. A controlled spread with
no onValueChange can clear again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@vishnuszipstack vishnuszipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR 2416 — UN-4287 Add the v1 text inputs

Mode: INITIAL, at the PR's current head. Reviewed head ccde261661f6cc2cc3ae88520963457e814b604a, merge base 198c058e37f706384e4e0f58afa86579cedc0f69, base freshened (base_fetch: ok), 27 files, 2335 lines (matches GitHub). Rubric: unstract plugin v2.1.80.
How this was done: a full fan-out (code reviewer, silent-failure hunter, type design, comments, lifecycle and a whole-diff cold read) ran at an earlier head 49fad946b. Before posting, every finding was re-checked against this head. Findings already fixed, already raised by another reviewer, or wrong on a second read were dropped (9 entries covering 14 old findings: 7 already raised by others, 1 fixed in fc94c05, 1 withdrawn). The author's own commits since 49fad946b were reviewed for new defects, and the tests were re-run here.
Security deep-dive: degraded — the vendored security prompt ran once at this head, its filter step inline instead of as independent passes; no findings at confidence >= 8.

Verdict — APPROVE WITH NITS

Summary — Critical: 0 · High: 0 · Medium: 2 · Low: 8 · Lenses run: 19/19 (2 not covered, 8 N/A; see checklist).

Most earlier review points are fixed. One new Medium comes from ccde261 itself: with {...register()} spread on SearchField, the field shows a different value from the one react-hook-form holds after defaultValues, setValue or reset. A throwaway test against this head reproduced it. The other Medium is accessibility: Input's prefix and suffix text is never announced. The Lows are edge cases, a duplicated ref helper, and comments or a test name that claim more than the code does. Tests at this head, from frontend/: the 9 form test files pass (96 tests), as do merge-refs (2/2) and the node fixtures (174/174); typecheck:v1, lint:v1 and biome ci on the 25 changed files pass. The full test:v1 run had 3 failures outside this PR (VersionSwitch ×2, a DiffView axe timeout); they pass when run alone, so they look like load flakes. No real-browser or screen-reader check was run.

Findings

Posted inline on the diff; index:

    1. [Medium] [Lens 3, 7] — A {...register()} spread shows a different value from the one react-hook-form holds — frontend/src/v1/components/ui/form/SearchField.tsx:84
    1. [Medium] [Lens 3] — Input's prefix and suffix text never reaches assistive technology — frontend/src/v1/components/ui/form/Input.tsx:60
    1. [Low] [Lens 3] — A form reset restores the uncontrolled SearchField without calling onValueChange — frontend/src/v1/components/ui/form/SearchField.tsx:106
    1. [Low] [Lens 3] — PromptTextarea submits an empty or whitespace-only prompt on Enter — frontend/src/v1/components/ui/form/PromptTextarea.tsx:110
    1. [Low] [Lens 7, 16] — Field assumes exactly one control reads its context, but neither documents nor checks that — frontend/src/v1/components/ui/form/field-context.ts:358
    1. [Low] [Lens 3] — A spread aria-invalid overrides the resolved invalid state — frontend/src/v1/components/ui/form/Input.tsx:65
    1. [Low] [Lens 2] — composeRefs duplicates Radix's useComposedRefs, and the rename shows the cost — frontend/src/v1/lib/merge-refs.ts:1
    1. [Low] [Lens 16] — useFieldControl's docstring says the control's props win for the description, but the description is joined — frontend/src/v1/components/ui/form/field-context.ts:353
    1. [Low] [Lens 16] — SearchField's comments: misplaced contract block, the ponytail: tag, and a stale canClear note — frontend/src/v1/components/ui/form/SearchField.tsx:33
    1. [Low] [Lens 13] — A Field test name claims a precedence check the test does not make — frontend/src/v1/components/ui/form/Field.test.tsx:59

Lens checklist

  1. Spec & intent — Clean
  2. Architectural fit & precedent — See findings #7
  3. Correctness & edge cases — See findings #1, #2, #3, #4, #6
  4. Security — Not covered — the security deep-dive ran degraded (single pass, filters inline) and found nothing at confidence >= 8, which is not a clean bill
  5. Data integrity & migrations — N/A — no persisted data or schema
  6. Concurrency — Clean
  7. API & contract compatibility — See findings #1, #5
  8. Reliability & resilience — N/A — no external calls
  9. Performance & cost — Clean
  10. Observability — N/A — form controls, no logging or metrics surface
  11. Operational safety — N/A — library components; no flag or rollout config
  12. LLM/agent-specific — N/A — no model, prompt or agent path
  13. Testing — See findings #10
  14. Dependencies & build — N/A — no dependency or lockfile change
  15. Code quality — Clean
  16. Doc & comment accuracy — See findings #5, #8, #9
  17. Cross-document rule consistency — Not covered — merge-result sweep unavailable: git 2.34 here, the script needs 2.38
  18. On-prem deployment experience — N/A — no chart, values or on-prem build path touched
  19. Scaling & workload lifecycle — N/A — no worker or scaling config

Unanchored findings

  • frontend/src/v1/components/ui/form/field-context.ts:363 (outside the diff hunk): 5. [Low] [Lens 7, 16] — Field assumes exactly one control reads its context, but neither documents nor checks that
  • Location: frontend/src/v1/components/ui/form/field-context.ts:L358-L363
  • id: field?.controlId ?? props.id gives every context-consuming control under a Field the same id. Two cases go wrong. A min/max pair in one Field produces duplicate DOM ids, and the label names only the first control. A SearchField in a portalled popover of a Field-wrapped picker takes the picker's id, description and invalid state. The reverse case is just as silent: a child that does not read the context (a native <select>, an antd control) leaves <label for> pointing at nothing, and the error is never linked to it. None of this is mentioned on FieldProps.children or in the README's "Text inputs" section. At a minimum, document "one form control per Field" on children. Better, warn in development when controlId matches zero elements or more than one.
  • Verification: VERIFIED — read at this head; static, not executed in a browser
  • frontend/src/v1/components/ui/form/field-context.ts:357 (outside the diff hunk): 8. [Low] [Lens 16] — useFieldControl's docstring says the control's props win for the description, but the description is joined
  • Location: frontend/src/v1/components/ui/form/field-context.ts:L353-L357
  • The doc says it "Resolves a control's id, description, and state … otherwise the control's own props win, then the Field's." For aria-describedby nothing wins: L364 joins the Field's ids and then the control's ids, and Field.test.tsx "keeps the control's own description after the field's" pins that join. Anyone building a new control on this hook (#2466's Select, for example) will expect an own aria-describedby to replace the Field's. Reword it to: "the description joins the Field's ids, then the control's; for state, the control's own props win, then the Field's."
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Comment on lines +84 to +85
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const current = value ?? uncontrolled;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. [Medium] [Lens 3, 7] — A {...register()} spread shows a different value from the one react-hook-form holds

  • Location: frontend/src/v1/components/ui/form/SearchField.tsx:L84-L85
  • The new comment at L18-L20 says a {...register()} spread works. It works for typing and clearing, but nothing else. register() gives no value, so the field keeps its own state and renders it as value={current}. React-hook-form writes ref.value straight to the DOM for defaultValues, setValue and reset, and the next render puts the stale state back. I reproduced this with a throwaway test at ccde261, using useForm({ defaultValues: { q: "seed" } }). After mount the box shows "" while getValues("q") is "seed". After setValue("q", "set") and a parent re-render the box is "" again, while the form still holds "set". The user then submits a value they cannot see. To fix it, render the input truly uncontrolled when a spread onChange arrives without value: pass defaultValue and track current from input events. If you would rather not support that case, support only the {...field} (useController) pattern and say so. Either way, add tests for defaultValues and for setValue.
  • Verification: VERIFIED — reproduced with a throwaway react-hook-form test against this head (deleted afterwards)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. When a spread onChange arrives without value, SearchField now renders the input uncontrolled (defaultValue, no value) and its state follows the DOM, so react-hook-form's defaultValues, setValue and reset stay on screen and the Clear button and Escape see them. New tests in SearchField.test.tsx use useForm({ defaultValues: { q: "seed" } }) for the default, setValue plus a parent re-render, reset() and reset({ q: "next" }), and a DOM write followed by Escape.

Comment on lines +60 to +79
{prefix ? (
<span data-slot="field-prefix" className={textSlot}>
{prefix}
</span>
) : null}
<input
data-slot="control"
id={control.id}
aria-describedby={control["aria-describedby"]}
aria-invalid={control["aria-invalid"]}
disabled={control.disabled}
readOnly={control.readOnly}
className={cn(fieldControlClass, "ui:h-full", controlClassName)}
{...props}
/>
{suffix ? (
<span data-slot="field-suffix" className={textSlot}>
{suffix}
</span>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. [Medium] [Lens 3] — Input's prefix and suffix text never reaches assistive technology

  • Location: frontend/src/v1/components/ui/form/Input.tsx:L60-L79
  • The prefix and suffix spans are siblings of the <input>. They are not part of its accessible name and they are not in its aria-describedby. A screen-reader user on <Field label="Endpoint"><Input prefix="https://" /></Field> hears "Endpoint, edit text". They never learn that the scheme is supplied, so they type "https://" again. The same goes for a "pages" suffix: nothing tells them the unit. The axe pass on the gallery does not catch this, because it is not a rule violation. Give each span an id derived from control.id, falling back to useId, and append those ids to the input's aria-describedby with joinIds. Then add an Input.test.tsx case asserting that toHaveAccessibleDescription includes the prefix text.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. The prefix and suffix spans get ids derived from the control id (falling back to useId), appended to the input's aria-describedby after the Field's and the control's own ids. Input.test.tsx asserts toHaveAccessibleDescription with the prefix and suffix text, inside a Field and standalone.

Comment on lines +106 to +111
const onReset = (event: Event) => {
timer = setTimeout(() => {
if (!event.defaultPrevented) {
setUncontrolled(defaultValue);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. [Low] [Lens 3] — A form reset restores the uncontrolled SearchField without calling onValueChange

  • Location: frontend/src/v1/components/ui/form/SearchField.tsx:L106-L111
  • Typing, Clear and Escape all report through update(), and so through onValueChange. The reset listener calls setUncontrolled(defaultValue) directly. Take an uncontrolled <SearchField defaultValue="" onValueChange={setQuery} /> inside a form. After the form resets, the box shows defaultValue again, but the caller still filters by the old query, because nothing told it the value changed. The reset tests at SearchField.test.tsx:L162-L186 assert only input.value. Call onValueChange?.(defaultValue) next to the setUncontrolled inside the timer. Read the callback through a ref so the effect's dependencies stay stable. Then extend the reset test to assert the callback.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. The reset handler now calls onValueChange with the restored value next to setUncontrolled, reading the callback through a ref so the effect's dependencies stay stable. The reset test asserts the callback.

Comment on lines +110 to +124
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(event);
if (
event.defaultPrevented ||
event.key !== "Enter" ||
event.shiftKey ||
!onSubmit ||
state.readOnly ||
isComposing(event)
) {
return;
}
event.preventDefault();
onSubmit(event.currentTarget.value);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. [Low] [Lens 3] — PromptTextarea submits an empty or whitespace-only prompt on Enter

  • Location: frontend/src/v1/components/ui/form/PromptTextarea.tsx:L110-L124
  • handleKeyDown calls onSubmit(event.currentTarget.value) on every unmodified Enter, including on an empty box. Every caller then has to filter out "" and " ", or it sends an empty prompt to the model. No test covers the empty case. Either skip onSubmit when value.trim() === "" and add a test, or state in the onSubmit JSDoc (L17) that callers receive empty text and must guard it. Separately, and as a product call: on a touch keyboard there is no Shift+Enter, so a phone user cannot write a multi-line prompt. Consider enterKeyHint="send", or let Enter insert a line break on coarse pointers.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. Enter on an empty or whitespace-only box now does nothing (no submit, no line break), and the onSubmit JSDoc says so. The textarea also sets enterKeyHint="send" when Enter submits. A line break on touch keyboards stays open as a product call.

Comment on lines +65 to +74
<input
data-slot="control"
id={control.id}
aria-describedby={control["aria-describedby"]}
aria-invalid={control["aria-invalid"]}
disabled={control.disabled}
readOnly={control.readOnly}
className={cn(fieldControlClass, "ui:h-full", controlClassName)}
{...props}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6. [Low] [Lens 3] — A spread aria-invalid overrides the resolved invalid state

  • Location: frontend/src/v1/components/ui/form/Input.tsx:L65-L74
  • aria-invalid is not destructured, and {...props} comes after aria-invalid={control["aria-invalid"]}. That happens at Input L69/L73, and the same way at Textarea.tsx:L130/L138. As a result, <Field error="x"><Input aria-invalid={false} /></Field> draws the destructive frame but tells assistive technology the value is valid. A caller who sets only aria-invalid gets the ARIA state without the frame styling. That makes two sources of truth for one state. Destructure aria-invalid and fold it into useFieldControl's invalid, or Omit it from InputProps and TextareaProps.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. Input and Textarea take aria-invalid out of the spread and pass it to useFieldControl, where a true value counts as invalid, so the frame and the ARIA state come from one value. A test covers a spread aria-invalid={true}, and aria-invalid={false} under a Field with an error, for both controls.

Comment on lines +1 to +38
import type { Ref, RefCallback } from "react";

type Cleanup = () => void;

function setRef<T>(ref: Ref<T> | undefined, node: T | null): Cleanup | null {
if (typeof ref === "function") {
const cleanup = ref(node);
return typeof cleanup === "function" ? cleanup : null;
}
if (ref) {
ref.current = node;
}
return null;
}

/*
* One ref callback that feeds every given ref, for a component that needs its
* node and also forwards the caller's ref. It returns a cleanup, so React 19
* calls that instead of the callback with null: a ref that returned its own
* cleanup gets it run, every other ref is set to null.
*/
export function composeRefs<T>(
...refs: readonly (Ref<T> | undefined)[]
): RefCallback<T> {
return (node) => {
const cleanups = refs.map((ref) => setRef(ref, node));
return () => {
refs.forEach((ref, index) => {
const cleanup = cleanups[index];
if (cleanup) {
cleanup();
} else {
setRef(ref, null);
}
});
};
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7. [Low] [Lens 2] — composeRefs duplicates Radix's useComposedRefs, and the rename shows the cost

  • Location: frontend/src/v1/lib/merge-refs.ts:L1-L38
  • radix-ui/internal already exports composeRefs and useComposedRefs. The app imports from that entry point (App.jsx), and the React 19 cleanup behaviour is the same. This PR adds a second ref-merging utility with its own test. 46e4646 then had to rename the file to merge-refs.ts, because **/compose* in the dockerignore dropped it from the image. The export is still called composeRefs, so the file and function names now disagree. Use useComposedRefs(inputRef, ref) from radix-ui/internal in the three controls and delete lib/merge-refs.ts and its test. If internal is considered too unstable, say so in the file so the copy is a recorded decision.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the local helper: radix-ui/internal is not a public entry point and can change in any release. Renamed in fa2f5e979 to mergeRefs to match lib/merge-refs.ts, and the file now records why it is not imported from radix-ui/internal.

Comment on lines +33 to +56
/*
* Escape clears the field when it has a value, and stops there; on an empty
* field Escape bubbles so a dialog or menu can close. Radix layers see Escape
* on the document in the capture phase, before this handler, so an overlay
* that holds a SearchField calls preventDefault in onEscapeKeyDown when
* `isEscapeClearTarget(event)` (keyboard.ts) is true. The clear
* therefore ignores a preventDefault made before it; only a preventDefault
* called from the caller's own onKeyDown cancels it.
*/
/**
* Runs the caller's onKeyDown and reports whether it called preventDefault
* itself. A Radix layer may have prevented the event already, so the event's
* prevented flag alone cannot say who cancelled it.
*/
function callerPreventsDefault(
event: KeyboardEvent<HTMLInputElement>,
onKeyDown: SearchFieldProps["onKeyDown"],
): boolean {
if (!onKeyDown) {
return false;
}
const preventDefault = event.preventDefault;
let called = false;
// ponytail: wraps the method for the call only, then puts the original back.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9. [Low] [Lens 16] — SearchField's comments: misplaced contract block, the ponytail: tag, and a stale canClear note

  • Location: frontend/src/v1/components/ui/form/SearchField.tsx:L33-L56
  • Three comment problems:
  • The /* … */ block at L33-L41 documents SearchField's Escape contract, but it sits directly above the JSDoc of the private callerPreventsDefault, not above export function SearchField. Hover docs on the component show nothing, and the block reads as if it described the helper. Move it to be SearchField's JSDoc.
  • ponytail: at L56 appears nowhere else in the repo and has no defined meaning. Drop the prefix.
  • After ccde261, the L89 comment "A controlled field without onValueChange cannot be cleared" is incomplete. A spread onChange now also makes canClear true (L93). Say "without onValueChange or a spread onChange".
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. The Escape contract is now SearchField's JSDoc, the ponytail: prefix is gone, and the canClear note reads "without onValueChange or a spread onChange".

Comment on lines +59 to +61
it.each(
CONTROLS,
)("takes a %s's own state props over an empty frame", (_, make) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10. [Low] [Lens 13] — A Field test name claims a precedence check the test does not make

  • Location: frontend/src/v1/components/ui/form/Field.test.tsx:L59-L61
  • "takes a %s's own state props over an empty frame" reads as if the control's props are shown to beat something. The body renders each control without a Field and checks only that invalid, readOnly and disabled reach the frame and the native control. The precedence case is the separate it.each at L162. Rename it to something like "marks a standalone %s from its own state props", so a future reader does not take it as precedence coverage.
  • Verification: VERIFIED — read at this head; static, not executed in a browser

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa2f5e979. The test is now "marks a standalone %s from its own state props".

…ption, empty prompt

- SearchField: a spread onChange without a value leaves the input
  uncontrolled, so react-hook-form's defaultValues, setValue and reset
  stay on screen; state follows the DOM for the Clear button and Escape.
- SearchField: a form reset reports the restored value via onValueChange.
- Input: prefix and suffix get ids and join the control's
  aria-describedby.
- Input, Textarea: a true spread aria-invalid folds into the resolved
  invalid state, so frame and ARIA agree.
- PromptTextarea: Enter on an empty or whitespace-only box does nothing;
  enterKeyHint="send" when Enter submits.
- Rename composeRefs to mergeRefs to match lib/merge-refs.ts, and record
  why it is not imported from radix-ui/internal.
- Docs: one form control per Field (FieldProps.children, README);
  useFieldControl docstring; SearchField JSDoc and comments; a Field test
  name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@athul-rs

Copy link
Copy Markdown
Contributor Author

On the two findings from the review summary that have no inline thread:

… clears the search

A form library can write the value straight to the input without a render, which left the escape marker stale and let a dialog close on an Escape the field then cleared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@ritwik-g ritwik-g left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed fa2f5e9. Both react-hook-form patterns now work end to end, and all three checks (test:v1, typecheck:v1, lint:v1) pass. Approving. One edge case is inline (N3): Clear and Escape go stale after a setValue with register(). It can follow in a separate PR.

{...(domOwned ? { defaultValue } : { value: current })}
disabled={disabled}
readOnly={readOnly}
data-escape-clears={clearable ? "" : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After setValue with {...register()}, Clear and data-escape-clears can be stale.

setValue writes the DOM without re-rendering SearchField, so clearable still reflects the old text. I confirmed this with a throwaway test: type "abc", then setValue("q", "").

  • The input is empty, but the Clear button stays visible. Clicking it does nothing: the DOM value is already "", so no onChange fires, and the button never goes away.
  • data-escape-clears stays set. Inside a dialog, the overlay cancels its close on Escape, and the field has nothing to clear, so Escape is swallowed.
  • The reverse case (setValue to a value while the field shows empty) gives no Clear button, and Escape closes the dialog instead of clearing.

Suggested fix: in the DOM-owned mode, re-sync from the DOM on focus (Escape needs focus on the input), and have clear() call update("") when the DOM value is already "". Add a test: register, type, setValue("q", ""), then expect no Clear button and no data-escape-clears. Fine as a follow-up if you'd rather not hold this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 67e26f997, on top of b3d3bf2db.

  • Escape: since b3d3bf2db the data-escape-clears marker only says the field can clear, and both isEscapeClearTarget and the field's own Escape handler read the input's live value. After setValue("q", "") Escape closes the dialog; after setValue("q", "x") on an empty field Escape clears it first.
  • Clear button: the field re-reads the DOM when it takes focus, and the Clear button refocuses the field after clearing, so pressing a stale one hides it. An unfocused field can still show a stale button until it next renders or takes focus, since React gets no signal for a direct DOM write.
  • Tests: your case (type, setValue("q", ""), focus: no Clear button, Escape not claimed), the reverse (setValue("q", "set") on an empty field, focus: Clear shows), and pressing a stale Clear. All three fail with the focus re-read removed.

@athul-rs
athul-rs requested a review from ritwik-g November 12, 2026 13:55
…tton catches up

setValue through a register() spread writes the DOM without a render, which
left the Clear button stale: shown on an emptied field, or missing on a filled
one. The field now re-reads the DOM when it takes focus, and the Clear button
refocuses the field, so pressing a stale one hides it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
⚪ e2e-agent-kv e2e 0 0 0 1 0.1
✅ e2e-api-deployment e2e 3 0 0 0 14.3
✅ e2e-coowners e2e 1 0 0 0 1.3
✅ e2e-etl e2e 1 0 0 0 20.7
✅ e2e-login e2e 2 0 0 0 1.1
✅ e2e-prompt-studio e2e 1 0 0 0 9.4
✅ e2e-smoke e2e 2 0 0 0 0.8
✅ e2e-workflow e2e 1 0 0 0 15.9
✅ frontend-v1-e2e e2e 49 0 0 1 54.2
❌ ui e2e 0 1 0 0 0.0
TOTAL 60 1 0 2 117.9

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • co-owner-manage — covered by e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • pipeline-etl-execute — covered by e2e-etl
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

main-only Stays on main: not backported to a release line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants