chore(frontend): enforce maintainable text rendering

This commit is contained in:
Alexandre Brandizzi 2026-07-23 18:48:15 -03:00
parent 20237f8142
commit 21b7de57a1
11 changed files with 297 additions and 39 deletions

View file

@ -0,0 +1,66 @@
# Frontend maintainability conventions
## Conditional rendering
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
state. Use a ternary only when both branches render meaningful alternatives.
```tsx
{
error && <Alert severity="error">{error.message}</Alert>;
}
<Text variant="description" when={Boolean(description)}>
{description}
</Text>;
```
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
distinct from real either-or UI decisions.
## Typography and feedback
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
code, and asynchronous feedback. It owns:
- semantic HTML for each visual variant;
- the display, body, and monospace font families;
- default, muted, error, success, and warning tones;
- accessible `alert` and `status` live regions for error and feedback text;
- one-sided conditional text through `when`.
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
but new shared UI should prefer `Text` so semantics and design tokens do not drift.
## Forms and mutations
Use the libraries already established in the application:
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
- Zod owns form validation and inferred form value types.
- TanStack Query owns server reads and mutations, including pending/error state, cache
invalidation, and retries where safe.
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
benchmarks, a codemod plan, and an approved deprecation path.
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
retry state. Do not place upload orchestration in a route-sized page component.
## Page state
Pages should compose focused state components instead of accumulating unrelated booleans:
- query loading, error, and empty states stay adjacent to the query result;
- mutation pending/error state belongs to the component that initiated the mutation;
- route pages coordinate sections and navigation;
- reusable sections own their interaction details;
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
## Enforcement and rollout
The lint rules are repository-wide and the initial violations were migrated in the same change.
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
maintainability rules must also land with a green migration rather than a warning-only backlog.

View file

@ -62,6 +62,26 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"no-restricted-syntax": [
"error",
{
selector:
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
message:
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
},
{
selector:
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
message:
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
},
{
selector:
"JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']",
message: 'Use Text variant="error" for accessible, consistent error feedback.',
},
],
},
},
{

View file

@ -1,5 +1,6 @@
import type { ComponentPropsWithoutRef } from "react";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
{subtitle}
</p>
)}
<Text variant="title" className="m-0 text-center font-bold text-foreground">
{title}
</Text>
<Text
variant="description"
tone="muted"
when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal"
>
{subtitle}
</Text>
</div>
);
}

View file

@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -37,9 +38,13 @@ export default function LoginPage() {
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
<Text
variant="caption"
tone="muted"
className="mt-5! block text-center text-(length:--text-xs)"
>
Having trouble? Contact your administrator.
</p>
</Text>
</AuthPageShell>
);
}

View file

@ -1,6 +1,7 @@
import { NavLink, Outlet, useLocation, useParams } from "react-router";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
import { Text } from "@/components/ui/text";
import "@/app/v/_components/vendor-portal.css";
function VendorPortalHeader() {
@ -55,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") {
return (
<div className="vp-container">
<div className="vp-error">
<h2>Access Denied</h2>
<p>
<section className="vp-error">
<Text variant="title">Access Denied</Text>
<Text variant="error">
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
</p>
</div>
</Text>
</section>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() {
return (
<>
<div className="vp-card">
<h2>Your Work</h2>
<Text variant="title">Your Work</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Active dispatches and work orders assigned to your company.
</div>
@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading dispatches…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && filtered.length === 0 && (
<div className="vp-card vp-empty">No dispatches in this view.</div>

View file

@ -3,6 +3,7 @@ import { Link, useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() {
}
if (error && !data) {
return <div className="vp-error">{error.message}</div>;
return (
<Text variant="error" className="vp-error">
{error.message}
</Text>
);
}
if (!data) return null;
@ -457,7 +462,7 @@ export default function VendorPortalDispatchPage() {
<div className="vp-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2>{data.dispatchNumber}</h2>
<Text variant="title">{data.dispatchNumber}</Text>
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
</div>
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
@ -492,7 +497,9 @@ export default function VendorPortalDispatchPage() {
{data.location && (
<>
<h3 style={{ marginTop: 16 }}>Location</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Location
</Text>
<div>{data.location.name}</div>
<div className="vp-muted">
{[
@ -509,7 +516,9 @@ export default function VendorPortalDispatchPage() {
{(data.description || data.workOrder?.description) && (
<>
<h3 style={{ marginTop: 16 }}>Description</h3>
<Text variant="heading" sx={{ marginTop: 2 }}>
Description
</Text>
<div>{data.description || data.workOrder?.description}</div>
</>
)}
@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Checklist</h3>
<Text variant="heading">Checklist</Text>
<ChecklistSection
token={token}
dispatchId={dispatchId}
@ -588,7 +597,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<Text variant="heading">NTE Uplift Requests</Text>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
@ -600,7 +609,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<Text variant="heading">Customer Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -612,7 +621,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Vendor Signoff</h3>
<Text variant="heading">Vendor Signoff</Text>
<SignoffSection
token={token}
dispatchId={dispatchId}
@ -624,7 +633,7 @@ export default function VendorPortalDispatchPage() {
</div>
<div className="vp-card">
<h3>Comments</h3>
<Text variant="heading">Comments</Text>
<CommentsSection
token={token}
dispatchId={dispatchId}

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { Text } from "@/components/ui/text";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
formatVendorPortalDateTime,
@ -81,7 +82,7 @@ export default function VendorPortalPosPage() {
return (
<>
<div className="vp-card">
<h2>Your Purchase Orders</h2>
<Text variant="title">Your Purchase Orders</Text>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Every PO issued to your company, with current status and dollar value.
</div>
@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
</div>
{isLoading && <div className="vp-card vp-muted">Loading POs…</div>}
{error && <div className="vp-error">{error.message}</div>}
<Text variant="error" when={Boolean(error)} className="vp-error">
{error?.message}
</Text>
{!isLoading && !error && visible.length === 0 && (
<div className="vp-card vp-empty">No POs match this view.</div>

View file

@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { Stack, Typography } from "@mui/material";
import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
type PageHeaderProps = {
@ -35,24 +36,25 @@ export function PageHeader({
}}
>
<Stack spacing={0}>
{eyebrow && (
<p className="m-0 font-sans text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase">
{eyebrow}
</p>
)}
<h1
<Text
variant="label"
when={Boolean(eyebrow)}
className="m-0 text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase"
>
{eyebrow}
</Text>
<Text
variant="display"
className={cn(
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
>
{title}
</h1>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Text>
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
{subtitle}
</Text>
</Stack>
{actions && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>

View file

@ -0,0 +1,99 @@
import type { ElementType, ReactNode } from "react";
import {
Typography as MuiTypography,
type TypographyProps as MuiTypographyProps,
} from "@mui/material";
type TextVariant =
| "display"
| "title"
| "heading"
| "body"
| "description"
| "label"
| "feedback"
| "error"
| "caption"
| "code";
type TextTone = "default" | "muted" | "error" | "success" | "warning";
type TextFamily = "display" | "body" | "mono";
export type TextProps = Omit<MuiTypographyProps, "children" | "color" | "variant"> & {
children: ReactNode;
as?: ElementType;
family?: TextFamily;
tone?: TextTone;
variant?: TextVariant;
when?: boolean;
};
const variantConfig: Record<
TextVariant,
{ element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily }
> = {
display: { element: "h1", muiVariant: "h3", family: "display" },
title: { element: "h2", muiVariant: "h5", family: "display" },
heading: { element: "h3", muiVariant: "h6", family: "display" },
body: { element: "p", muiVariant: "body1", family: "body" },
description: { element: "p", muiVariant: "body2", family: "body" },
label: { element: "span", muiVariant: "subtitle2", family: "body" },
feedback: { element: "p", muiVariant: "body2", family: "body" },
error: { element: "p", muiVariant: "body2", family: "body" },
caption: { element: "span", muiVariant: "caption", family: "body" },
code: { element: "code", muiVariant: "body2", family: "mono" },
};
const toneColor: Record<TextTone, MuiTypographyProps["color"]> = {
default: "text.primary",
muted: "text.secondary",
error: "error",
success: "success.main",
warning: "warning.main",
};
const familyValue: Record<TextFamily, string> = {
display: "var(--font-display)",
body: "var(--font-sans)",
mono: "var(--font-mono)",
};
export function Text({
as,
children,
family,
tone,
variant = "body",
when = true,
sx,
...props
}: TextProps) {
if (!when) {
return null;
}
const config = variantConfig[variant];
const resolvedTone = tone ?? (variant === "error" ? "error" : "default");
let liveProps = {};
if (variant === "error") {
liveProps = { role: "alert", "aria-live": "assertive" as const };
} else if (variant === "feedback") {
liveProps = { role: "status", "aria-live": "polite" as const };
}
return (
<MuiTypography
component={as ?? config.element}
variant={config.muiVariant}
color={toneColor[resolvedTone]}
sx={[
{ fontFamily: familyValue[family ?? config.family] },
...(Array.isArray(sx) ? sx : [sx]),
]}
{...liveProps}
{...props}
>
{children}
</MuiTypography>
);
}

View file

@ -0,0 +1,44 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Text } from "@/components/ui/text";
import { renderWithProviders } from "@/test/test-utils";
describe("Text", () => {
it("maps visual variants to semantic elements", () => {
renderWithProviders(
<>
<Text variant="display">Page title</Text>
<Text variant="description">Supporting copy</Text>
</>,
{ withAuth: false },
);
expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument();
expect(screen.getByText("Supporting copy").tagName).toBe("P");
});
it("does not render conditional text when its condition is false", () => {
renderWithProviders(
<Text when={false} variant="feedback">
Saved
</Text>,
{ withAuth: false },
);
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
});
it("gives error and feedback messages accessible live-region semantics", () => {
renderWithProviders(
<>
<Text variant="error">Upload failed</Text>
<Text variant="feedback">Uploading</Text>
</>,
{ withAuth: false },
);
expect(screen.getByRole("alert")).toHaveTextContent("Upload failed");
expect(screen.getByRole("status")).toHaveTextContent("Uploading");
});
});