Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
0c5bacb
feat(frontend): v1 scaffold, strict TypeScript and import boundary (U…
jaseemjaskp Oct 26, 2026
5c55a7c
fix(frontend): close v1 guardrail bypasses found in self-review (UN-4…
jaseemjaskp Oct 26, 2026
87c803c
feat(frontend): run v1 checks in CI and tighten what pages may import…
jaseemjaskp Oct 27, 2026
f86de0b
refactor(frontend): address SonarCloud findings on the v1 guardrails …
jaseemjaskp Oct 27, 2026
67be34b
refactor(frontend): split the contract-matrix builder to cut its comp…
jaseemjaskp Oct 27, 2026
81e15b1
fix(frontend): check every glob pattern and v0 -> v1 imports Biome ca…
jaseemjaskp Oct 27, 2026
7e541ab
UN-4299 [MISC] Turn on skipLibCheck for v1: vitest 3.2 and @types/cha…
praveen-formido Oct 28, 2026
6f22f62
UN-4299 [FEAT] Add the v1 theme model: three modes, follow-system and…
praveen-formido Oct 28, 2026
7aa5bf4
UN-4299 [FEAT] Add the v1 portal container and the theme preference s…
praveen-formido Oct 28, 2026
0c85cd6
UN-4299 [FEAT] Add the v1 token source, the generator and tokens.css …
praveen-formido Oct 28, 2026
6393d31
UN-4299 [FIX] Prefix the spacing utilities in the token table so they…
praveen-formido Oct 28, 2026
4a05a26
UN-4299 [FEAT] Add cn() configured for the v1 utility prefix
praveen-formido Oct 28, 2026
a9307e7
UN-4299 [FIX] Pin the v1 prefix in cn() with a test that fails withou…
praveen-formido Oct 28, 2026
5a361ae
UN-4299 [FIX] Use "ui" as the v1 utility prefix: Tailwind accepts low…
praveen-formido Oct 28, 2026
0e2b7a0
UN-4299 [FEAT] Add the prefixed v1 Tailwind root with fonts, modes, t…
praveen-formido Oct 28, 2026
d737fa8
UN-4299 [FIX] Exclude the generated tokens.css from Biome formatting …
praveen-formido Oct 28, 2026
a9acf5e
UN-4299 [FIX] Sort the imports in cn.ts so biome ci passes
praveen-formido Oct 28, 2026
54469cb
UN-4299 [FIX] Make the root compile test fail on unscoped selector li…
praveen-formido Oct 28, 2026
43c9ced
UN-4299 [FEAT] Add ThemeProvider: the v1 root, follow-system binding …
praveen-formido Oct 28, 2026
cc6126c
UN-4299 [FIX] Apply the stored theme before first paint with useLayou…
praveen-formido Oct 28, 2026
0e13d94
UN-4299 [FEAT] Enforce the v1 styling policy: raw colours, pixels, in…
praveen-formido Oct 28, 2026
c3a703c
UN-4299 [FIX] Scope the styling lint to class strings and cover vh/vw…
praveen-formido Oct 28, 2026
a35da5b
UN-4299 [FIX] Keep v0's Tailwind scan out of src/v1, the lint scripts…
praveen-formido Oct 28, 2026
d6047e9
UN-4299 [FIX] Teach cn the type roles so a role and a text colour no …
praveen-formido Oct 28, 2026
b29503d
UN-4299 [FIX] Reject colour literals outside class strings, named-col…
praveen-formido Oct 28, 2026
e064614
UN-4299 [FIX] Keep the focus ring visible in forced-colors mode and s…
praveen-formido Oct 28, 2026
88354e1
UN-4299 [FIX] Create the portal container before the mode changes so …
praveen-formido Oct 28, 2026
a41e1ba
UN-4299 [FIX] Document the v0 rules that reach v1, the focus-ring usa…
praveen-formido Oct 28, 2026
8231919
UN-4299 [FIX] Name the theme storage key in the v1 README
praveen-formido Oct 28, 2026
d0c1be0
UN-4299 [MISC] Name the three theme modes in the v1 README
praveen-formido Oct 28, 2026
664d1cf
fix(frontend): typecheck v1 tests that import vitest; skip cloud plug…
jaseemjaskp Oct 29, 2026
787bf8b
fix(frontend): close v1 guardrail gaps from team review (UN-4270)
jaseemjaskp Oct 29, 2026
b9bed0d
feat(frontend): add an assets/ folder to the v1 taxonomy (UN-4270)
jaseemjaskp Oct 29, 2026
3889a34
fix(frontend): find v1 imports only in executable code, not in string…
jaseemjaskp Oct 29, 2026
bef9515
UN-4299 [FEAT] Let a v1 style attribute set CSS custom properties only
praveen-formido Oct 29, 2026
678efbb
UN-4299 [FIX] Make check-v1-styles.mjs executable to match its shebang
praveen-formido Oct 29, 2026
8028a73
UN-4299 [FIX] Type the custom-property keys a v1 style attribute may set
praveen-formido Oct 29, 2026
feb8c5f
fix(frontend): don't read JSX apostrophes as strings in the v1 scanne…
jaseemjaskp Oct 29, 2026
aca2e0d
UN-4287 [FEAT] Add composeRefs to v1 lib
athul-rs Oct 29, 2026
9a254a0
UN-4287 [FEAT] Add v1 text inputs on one field frame
athul-rs Oct 29, 2026
c8438dc
UN-4287 [FIX] Derive helper and error ids from the control id
athul-rs Oct 29, 2026
fa87ac6
UN-4287 [FIX] Match the field fill, padding and gaps to the Input Gro…
athul-rs Oct 29, 2026
9ae418b
UN-4287 [FIX] Run React 19 ref cleanups in composeRefs
athul-rs Oct 29, 2026
c320695
UN-4287 [FIX] Fix the review round 1 findings in the text inputs
athul-rs Oct 29, 2026
e611579
UN-4287 [FIX] Test ref forwarding and that a field action never submits
athul-rs Oct 29, 2026
7af9264
fix(frontend): read v1 imports from a Babel AST, not a hand-written t…
jaseemjaskp Oct 30, 2026
daa1a18
fix(frontend): read v1 import specifiers through TypeScript assertion…
jaseemjaskp Oct 30, 2026
73b9ffa
fix(frontend): close v1 boundary gaps from the daa1a182d review (UN-4…
jaseemjaskp Nov 2, 2026
508e370
UN-4299 [FIX] Close the styling-lint gaps found in review
praveen-formido Nov 2, 2026
6e75882
UN-4299 [FIX] Set color-scheme per mode, bind spacing utilities to th…
praveen-formido Nov 2, 2026
622695f
UN-4299 [FIX] Run the token generator under bun
praveen-formido Nov 2, 2026
7fdcb02
fix(frontend): read CSS image-set() and escaped url()s; allowlist v1 …
jaseemjaskp Nov 2, 2026
ee517cb
chore: git-ignore cloud v1 plugins copied into src/v1/plugins/ (UN-4270)
jaseemjaskp Nov 2, 2026
e565ae5
UN-4299 [MISC] Merge main into the tokens branch now that the v1 scaf…
praveen-formido Nov 2, 2026
c6c0b21
UN-4299 [FIX] Close the S3 separator gap and two lint false positives…
praveen-formido Nov 2, 2026
4667e5d
UN-4299 [MISC] Move the token values to tokens.json and settle the So…
praveen-formido Nov 3, 2026
fa189cf
Merge branch 'main' into UN-4299-design-tokens-themes
muhammad-ali-e Nov 4, 2026
486c2be
Merge branch 'main' into UN-4299-design-tokens-themes
muhammad-ali-e Nov 4, 2026
ac4a720
Merge the final v1 scaffold head (ee517cb4e) into UN-4287-v1-text-inputs
athul-rs Nov 5, 2026
3ea6a61
Merge commit 'refs/tmp/pr2399' into UN-4287-v1-text-inputs
athul-rs Nov 5, 2026
0a88507
UN-4287 [FIX] Inline the markup snapshot: v1 rejects .snap files
athul-rs Nov 5, 2026
ee94371
Merge remote-tracking branch 'origin/main' into UN-4287-v1-text-inputs
athul-rs Nov 5, 2026
f012183
UN-4287 [FIX] Make the reset and controlled-Escape tests fail on the …
athul-rs Nov 5, 2026
4e88fa5
UN-4287 [FIX] Re-fit the prompt box when it is shown or its width cha…
athul-rs Nov 9, 2026
aab63be
Merge remote-tracking branch 'origin/main' into UN-4287-v1-text-inputs
athul-rs Nov 9, 2026
49fad94
UN-4287 [FIX] Keep the Field label on its control, restore search tex…
athul-rs Nov 9, 2026
46e4646
UN-4287 [FIX] Rename compose-refs so the frontend image build keeps it
athul-rs Nov 10, 2026
fc94c05
UN-4287 [FIX] Address review: copy through the toast, gallery with ax…
athul-rs Nov 11, 2026
cb5e321
UN-4287 [FIX] Keep SecretField's onCopy argument a CopyResult
athul-rs Nov 11, 2026
494659c
UN-4287 [FEAT] Keep SearchField's own handlers under a spread onChange
athul-rs Nov 11, 2026
7f92415
Merge remote-tracking branch 'origin/main' into UN-4287-v1-text-inputs
athul-rs Nov 11, 2026
ccde261
UN-4287 [FEAT] Forward a spread onChange from SearchField
athul-rs Nov 12, 2026
fa2f5e9
UN-4287 [FIX] Address review: register() spread, prefix/suffix descri…
athul-rs Nov 12, 2026
b3d3bf2
UN-4287 [FIX] Read the live value when an overlay asks whether Escape…
athul-rs Nov 12, 2026
67e26f9
UN-4287 [FIX] Re-read a form library's value on focus so the Clear bu…
athul-rs Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ is convention.
- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call
`loader.config()` in v1: the loader is shared with v0.

## Text inputs

- Text controls come from `components/ui/form/` (`Input`, `Textarea`,
`PromptTextarea`, `SearchField`, `SecretField`) inside a `Field`, which
ties the label, helper and error text to the control. A control the Field
cannot name with `<label for>` (a radiogroup) takes `labelId` from
`useFieldControl` as `aria-labelledby`. A trigger that is itself the frame
(a select) uses `fieldFrameVariants({ focus: "self" })`.
- A Field holds one form control. Every control under it that reads the
Field's context takes the same id and description, and a control that does
not read it (a native `<select>`) is left unlabelled. Give a min/max pair a
Field each.
- A control's own `invalid`, `disabled` and `readOnly` win over its Field's.
- An overlay (Dialog, Popover, Menu) that can hold a `SearchField` must call
`event.preventDefault()` in `onEscapeKeyDown` when
`isEscapeClearTarget(event)` is true, so Escape clears the search before it
closes the overlay.

## Version switch and route registry

v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/v1/components/ui/form/ErrorText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cn } from "@v1/lib/cn";
import type { ComponentProps, JSX } from "react";

export type ErrorTextProps = ComponentProps<"p">;

/** A validation message. role="alert" makes screen readers announce it when it is rendered, so render it only while there is an error. */
export function ErrorText({
className,
...props
}: ErrorTextProps): JSX.Element {
return (
<p
role="alert"
data-slot="error-text"
className={cn("ui:text-caption ui:text-destructive", className)}
{...props}
/>
);
}
210 changes: 210 additions & 0 deletions frontend/src/v1/components/ui/form/Field.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { screen } from "@testing-library/react";
import { renderV1 } from "@v1/test";
import type { JSX } from "react";
import { expect, it } from "vitest";
import { ErrorText } from "./ErrorText";
import { Field } from "./Field";
import { fieldFrameVariants } from "./FieldFrame";
import { useFieldControl } from "./field-context";
import { Input } from "./Input";
import { Label } from "./Label";
import { PromptTextarea } from "./PromptTextarea";
import { SearchField } from "./SearchField";
import { SecretField } from "./SecretField";
import { Textarea } from "./Textarea";

/** Every text control, rendered with the given props. */
const CONTROLS: [string, (props: ControlProps) => JSX.Element][] = [
["Input", (p) => <Input {...p} />],
["Textarea", (p) => <Textarea {...p} />],
["PromptTextarea", (p) => <PromptTextarea {...p} />],
["SearchField", (p) => <SearchField {...p} />],
["SecretField", (p) => <SecretField {...p} />],
];

type ControlProps = Readonly<{
invalid?: boolean;
disabled?: boolean;
readOnly?: boolean;
"aria-label"?: string;
}>;

const frameOf = (control: HTMLElement) =>
control.closest("[data-slot=field-frame]") as HTMLElement;

it.each(CONTROLS)("associates the Field label with a %s", (_, make) => {
renderV1(<Field label="Name">{make({})}</Field>);
const named = screen.getByLabelText("Name");
expect(named.dataset.slot).toBe("control");
});

it.each(
CONTROLS,
)("wires a %s to the Field's error, helper and states", (_, make) => {
renderV1(
<Field label="Name" id="name" helperText="Help" error="Bad" disabled>
{make({})}
</Field>,
);
const control = screen.getByLabelText("Name") as HTMLInputElement;
expect(control.getAttribute("aria-invalid")).toBe("true");
expect(control.getAttribute("aria-describedby")).toBe(
"name-helper name-error",
);
expect(control.disabled).toBe(true);
expect(frameOf(control).hasAttribute("data-invalid")).toBe(true);
expect(frameOf(control).hasAttribute("data-disabled")).toBe(true);
});

it.each(
CONTROLS,
)("marks a standalone %s from its own state props", (_, make) => {
const { rerender } = renderV1(make({ "aria-label": "C" }));
const control = () => screen.getByLabelText("C") as HTMLInputElement;
expect(control().hasAttribute("aria-invalid")).toBe(false);
for (const flag of ["data-invalid", "data-disabled", "data-readonly"]) {
expect(frameOf(control()).hasAttribute(flag)).toBe(false);
}
rerender(make({ "aria-label": "C", invalid: true, readOnly: true }));
expect(control().getAttribute("aria-invalid")).toBe("true");
expect(control().readOnly).toBe(true);
expect(frameOf(control()).hasAttribute("data-invalid")).toBe(true);
expect(frameOf(control()).hasAttribute("data-readonly")).toBe(true);
rerender(make({ "aria-label": "C", disabled: true }));
expect(control().disabled).toBe(true);
expect(frameOf(control()).hasAttribute("data-disabled")).toBe(true);
});

it.each([
["Input", <Input key="i" id="own" />],
["Textarea", <Textarea key="t" id="own" />],
])("names a %s that has its own id: the Field's id wins", (_, control) => {
renderV1(<Field label="Name">{control}</Field>);
const named = screen.getByLabelText("Name");
expect(named.id).not.toBe("own");
expect(document.querySelector("label")?.htmlFor).toBe(named.id);
});

it("associates a standalone Label through htmlFor", () => {
renderV1(
<>
<Label htmlFor="api-key">API key</Label>
<Input id="api-key" />
</>,
);
expect(screen.getByLabelText("API key").id).toBe("api-key");
});

it("describes the control by its helper text, and adds the error when it appears", () => {
const { rerender } = renderV1(
<Field label="Name" helperText="As shown on invoices">
<Input />
</Field>,
);
const input = screen.getByLabelText("Name");
const helper = screen.getByText("As shown on invoices");
expect(input.getAttribute("aria-describedby")).toBe(helper.id);
expect(input.hasAttribute("aria-invalid")).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();

rerender(
<Field label="Name" helperText="As shown on invoices" error="Required">
<Input />
</Field>,
);
const alert = screen.getByRole("alert");
expect(alert.textContent).toBe("Required");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(input.getAttribute("aria-describedby")).toBe(
`${helper.id} ${alert.id}`,
);
expect(
input.closest("[data-slot=field-frame]")?.hasAttribute("data-invalid"),
).toBe(true);
});

it("keeps the control's own description after the field's", () => {
renderV1(
<Field label="Name" helperText="Help" id="name">
<Input aria-describedby="extra" />
</Field>,
);
const input = screen.getByLabelText("Name");
expect(input.id).toBe("name");
expect(input.getAttribute("aria-describedby")).toMatch(/ extra$/);
});

it("passes disabled and read-only from the field to the control and frame", () => {
renderV1(
<>
<Field label="Off" disabled>
<Input />
</Field>
<Field label="Fixed" readOnly>
<Textarea />
</Field>
</>,
);
const off = screen.getByLabelText("Off") as HTMLInputElement;
const fixed = screen.getByLabelText("Fixed") as HTMLTextAreaElement;
expect(off.disabled).toBe(true);
expect(off.parentElement?.hasAttribute("data-disabled")).toBe(true);
expect(fixed.readOnly).toBe(true);
expect(fixed.parentElement?.hasAttribute("data-readonly")).toBe(true);
});

it("renders ErrorText as an alert so it is announced", () => {
renderV1(<ErrorText>Bad value</ErrorText>);
expect(screen.getByRole("alert").textContent).toBe("Bad value");
});

// FormField (P1-T06) passes `error` into Field, so this precedence is what a form control gets.
it.each(
CONTROLS,
)("a %s's own invalid, disabled and readOnly win over its Field's", (_, make) => {
renderV1(
<Field label="Name" error="x" disabled readOnly>
{make({ invalid: false, disabled: false, readOnly: false })}
</Field>,
);
const control = screen.getByLabelText("Name") as HTMLInputElement;
expect(control).not.toHaveAttribute("aria-invalid");
expect(control).toBeEnabled();
expect(control.readOnly).toBe(false);
expect(frameOf(control)).not.toHaveAttribute("data-invalid");
});

it("invalid={false} on a Field keeps its error text but not the invalid mark", () => {
renderV1(
<Field label="Name" error="Taken" invalid={false}>
<Input />
</Field>,
);
const control = screen.getByLabelText("Name");
expect(control).not.toHaveAttribute("aria-invalid");
expect(screen.getByText("Taken")).toBeInTheDocument();
});

it("gives the label an id that useFieldControl hands to a group control", () => {
function Group() {
const { labelId } = useFieldControl({});
return <div role="radiogroup" aria-labelledby={labelId} />;
}
renderV1(
<Field id="plan" label="Plan">
<Group />
</Field>,
);
expect(screen.getByRole("radiogroup", { name: "Plan" })).toBeInTheDocument();
expect(screen.getByText("Plan")).toHaveAttribute("id", "plan-label");
});

it("fieldFrameVariants: focus within rings on the control, focus self on the frame", () => {
expect(fieldFrameVariants()).toContain(
"ui:has-[[data-slot=control]:focus-visible]:focus-ring",
);
const self = fieldFrameVariants({ focus: "self" });
expect(self).toContain("ui:focus-visible:focus-ring");
expect(self).not.toContain("has-[[data-slot=control]");
expect(self).toContain("ui:data-readonly:bg-muted");
});
77 changes: 77 additions & 0 deletions frontend/src/v1/components/ui/form/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { cn } from "@v1/lib/cn";
import { type JSX, type ReactNode, useId, useMemo } from "react";
import { ErrorText } from "./ErrorText";
import { FieldContext, type FieldContextValue } from "./field-context";
import { HelperText } from "./HelperText";
import { Label } from "./Label";

export type FieldProps = Readonly<{
label: ReactNode;
helperText?: ReactNode;
/** Shown as ErrorText and marks the control invalid unless `invalid` says otherwise. */
error?: ReactNode;
invalid?: boolean;
disabled?: boolean;
readOnly?: boolean;
/** The control's id, which replaces any id on the control itself; generated when omitted. */
id?: string;
className?: string;
/**
* One form control. The Field gives its id, description and state to every
* control that reads its context, so two controls would share one id, and a
* control that does not read it (a native `<select>`) is not labelled.
*/
children: ReactNode;
}>;

/** Label, control, helper and error text, wired together: the label names the control and the control is described by the texts. */
export function Field({
label,
helperText,
error,
invalid,
disabled = false,
readOnly = false,
id,
className,
children,
}: FieldProps): JSX.Element {
const generatedId = useId();
const controlId = id ?? `${generatedId}control`;
// Derived from the control id, so a caller that passes `id` gets stable ids.
const labelId = `${controlId}-label`;
const helperId = helperText ? `${controlId}-helper` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const isInvalid = invalid ?? Boolean(error);

const context = useMemo<FieldContextValue>(
() => ({
controlId,
labelId,
describedBy: [helperId, errorId].filter(Boolean).join(" ") || undefined,
invalid: isInvalid,
disabled,
readOnly,
}),
[controlId, labelId, helperId, errorId, isInvalid, disabled, readOnly],
);

return (
<FieldContext value={context}>
<div data-slot="field" className={cn("ui:grid ui:gap-1.5", className)}>
<Label
id={labelId}
htmlFor={controlId}
data-disabled={disabled ? "" : undefined}
>
{label}
</Label>
{children}
{helperText ? (
<HelperText id={helperId}>{helperText}</HelperText>
) : null}
{error ? <ErrorText id={errorId}>{error}</ErrorText> : null}
</div>
</FieldContext>
);
}
21 changes: 21 additions & 0 deletions frontend/src/v1/components/ui/form/FieldAction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { screen } from "@testing-library/react";
import { renderV1 } from "@v1/test";
import { expect, it, vi } from "vitest";
import { FieldAction } from "./FieldAction";

it("is a named button that does not submit its form", async () => {
const onSubmit = vi.fn((event: { preventDefault: () => void }) =>
event.preventDefault(),
);
const onClick = vi.fn();
const { user } = renderV1(
<form onSubmit={onSubmit}>
<FieldAction label="Copy" onClick={onClick} />
</form>,
);
const button = screen.getByRole("button", { name: "Copy" });
expect(button.getAttribute("title")).toBe("Copy");
await user.click(button);
expect(onClick).toHaveBeenCalledOnce();
expect(onSubmit).not.toHaveBeenCalled();
});
30 changes: 30 additions & 0 deletions frontend/src/v1/components/ui/form/FieldAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { cn } from "@v1/lib/cn";
import type { ComponentProps, JSX } from "react";

export type FieldActionProps = Omit<ComponentProps<"button">, "aria-label"> &
Readonly<{
/** The accessible name; also shown as the tooltip, since the button shows only an icon. */
label: string;
}>;

/** An icon button inside a field frame, such as Reveal, Copy or Clear. */
export function FieldAction({
label,
className,
type = "button",
...props
}: FieldActionProps): JSX.Element {
return (
<button
type={type}
aria-label={label}
title={label}
data-slot="field-action"
className={cn(
"ui:inline-flex ui:size-6 ui:shrink-0 ui:cursor-pointer ui:items-center ui:justify-center ui:rounded-sm ui:border-0 ui:bg-transparent ui:p-0 ui:text-muted-foreground ui:hover:bg-accent ui:hover:text-foreground ui:focus-visible:focus-ring ui:aria-pressed:text-foreground ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:[&>svg]:size-4",
className,
)}
{...props}
/>
);
}
Loading
Loading