diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md
new file mode 100644
index 00000000..9b125ecc
--- /dev/null
+++ b/docs/FRONTEND_MAINTAINABILITY.md
@@ -0,0 +1,115 @@
+# 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 && {error.message};
+}
+
+
+ {description}
+;
+```
+
+ESLint rejects `condition ? : null`. This keeps one-sided conditions visually
+distinct from real either-or UI decisions.
+
+The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
+and non-boolean (notably `{count && }` renders `0`), so a type-aware ESLint rule,
+`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
+TypeScript checker for the type of the left operand and reports unless every union constituent is
+boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
+fails closed: if type services are unavailable in a governed TSX file it reports rather than
+silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
+same constraint at that component boundary.
+
+Approved guard forms (choose by semantics, not as a blind codemod):
+
+- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
+ (descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
+ `Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
+ `{Boolean(error) && }` and `{Boolean(description) && }` are the canonical forms.
+- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
+ helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
+ TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
+ `{isEdit && id != null && }` keep `0`/`""` semantics and preserve
+ narrowing.
+- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
+ slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
+ widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
+
+Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
+that needs a non-boolean operand must be rewritten into one of the approved forms above.
+
+## 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`.
+
+The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
+`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
+attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
+choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
+`aria-live` directly.
+
+Live-region behavior:
+
+- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
+ the polite region exists before its content changes and is announced reliably. Mounting an
+ already-populated status node on demand is not announceable on most screen-reader/browser pairs.
+- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
+ expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
+
+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.
+
+The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
+on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
+string literal containing `vp-error` (e.g. `
`, ``); pair the error message with `variant="error"`. The rule enforces the
+static surface only. It cannot resolve dynamic or composed class values
+(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
+strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
+
+## 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.
diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js
new file mode 100644
index 00000000..10408424
--- /dev/null
+++ b/eslint-rules/no-non-boolean-jsx-and.js
@@ -0,0 +1,95 @@
+import ts from "typescript";
+
+/**
+ * Local type-aware rule: the left operand of `&&` in JSX must be entirely
+ * boolean.
+ *
+ * `{value && }` renders its left operand when it is falsy and
+ * non-boolean (notably `{count && }` renders `0`), so the left operand
+ * must be `boolean` (or boolean literals) across the whole union. The rule
+ * asks the TypeScript checker for the type of the left operand and reports
+ * unless every union constituent is boolean-like.
+ *
+ * Type-aware by design: a selector that cannot see operand types would give
+ * false confidence rather than enforcement. If type services are unexpectedly
+ * unavailable in a governed TSX file, the rule fails closed (reports) instead
+ * of silently claiming the expression is safe.
+ */
+const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
+
+function isBooleanLikeType(type) {
+ return (type.flags & booleanLikeFlags) !== 0;
+}
+
+function isEntirelyBoolean(type) {
+ if (type.isUnion()) {
+ return type.types.every((constituent) => isBooleanLikeType(constituent));
+ }
+ return isBooleanLikeType(type);
+}
+
+const transparentExpressionTypes = new Set([
+ "ChainExpression",
+ "ParenthesizedExpression",
+ "TSAsExpression",
+ "TSNonNullExpression",
+ "TSTypeAssertion",
+]);
+
+function isInRenderedPosition(node) {
+ let current = node;
+ while (current.parent) {
+ const parent = current.parent;
+ if (parent.type === "JSXExpressionContainer") {
+ return parent.parent?.type !== "JSXAttribute";
+ }
+ if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
+ current = parent;
+ continue;
+ }
+ return false;
+ }
+ return false;
+}
+
+const rule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Require the left operand of `&&` in JSX to be entirely boolean",
+ },
+ schema: [],
+ messages: {
+ nonBooleanLeft:
+ 'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
+ typeServicesUnavailable:
+ "Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
+ },
+ },
+ create(context) {
+ const services = context.sourceCode?.parserServices ?? context.parserServices;
+
+ return {
+ LogicalExpression(node) {
+ if (node.operator !== "&&") {
+ return;
+ }
+ if (!isInRenderedPosition(node)) {
+ return;
+ }
+
+ if (!services || services.program === null) {
+ context.report({ node, messageId: "typeServicesUnavailable" });
+ return;
+ }
+
+ const leftType = services.getTypeAtLocation(node.left);
+ if (!isEntirelyBoolean(leftType)) {
+ context.report({ node: node.left, messageId: "nonBooleanLeft" });
+ }
+ },
+ };
+ },
+};
+
+export default rule;
diff --git a/eslint-rules/no-vp-error-outside-text.js b/eslint-rules/no-vp-error-outside-text.js
new file mode 100644
index 00000000..3498f5ea
--- /dev/null
+++ b/eslint-rules/no-vp-error-outside-text.js
@@ -0,0 +1,55 @@
+function containsVpErrorToken(node) {
+ if (!node || typeof node !== "object") {
+ return false;
+ }
+
+ if (node.type === "Literal" && typeof node.value === "string") {
+ return node.value.split(/\s+/u).includes("vp-error");
+ }
+
+ if (node.type === "TemplateElement") {
+ return node.value.raw.split(/\s+/u).includes("vp-error");
+ }
+
+ return Object.entries(node).some(([key, value]) => {
+ if (key === "parent") {
+ return false;
+ }
+ if (Array.isArray(value)) {
+ return value.some(containsVpErrorToken);
+ }
+ return containsVpErrorToken(value);
+ });
+}
+
+const rule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Require the Text component for legacy vp-error styling",
+ },
+ schema: [],
+ messages: {
+ useText:
+ 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
+ },
+ },
+ create(context) {
+ return {
+ JSXAttribute(node) {
+ if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) {
+ return;
+ }
+
+ const elementName = node.parent?.name;
+ if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") {
+ return;
+ }
+
+ context.report({ node, messageId: "useText" });
+ },
+ };
+ },
+};
+
+export default rule;
diff --git a/eslint.config.js b/eslint.config.js
index 8d1f29c2..10c0aa46 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,6 +5,16 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
+import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
+import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js";
+
+const localRules = {
+ rules: {
+ "no-non-boolean-jsx-and": noNonBooleanJsxAnd,
+ "no-vp-error-outside-text": noVpErrorOutsideText,
+ },
+};
+
const legacyIgnores = [
"src/pages/**",
"src/app/store.js",
@@ -54,6 +64,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
+ seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@@ -62,6 +73,23 @@ 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",
+ "seahaven/no-non-boolean-jsx-and": "error",
+ "seahaven/no-vp-error-outside-text": "error",
+ "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.",
+ },
+ ],
},
},
{
diff --git a/package.json b/package.json
index ee9bb861..170958c4 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,8 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
- "lint": "eslint .",
- "lint:fix": "eslint . --fix",
+ "lint": "eslint . --max-warnings=0",
+ "lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx
index 795ca0a7..aaf95cf6 100644
--- a/src/app/(auth)/_components/auth-card-header.tsx
+++ b/src/app/(auth)/_components/auth-card-header.tsx
@@ -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 (