diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md
index cd7863da..9b125ecc 100644
--- a/docs/FRONTEND_MAINTAINABILITY.md
+++ b/docs/FRONTEND_MAINTAINABILITY.md
@@ -18,6 +18,33 @@ state. Use a ternary only when both branches render meaningful alternatives.
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,
@@ -29,9 +56,31 @@ code, and asynchronous feedback. It owns:
- 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:
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.config.js b/eslint.config.js
index f88e2a81..55ef4f71 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,6 +5,14 @@ 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";
+
+const localRules = {
+ rules: {
+ "no-non-boolean-jsx-and": noNonBooleanJsxAnd,
+ },
+};
+
const legacyIgnores = [
"src/pages/**",
"src/app/store.js",
@@ -54,6 +62,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
+ seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@@ -62,6 +71,7 @@ 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",
"no-restricted-syntax": [
"error",
{
@@ -78,8 +88,9 @@ export default tseslint.config(
},
{
selector:
- "JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']",
- message: 'Use Text variant="error" for accessible, consistent error feedback.',
+ "JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]",
+ message:
+ 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and 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/login-form.tsx b/src/app/(auth)/_components/login-form.tsx
index e06ba713..3e3d8456 100644
--- a/src/app/(auth)/_components/login-form.tsx
+++ b/src/app/(auth)/_components/login-form.tsx
@@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn}
error={Boolean(errors.password)}
/>
- {loginError && }
+ {Boolean(loginError) && (
+
+ )}
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load accounts"}
diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx
index d01ae284..513f1837 100644
--- a/src/app/(protected)/assets/_components/asset-form.tsx
+++ b/src/app/(protected)/assets/_components/asset-form.tsx
@@ -91,7 +91,7 @@ export default function AssetFormPage() {
{isEdit ? "Edit Asset" : "Create Asset"}
- {(error || optionsError) && (
+ {Boolean(error || optionsError) && (
{error instanceof Error
? error.message
diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx
index bea3dd35..e6d6451f 100644
--- a/src/app/(protected)/assets/index.tsx
+++ b/src/app/(protected)/assets/index.tsx
@@ -116,7 +116,7 @@ export default function AssetsListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load assets"}
diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx
index 183201d2..01134222 100644
--- a/src/app/(protected)/calendar/_components/event-form.tsx
+++ b/src/app/(protected)/calendar/_components/event-form.tsx
@@ -120,7 +120,7 @@ export default function EventFormPage() {
{isEdit ? "Edit Event" : "Create Event"}
- {error && Failed to load event}
+ {Boolean(error) && Failed to load event}
diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx
index f2fefc39..401265d6 100644
--- a/src/app/(protected)/contacts/_components/contact-form.tsx
+++ b/src/app/(protected)/contacts/_components/contact-form.tsx
@@ -127,7 +127,7 @@ export default function ContactFormPage() {
{isEdit ? "Edit Contact" : "Create Contact"}
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load contact"}
diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx
index 54591c7e..e910c8f4 100644
--- a/src/app/(protected)/contacts/index.tsx
+++ b/src/app/(protected)/contacts/index.tsx
@@ -103,7 +103,7 @@ export default function ContactsListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load contacts"}
diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx
index 20be5454..e1af6bf9 100644
--- a/src/app/(protected)/dashboard.tsx
+++ b/src/app/(protected)/dashboard.tsx
@@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
)}
- {description && (
+ {Boolean(description) && (
{isFetching && !isLoading && }
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx
index 78ed8bc1..6d5413a8 100644
--- a/src/app/(protected)/employees/_components/employee-form.tsx
+++ b/src/app/(protected)/employees/_components/employee-form.tsx
@@ -83,7 +83,7 @@ function SelectField({
))}
- {helperText && (
+ {Boolean(helperText) && (
{helperText}
@@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load employee"}
diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx
index b570dbe3..ed56887b 100644
--- a/src/app/(protected)/employees/index.tsx
+++ b/src/app/(protected)/employees/index.tsx
@@ -124,7 +124,7 @@ export default function EmployeesListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load employees"}
diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx
index 603dd3f1..d7a6a86c 100644
--- a/src/app/(protected)/followups/_components/follow-up-form.tsx
+++ b/src/app/(protected)/followups/_components/follow-up-form.tsx
@@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
{isEdit ? "Edit Follow-up" : "Create Follow-up"}
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load follow-up"}
diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx
index 69b42a59..30980fc0 100644
--- a/src/app/(protected)/followups/index.tsx
+++ b/src/app/(protected)/followups/index.tsx
@@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load follow-ups"}
diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx
index 20be5454..e1af6bf9 100644
--- a/src/app/(protected)/index.tsx
+++ b/src/app/(protected)/index.tsx
@@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
)}
- {description && (
+ {Boolean(description) && (
{isFetching && !isLoading && }
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx
index 79ef1acc..990fc75d 100644
--- a/src/app/(protected)/locations/_components/location-form.tsx
+++ b/src/app/(protected)/locations/_components/location-form.tsx
@@ -114,7 +114,7 @@ export default function LocationFormPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load location"}
diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx
index df239574..097db2c8 100644
--- a/src/app/(protected)/locations/index.tsx
+++ b/src/app/(protected)/locations/index.tsx
@@ -118,7 +118,7 @@ export default function LocationsListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load locations"}
diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
index d3b82f15..0253a149 100644
--- a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
+++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx
@@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}
- {error && Failed to load PM schedule}
- {optionsError && (
+ {Boolean(error) && Failed to load PM schedule}
+ {Boolean(optionsError) && (
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx
index 4517d4ed..826381e7 100644
--- a/src/app/(protected)/pmschedules/index.tsx
+++ b/src/app/(protected)/pmschedules/index.tsx
@@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
)}
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load PM schedules"}
diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx
index 4e2c0df7..e4aef562 100644
--- a/src/app/(protected)/settings/dropdowns.tsx
+++ b/src/app/(protected)/settings/dropdowns.tsx
@@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
alignItems: "stretch",
}}
>
- {parentCategory && (
+ {Boolean(parentCategory) && (
Parent Trade
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load templates"}
diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx
index 7e4f20e3..e78ac07f 100644
--- a/src/app/(protected)/uplifts/index.tsx
+++ b/src/app/(protected)/uplifts/index.tsx
@@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load uplift queue"}
@@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
{row.requestedAt ? formatDateTime(row.requestedAt) : ""}
- {row.workOrderId && (
+ {Boolean(row.workOrderId) && (
- {u.vendorReason && (
+ {Boolean(u.vendorReason) && (
Reason: {u.vendorReason}
diff --git a/src/app/(protected)/vendor-pos/index.tsx b/src/app/(protected)/vendor-pos/index.tsx
index 7667b269..81d07243 100644
--- a/src/app/(protected)/vendor-pos/index.tsx
+++ b/src/app/(protected)/vendor-pos/index.tsx
@@ -199,7 +199,7 @@ export default function VendorPosListPage() {
Clear filters
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load vendor POs"}
diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx
index 69739ea5..06f9df58 100644
--- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx
@@ -107,12 +107,12 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
- {facetsError && (
+ {Boolean(facetsError) && (
Company autocomplete unavailable. You can still type a company manually.
)}
- {submitError && {submitError}}
+ {Boolean(submitError) && {submitError}}
{isLoadingFacets ? (
) : (
diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
index a20abdcb..aaf0795d 100644
--- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
+++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx
@@ -248,7 +248,7 @@ export function VendorDetailDrawer({
<>
{mode === "view" ? (
- {submitError && {submitError}}
+ {Boolean(submitError) && {submitError}}
- {trades[0] && (
+ {Boolean(trades[0]) && (
- {mapsUrl && (
+ {Boolean(mapsUrl) && (
- {detail.notes && (
+ {Boolean(detail.notes) && (
{detail.notes}
@@ -335,7 +335,7 @@ export function VendorDetailDrawer({
sx={{ px: 3, py: 3 }}
>
- {submitError && {submitError}}
+ {Boolean(submitError) && {submitError}}
{mode === "view" ? (
<>
- {mapsUrl && (
+ {Boolean(mapsUrl) && (
- {vendor.tradeSpecialties && (
+ {Boolean(vendor.tradeSpecialties) && (
)}
- {vendor.address && (
+ {Boolean(vendor.address) && (
- {dispatch.description && (
+ {Boolean(dispatch.description) && (
{type}
{signoff ? (
- {signoff.signatureMethod === "drawn" && signoff.signature && (
+ {signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
- {uplift.vendorReason && (
+ {Boolean(uplift.vendorReason) && (
Reason: {uplift.vendorReason}
)}
- {(uplift.requestedByVendorName || uplift.requestedAt) && (
+ {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
)}
{uplift.status !== "Pending" &&
- (uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
+ Boolean(
+ uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
+ ) && (
- {(uplift.decidedByName || uplift.decidedAt) && (
+ {Boolean(uplift.decidedByName || uplift.decidedAt) && (
)}
- {uplift.decisionNote && (
+ {Boolean(uplift.decisionNote) && (
Note: {uplift.decisionNote}
@@ -720,7 +722,7 @@ export function DispatchDetailModal({
)}
- {sigCaptureType && (
+ {sigCaptureType != null && (
setSigCaptureType(null)}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 5fabb151..d918878e 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -219,7 +219,7 @@ export default function WorkOrdersListPage() {
- {error && (
+ {Boolean(error) && (
{error instanceof Error ? error.message : "Failed to load work orders"}
diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx
index 3b4e7880..cd3bb545 100644
--- a/src/app/v/[token]/_layout.tsx
+++ b/src/app/v/[token]/_layout.tsx
@@ -10,7 +10,7 @@ function VendorPortalHeader() {
return (