Repository navigation
Conversation
…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]>
…tore Co-Authored-By: Claude Opus 5.5 <[email protected]>
…in three modes Co-Authored-By: Claude Opus 5.5 <[email protected]>
… match the v1 Tailwind root Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t it Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ercase letters only 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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…sts and on missing variants Co-Authored-By: Claude Opus 5.5 <[email protected]>
…and portal sync Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tEffect 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]>
… arbitrary values 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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[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
left a comment
There was a problem hiding this comment.
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:
- 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 requiredonCopyResultand a second publicCopyResulttype would mean a breaking change later. - Acceptance and spec: the gallery and the a11y scan. #2447 and #2466 both ship a
*.gallery.tsxwith 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. - 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.
Fieldgives no label id, and the hook's input type isn't exported. - Two behaviour bugs. A revealed SecretField stays in clear text when it becomes disabled. A controlled SearchField wired with
onChangequietly loses Clear and Escape. - Tests use a private setup (
render+userEvent.setup()+ hand-rolled clipboard and cleanup) instead of the P0-T08 harness (renderV1, which returnsuser).
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
left a comment
There was a problem hiding this comment.
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:
- Add
"onCopy"to SecretField'sOmit; right now a typed callback doesn't compile. - Stop a spread
onChangefrom 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.
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]>
# Conflicts: # frontend/src/v1/README.md
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
left a comment
There was a problem hiding this comment.
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:
-
- [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
- [Medium] [Lens 3, 7] — A
-
- [Medium] [Lens 3] — Input's prefix and suffix text never reaches assistive technology —
frontend/src/v1/components/ui/form/Input.tsx:60
- [Medium] [Lens 3] — Input's prefix and suffix text never reaches assistive technology —
-
- [Low] [Lens 3] — A form reset restores the uncontrolled SearchField without calling
onValueChange—frontend/src/v1/components/ui/form/SearchField.tsx:106
- [Low] [Lens 3] — A form reset restores the uncontrolled SearchField without calling
-
- [Low] [Lens 3] — PromptTextarea submits an empty or whitespace-only prompt on Enter —
frontend/src/v1/components/ui/form/PromptTextarea.tsx:110
- [Low] [Lens 3] — PromptTextarea submits an empty or whitespace-only prompt on Enter —
-
- [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
- [Low] [Lens 7, 16] — Field assumes exactly one control reads its context, but neither documents nor checks that —
-
- [Low] [Lens 3] — A spread
aria-invalidoverrides the resolved invalid state —frontend/src/v1/components/ui/form/Input.tsx:65
- [Low] [Lens 3] — A spread
-
- [Low] [Lens 2] —
composeRefsduplicates Radix'suseComposedRefs, and the rename shows the cost —frontend/src/v1/lib/merge-refs.ts:1
- [Low] [Lens 2] —
-
- [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
- [Low] [Lens 16] —
-
- [Low] [Lens 16] — SearchField's comments: misplaced contract block, the
ponytail:tag, and a stalecanClearnote —frontend/src/v1/components/ui/form/SearchField.tsx:33
- [Low] [Lens 16] — SearchField's comments: misplaced contract block, the
-
- [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
- [Low] [Lens 13] — A Field test name claims a precedence check the test does not make —
Lens checklist
- Spec & intent — Clean
- Architectural fit & precedent — See findings #7
- Correctness & edge cases — See findings #1, #2, #3, #4, #6
- 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
- Data integrity & migrations — N/A — no persisted data or schema
- Concurrency — Clean
- API & contract compatibility — See findings #1, #5
- Reliability & resilience — N/A — no external calls
- Performance & cost — Clean
- Observability — N/A — form controls, no logging or metrics surface
- Operational safety — N/A — library components; no flag or rollout config
- LLM/agent-specific — N/A — no model, prompt or agent path
- Testing — See findings #10
- Dependencies & build — N/A — no dependency or lockfile change
- Code quality — Clean
- Doc & comment accuracy — See findings #5, #8, #9
- Cross-document rule consistency — Not covered — merge-result sweep unavailable: git 2.34 here, the script needs 2.38
- On-prem deployment experience — N/A — no chart, values or on-prem build path touched
- 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.idgives 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 onFieldProps.childrenor in the README's "Text inputs" section. At a minimum, document "one form control per Field" onchildren. Better, warn in development whencontrolIdmatches 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-describedbynothing wins: L364 joins the Field's ids and then the control's ids, andField.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 ownaria-describedbyto 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
| const [uncontrolled, setUncontrolled] = useState(defaultValue); | ||
| const current = value ?? uncontrolled; |
There was a problem hiding this comment.
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 novalue, so the field keeps its own state and renders it asvalue={current}. React-hook-form writesref.valuestraight to the DOM fordefaultValues,setValueandreset, and the next render puts the stale state back. I reproduced this with a throwaway test at ccde261, usinguseForm({ defaultValues: { q: "seed" } }). After mount the box shows""whilegetValues("q")is"seed". AftersetValue("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 spreadonChangearrives withoutvalue: passdefaultValueand trackcurrentfrom input events. If you would rather not support that case, support only the{...field}(useController) pattern and say so. Either way, add tests fordefaultValuesand forsetValue. - Verification: VERIFIED — reproduced with a throwaway react-hook-form test against this head (deleted afterwards)
There was a problem hiding this comment.
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.
| {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} |
There was a problem hiding this comment.
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
prefixandsuffixspans are siblings of the<input>. They are not part of its accessible name and they are not in itsaria-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 fromcontrol.id, falling back touseId, and append those ids to the input'saria-describedbywithjoinIds. Then add anInput.test.tsxcase asserting thattoHaveAccessibleDescriptionincludes the prefix text. - Verification: VERIFIED — read at this head; static, not executed in a browser
There was a problem hiding this comment.
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.
| const onReset = (event: Event) => { | ||
| timer = setTimeout(() => { | ||
| if (!event.defaultPrevented) { | ||
| setUncontrolled(defaultValue); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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 throughonValueChange. The reset listener callssetUncontrolled(defaultValue)directly. Take an uncontrolled<SearchField defaultValue="" onValueChange={setQuery} />inside a form. After the form resets, the box showsdefaultValueagain, but the caller still filters by the old query, because nothing told it the value changed. The reset tests atSearchField.test.tsx:L162-L186assert onlyinput.value. CallonValueChange?.(defaultValue)next to thesetUncontrolledinside 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
There was a problem hiding this comment.
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.
| 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); | ||
| }; |
There was a problem hiding this comment.
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 handleKeyDowncallsonSubmit(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 skiponSubmitwhenvalue.trim() === ""and add a test, or state in theonSubmitJSDoc (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. ConsiderenterKeyHint="send", or let Enter insert a line break on coarse pointers.- Verification: VERIFIED — read at this head; static, not executed in a browser
There was a problem hiding this comment.
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.
| <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} | ||
| /> |
There was a problem hiding this comment.
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-invalidis not destructured, and{...props}comes afteraria-invalid={control["aria-invalid"]}. That happens at Input L69/L73, and the same way atTextarea.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 onlyaria-invalidgets the ARIA state without the frame styling. That makes two sources of truth for one state. Destructurearia-invalidand fold it intouseFieldControl'sinvalid, orOmitit fromInputPropsandTextareaProps.- Verification: VERIFIED — read at this head; static, not executed in a browser
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| }); | ||
| }; | ||
| }; | ||
| } |
There was a problem hiding this comment.
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/internalalready exportscomposeRefsanduseComposedRefs. 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 tomerge-refs.ts, because**/compose*in the dockerignore dropped it from the image. The export is still calledcomposeRefs, so the file and function names now disagree. UseuseComposedRefs(inputRef, ref)fromradix-ui/internalin the three controls and deletelib/merge-refs.tsand its test. Ifinternalis 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
There was a problem hiding this comment.
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.
| /* | ||
| * 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. |
There was a problem hiding this comment.
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 privatecallerPreventsDefault, not aboveexport 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
onChangenow also makescanCleartrue (L93). Say "without onValueChange or a spread onChange". - Verification: VERIFIED — read at this head; static, not executed in a browser
There was a problem hiding this comment.
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".
| it.each( | ||
| CONTROLS, | ||
| )("takes a %s's own state props over an empty frame", (_, make) => { |
There was a problem hiding this comment.
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,readOnlyanddisabledreach the frame and the native control. The precedence case is the separateit.eachat 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
There was a problem hiding this comment.
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]>
|
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
left a comment
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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
onChangefires, and the button never goes away. data-escape-clearsstays 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 (
setValueto 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.
There was a problem hiding this comment.
Fixed in 67e26f997, on top of b3d3bf2db.
- Escape: since
b3d3bf2dbthedata-escape-clearsmarker only says the field can clear, and bothisEscapeClearTargetand the field's own Escape handler read the input's live value. AftersetValue("q", "")Escape closes the dialog; aftersetValue("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.
…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]>
Frontend Lint Report (Biome)✅ All checks passed! No linting or formatting issues found. |
|
Unstract test resultsPer-group results
Critical paths
|



What
frontend/src/v1/components/ui/form/, all drawn by oneFieldFrame(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, andPromptTextarea: 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 throughonCopyResult.Field,Label,HelperText,ErrorText:Fieldwires the label,aria-describedbyandaria-invalidfor whichever control it holds;ErrorTextis announced (role="alert").lib/compose-refs.ts, which merges a component's own ref with the caller's, including React 19 ref cleanups.Why
How
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).SearchFieldinside a Radix dialog: Radix callspreventDefaulton Escape in the capture phase when the dialog chooses not to close. The field ignores that, so a dialog that skips dismissal for targets carryingdata-escape-clearsgets "Escape clears the field, then closes the dialog", which a test with a real Radix Dialog checks. Only apreventDefaultfrom the caller's ownonKeyDowncancels the clear.SecretFieldsetsautoComplete="new-password",data-1p-ignore,data-lpignoreand 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"}.onCopyResultis 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.field-sizing: contentis unsupported (Firefox),PromptTextareasizes itself fromscrollHeightthrough the--prompt-heightcustom property, and re-fits after a form reset and, through aResizeObserver, 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)
frontend/src/v1/, which no v0 route renders. No v0 file, dependency or build file changes.Database Migrations
Env Config
Relevant Docs
83e4fff.Related Issues or PRs
main. UN-4299 [FEAT] v1 design tokens, themes and style isolation (P0-T04) #2399 (UN-4299, P0-T04 tokens) and UN-4270 [FEAT] v1 scaffold, strict TypeScript and import boundary #2380 (UN-4270 scaffold) have merged, and main is merged in.test:v1.Dependencies Versions
Notes on Testing
lint:v1,typecheck:v1andbiome check src/v1clean.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).keyCode 229, Escape clear and bubbling, the Radix dialog case, read-only and disabled locks from props and fromField, copy result handling, empty copy, privacy attributes, ref cleanups, the fallback re-fit): removing each one fails a test.Design questions (no frame answers them)
dark:input/30, which no token carries. This PR uses--backgroundin every theme.--destructiveat 50% (one focus treatment) or the kit's raw red at 20%? Disabled fill#e5e5e540has no token.Screenshots
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code