Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
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
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
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
3366267
UN-4361 [FEAT] Add the v1 feedback components and a single notificati…
johnyrahul Nov 3, 2026
f029186
UN-4361 [FIX] Close the Greptile findings on the v1 feedback components
johnyrahul Nov 3, 2026
d2d99bc
Merge branch 'main' into UN-4361-feedback
johnyrahul Nov 4, 2026
c0dd489
UN-4361 [FIX] Pass main's shim-completeness check and follow Toaster …
claude Nov 4, 2026
bb4ea8b
UN-4361 [FIX] Address review findings on the v1 feedback components
claude Nov 4, 2026
c745e14
UN-4361 [FIX] Keep focus where the user left it when a later toast em…
claude Nov 4, 2026
e288c0b
Merge branch 'main' into UN-4361-feedback
johnyrahul Nov 5, 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
61 changes: 59 additions & 2 deletions frontend/scripts/check-v1.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* the v1 folder taxonomy. Run by `typecheck:v1`.
* --code Imports: nothing outside src/v1 and the folder import contract
* (which v1 folder may import which). No PropTypes, no antd `ant-`
* class names. Also scans v0 (the rest of src/) for imports that
* class names, and one notification entry point: toast
* libraries only in components/ui/feedback/. Also scans v0 (the rest of src/) for imports that
* reach into v1 other than through `@v1`. Every file is read
* except assets, data and docs; one of unknown type is read as
* code, since Vite can load any file (`?worker`). Run by
Expand Down Expand Up @@ -500,6 +501,56 @@ const ANT_CLASS = /(?<![\w$-])ant-/g;
// The nodes that hold text, where a class name can appear.
const TEXT_NODES = new Set(["StringLiteral", "TemplateElement", "JSXText"]);

/*
* One notification entry point (P1-T13): v1 tells the user something happened
* only through `notify()` in lib/notify.ts, which the Toaster in
* components/ui/feedback/ implements. A toast library anywhere else would be
* a second entry point; v0 already ships sonner, so it is one import away.
*/
const NOTIFY_HOME = "components/ui/feedback/";
const TOAST_PACKAGES =
/^(sonner|react-hot-toast|react-toastify|notistack|@radix-ui\/react-toast)(\/|$)/;
const NOTIFY_MSG =
"One notification entry point in v1: call notify() or toast.* from @v1/lib/notify; toast libraries are imported only in components/ui/feedback/.";

/*
* Toast-library imports outside the feedback folder, as { index, msg }.
*
* radix-ui bundles every primitive, Toast among them, and a namespace, default,
* dynamic, required or re-exported binding can reach Toast in more spellings
* than a walk can follow (`R.Toast`, `<R.Toast.Root>`, `R["Toast"]`, aliases,
* `.then`). So the rule is structural, not a hunt for spellings: outside the
* feedback folder radix-ui may be used only as a static named import that does
* not name Toast, and every other reference to it is reported. (antd's
* notification and message are already rejected by Biome's noRestrictedImports,
* which lint:v1 runs first.)
*/
const RADIX = /^radix-ui(\/|$)/;
const isNamedRadixImport = (node) =>
node.type === "ImportDeclaration" &&
node.source.value === "radix-ui" &&
node.specifiers.length > 0 &&
node.specifiers.every(
(spec) =>
spec.type === "ImportSpecifier" &&
(spec.imported.name ?? spec.imported.value) !== "Toast",
);

function* notifyEntryPoints(ast, specs) {
const allowed = new Set(
ast.program.body.filter(isNamedRadixImport).map((node) => node.start),
);
for (const arg of specs) {
const spec = arg.spec ?? "";
if (
TOAST_PACKAGES.test(spec) ||
(RADIX.test(spec) && !allowed.has(arg.index))
) {
yield { index: arg.index, msg: NOTIFY_MSG };
}
}
}

/* PropTypes and antd `ant-` class names, as { index, msg }. */
function* bannedUses(ast, src) {
for (const node of nodes(ast.program)) {
Expand Down Expand Up @@ -820,12 +871,18 @@ for (const rel of walk(ROOT)) {
}
const isTestFile = TEST_FILE.test(name);

for (const arg of importSpecs(parsed.ast)) {
const specs = importSpecs(parsed.ast);
for (const arg of specs) {
const problem = v1ImportProblem(folder, rel, arg, isTestFile);
if (problem) {
report(rel, arg.line, problem);
}
}
if (!rel.startsWith(NOTIFY_HOME)) {
for (const { index, msg } of notifyEntryPoints(parsed.ast, specs)) {
report(rel, lineAt(src, index), msg);
}
}
for (const { index, msg } of bannedUses(parsed.ast, src)) {
report(rel, lineAt(src, index), msg);
}
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ is convention.
icon's `size` prop. Put icons in a `ui:flex`/`ui:inline-flex` parent
that is not one of those tags, or size the parent's font.

## Feedback

- One notification entry point: `notify()` or `toast.success/error/warning/info`
from `@v1/lib/notify`, which `services/` can reach too. The `Toaster` in
`components/ui/feedback/` implements it; mount it once inside the v1 root.
With no `Toaster` mounted, `notify()` only logs to the console (with the
request id); nothing is shown or queued. `lint:v1` rejects sonner,
react-hot-toast, react-toastify and notistack outside
`components/ui/feedback/`, and there allows radix-ui only as a static named
import that does not name `Toast` (Biome already rejects antd in v1).
- Errors are announced assertively, everything else politely. Toasts stack
bottom-right (at most three), pause while hovered or focused, and F8 moves
focus to them; pass `persistent` to keep one until it is dismissed. At the
limit, timed toasts go before persistent ones; if all three are persistent
the oldest goes. Reusing an `id` replaces a toast with a fresh one (new
timer, announced again). A `requestId` is shown on the toast.
- Loading is a skeleton (`Skeleton` + `SkeletonText`, `SkeletonBlock`,
`SkeletonAvatar`, `SkeletonTableRows`), never a spinner inside a card.
`Spinner` sits only in buttons and running text. Errors on a screen are an
`InlineAlert` above the content, with `onRetry`.

## Folders and what each may import

Every folder may import from itself. Test files (`*.test.ts(x)`,
Expand Down Expand Up @@ -91,7 +112,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| Script | Checks |
| --- | --- |
| `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` |
| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) |
| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above) |
| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) |
| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/` and `test-fixtures/v1-harness/`. The harness is documented in `test/README.md` |

Expand Down
67 changes: 67 additions & 0 deletions frontend/src/v1/components/ui/feedback/InlineAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cn } from "@v1/lib/cn";
import type { NotifyKind } from "@v1/lib/notify";
import type { ComponentProps, JSX, ReactNode } from "react";
import { KIND_BORDER, KIND_ICON, KIND_TEXT } from "./kinds";

export type InlineAlertProps = Omit<ComponentProps<"div">, "title" | "role"> &
Readonly<{
kind?: NotifyKind;
title: ReactNode;
children?: ReactNode;
/** Shows a Retry button; the error state's one action. */
onRetry?: () => void;
retryLabel?: string;
}>;

/**
* A message above the content it concerns: dev spec section 8, "inline, above
* the content it concerns, with a retry". Errors are announced at once
* (`role="alert"`); other kinds wait for a pause (`role="status"`).
*/
export function InlineAlert({
kind = "error",
title,
children,
onRetry,
retryLabel = "Retry",
className,
...props
}: InlineAlertProps): JSX.Element {
const Icon = KIND_ICON[kind];
return (
<div
role={kind === "error" ? "alert" : "status"}
data-slot="inline-alert"
data-kind={kind}
className={cn(
// `destructive-muted` has no value yet, so the fill is `muted` as in frame 622:1320.
"ui:box-border ui:flex ui:items-start ui:gap-2.5 ui:rounded-lg ui:border ui:bg-muted ui:px-3.5 ui:py-3 ui:text-foreground",
KIND_BORDER[kind],
className,
)}
{...props}
>

Check warning on line 43 in frontend/src/v1/components/ui/feedback/InlineAlert.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGgFU3R99pIcow0RFPc&open=AaGgFU3R99pIcow0RFPc&pullRequest=2426
<Icon
aria-hidden
className={cn("ui:mt-0.5 ui:size-4 ui:shrink-0", KIND_TEXT[kind])}
/>
<div className="ui:flex ui:min-w-0 ui:flex-1 ui:flex-col ui:gap-1">
<p className="ui:m-0 ui:text-body-small ui:font-semibold">{title}</p>
{children ? (
<div className="ui:text-caption ui:text-muted-foreground">
{children}
</div>
) : null}
</div>
{onRetry ? (
<button
type="button"
onClick={() => onRetry()}
className="ui:inline-flex ui:h-7 ui:shrink-0 ui:cursor-pointer ui:items-center ui:rounded-sm ui:border ui:border-input ui:bg-background ui:px-2.5 ui:text-body-small ui:font-medium ui:text-foreground ui:hover:bg-accent ui:focus-visible:focus-ring"
>
{retryLabel}
</button>
) : null}
</div>
);
}
54 changes: 54 additions & 0 deletions frontend/src/v1/components/ui/feedback/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { cn } from "@v1/lib/cn";
import { Progress as RadixProgress } from "radix-ui";
import type { JSX } from "react";

type Named =
| Readonly<{ "aria-label": string; "aria-labelledby"?: never }>
| Readonly<{ "aria-labelledby": string; "aria-label"?: never }>;

export type ProgressBarProps = Named &
Readonly<{
/** 0 to `max`; leave it out (or null) while the total is unknown. */
value?: number | null;
max?: number;
className?: string;
}>;

/** A 6px rounded track with a primary fill (frame 757:310). The name is required. */
export function ProgressBar({
value = null,
max = 100,
className,
...named
}: ProgressBarProps): JSX.Element {
const indeterminate = value === null;
// Radix rejects a max that is not a positive number, and a value that is not
// finite; an empty batch (0 of 0, or `done / total * 100` with total 0) shows
// as an empty bar rather than NaN.
const validMax = Number.isFinite(max) && max > 0;
const total = validMax ? max : 100;
const clamped =
indeterminate || !validMax || !Number.isFinite(value)
? 0
: Math.min(Math.max(value, 0), total);
return (
<RadixProgress.Root
value={indeterminate ? null : clamped}
max={total}
data-slot="progress-bar"
className={cn(
"ui:relative ui:h-1.5 ui:w-full ui:overflow-hidden ui:rounded-full ui:bg-muted",
className,
)}
{...named}
>
<RadixProgress.Indicator
style={{ "--progress": indeterminate ? 0.4 : clamped / total }}
className={cn(
"ui:h-full ui:w-[calc(var(--progress)*100%)] ui:rounded-full ui:bg-primary ui:transition-[width] ui:motion-reduce:transition-none",
indeterminate && "ui:motion-safe:animate-pulse",
)}
/>
</RadixProgress.Root>
);
}
148 changes: 148 additions & 0 deletions frontend/src/v1/components/ui/feedback/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { cn } from "@v1/lib/cn";
import type { JSX, ReactNode } from "react";

/*
* Dev spec section 8 and frame 676:9632: muted bars, heights 12 then 10, three
* widths only (100%, 78%, 46%), never a spinner inside a card. Two values are
* not tokens, so the closest ones stand in: radius 4 (`rounded-sm`, 6) and the
* bar colour #e4e4e7 (`muted`).
*/

const BAR = "ui:block ui:rounded-sm ui:bg-muted ui:motion-safe:animate-pulse";
const WIDTHS = ["ui:w-full", "ui:w-[78%]", "ui:w-[46%]"] as const;

export type SkeletonProps = Readonly<{
/** What is loading, read by screen readers ("Loading projects"). */
label?: string;
className?: string;
children?: ReactNode;
}>;

/**
* The container every skeleton sits in: one status region whose only text is
* the label. No `aria-busy`: on a live region it holds announcements until it
* turns false, and a skeleton unmounts instead, so the label would never be read.
*/
export function Skeleton({
label = "Loading",
className,
children,
}: SkeletonProps): JSX.Element {
return (
// biome-ignore lint/a11y/useSemanticElements: <output> may hold only phrasing content, and skeletons hold blocks.
<div
role="status"
data-slot="skeleton"
className={cn("ui:flex ui:flex-col ui:gap-2.5", className)}
>

Check warning on line 37 in frontend/src/v1/components/ui/feedback/Skeleton.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGgFU8y99pIcow0RFPd&open=AaGgFU8y99pIcow0RFPd&pullRequest=2426
<span className="ui:sr-only">{label}</span>
<div aria-hidden className="ui:contents">
{children}
</div>
</div>
);
}

export type SkeletonTextProps = Readonly<{
lines?: number;
className?: string;
}>;

/** Text lines: the first 12 tall, the rest 10, widths cycling 100%, 78%, 46%. */
export function SkeletonText({
lines = 3,
className,
}: SkeletonTextProps): JSX.Element {
return (
<div
data-slot="skeleton-text"
className={cn("ui:flex ui:flex-col ui:gap-2.5", className)}
>
{Array.from({ length: lines }, (_, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows.
key={i}
className={cn(
BAR,
i === 0 ? "ui:h-3" : "ui:h-2.5",
WIDTHS[i % WIDTHS.length],
)}
/>
))}
</div>
);
}

export type SkeletonBlockProps = Readonly<{ className?: string }>;

/** A block (a card body, a chart, an image); size it with classes. */
export function SkeletonBlock({ className }: SkeletonBlockProps): JSX.Element {
return (
<span
data-slot="skeleton-block"
className={cn(BAR, "ui:h-24 ui:w-full", className)}
/>
);
}

export type SkeletonAvatarProps = Readonly<{
size?: "sm" | "md";
className?: string;
}>;

export function SkeletonAvatar({
size = "md",
className,
}: SkeletonAvatarProps): JSX.Element {
return (
<span
data-slot="skeleton-avatar"
className={cn(
BAR,
"ui:shrink-0 ui:rounded-full",
size === "sm" ? "ui:size-6" : "ui:size-8",
className,
)}
/>
);
}

export type SkeletonTableRowsProps = Readonly<{
rows?: number;
columns?: number;
className?: string;
}>;

/** Table rows: one 12-tall bar per cell, widths cycling so columns look real. */
export function SkeletonTableRows({
rows = 5,
columns = 4,
className,
}: SkeletonTableRowsProps): JSX.Element {
return (
<div
data-slot="skeleton-table-rows"
className={cn("ui:flex ui:flex-col", className)}
>
{Array.from({ length: rows }, (_, r) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows.
key={r}
className="ui:flex ui:items-center ui:gap-4 ui:border-b ui:border-border ui:px-4 ui:py-3 ui:last:border-b-0"
>
{Array.from({ length: columns }, (_, c) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder cells.
key={c}
className="ui:flex ui:flex-1"
>
<span
className={cn(BAR, "ui:h-3", WIDTHS[(r + c) % WIDTHS.length])}
/>
</span>
))}
</div>
))}
</div>
);
}
Loading
Loading