Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ else
fail "DS token check failed → npm run lint:ds"
fi

# 3a-svg. SVG-dimensions guardrail — every public SVG must declare intrinsic
# width + height so an <img src=*.svg> never FOUC-flashes at ~300px before
# CSS loads (also gated in CI via npm run lint).
step "SVG dimensions"
if node scripts/check-svg-dimensions.mjs > /dev/null 2>&1; then
pass "SVG dimensions passed"
else
fail "SVG-dimensions check failed → npm run lint:svg"
fi

# 3b. Migration-reference hygiene — no dangling migration sequence numbers in
# comments (fast full-repo scan; also gated in CI via npm run lint).
step "Migration-reference hygiene"
Expand Down
54 changes: 54 additions & 0 deletions app/components/NavProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { useNavigation } from "react-router";

/**
* Global navigation progress bar (issue #202, Tier 1). React Router runs a
* route's loader BEFORE it swaps the page in, so a click can sit silent for a
* beat while data is fetched — users read that as "the app froze." This thin top
* bar gives immediate feedback: it ramps toward 90% while a navigation is in
* flight, then snaps to 100% and fades once the new route is committed.
*
* It keys off `navigation.state` only, so it self-resolves on success AND on
* error/404 (a thrown loader Response still returns the state to "idle", which
* triggers the fade-out) — no stuck-forever bar. Uses the Design System 0523
* `--ih-primary` accent so it tracks light/dark/field themes automatically.
*/
export function NavProgress() {
const navigation = useNavigation();
const active = navigation.state !== "idle";
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);

useEffect(() => {
if (active) {
setVisible(true);
setWidth(8);
const id = window.setInterval(() => {
// Ease toward 90% — never reaches it, so the bar keeps inching while we wait.
setWidth((w) => (w >= 90 ? w : w + Math.max(0.5, (90 - w) * 0.1)));
}, 200);
return () => window.clearInterval(id);
}
// Navigation settled (committed OR errored) → complete, then fade out.
setWidth(100);
const hide = window.setTimeout(() => setVisible(false), 250);
const reset = window.setTimeout(() => setWidth(0), 520);
return () => {
window.clearTimeout(hide);
window.clearTimeout(reset);
};
}, [active]);

if (!visible) return null;
return (
<div
aria-hidden
className="fixed inset-x-0 top-0 z-[200] h-[3px] pointer-events-none"
>
<div
className="h-full bg-ih-primary shadow-[0_0_8px_var(--ih-primary-glow)] transition-[width,opacity] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${width}%`, opacity: width >= 100 ? 0 : 1 }}
/>
</div>
);
}
25 changes: 25 additions & 0 deletions app/components/RouteSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { PageLoadingSkeleton } from "~/components/PageLoadingSkeleton";
import { InspectionsListSkeleton } from "~/components/dashboard/InspectionsListSkeleton";

/**
* Picks the loading skeleton that best matches the route being navigated to
* (issue #202, Tier 2). A route-matched skeleton mimics the destination's real
* layout — header, stat cards, list rows — so the page keeps its shape during
* the loader wait instead of flashing a generic placeholder and then shifting.
* Unknown routes fall back to the generic <PageLoadingSkeleton>.
*
* Exported as a pure path→component map so the matching is unit-testable.
*/
export function skeletonForPath(pathname: string): React.ReactNode {
// Inspections LIST only (exact). Detail (/inspections/:id) and the editor
// (/inspections/:id/edit) have their own shapes — keep the generic fallback
// for them rather than showing a list skeleton that wouldn't match.
if (pathname === "/inspections" || pathname === "/inspections/") {
return <InspectionsListSkeleton />;
}
return <PageLoadingSkeleton />;
}

export function RouteSkeleton({ pathname }: { pathname: string }) {
return <>{skeletonForPath(pathname)}</>;
}
23 changes: 23 additions & 0 deletions app/components/dashboard/InspectionCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Skeleton } from "@core/shared-ui";

/**
* Skeleton row that mirrors <DashboardInspectionRow> (issue #202, Tier 2): the
* same `px-4 py-3` padding, a leading checkbox-sized block, a two-line
* address + meta stack on the left, and a status pill on the right. Used to
* pre-render the inspection list structure while its loader is in flight, so the
* page shows its real shape instead of going blank.
*/
export function InspectionCardSkeleton({ widthPct = 70 }: { widthPct?: number }) {
return (
<div className="flex items-center gap-2 px-4 py-3">
<Skeleton variant="block" width="14px" className="h-3.5 rounded-sm shrink-0" />
<div className="flex items-center justify-between flex-1 min-w-0">
<div className="min-w-0 flex flex-col gap-1.5">
<Skeleton variant="text" width={`${widthPct}%`} className="h-3" />
<Skeleton variant="text" width={`${widthPct - 25}%`} className="h-2.5" />
</div>
<Skeleton variant="block" width="72px" className="h-5 rounded-full shrink-0 ml-4" />
</div>
</div>
);
}
64 changes: 64 additions & 0 deletions app/components/dashboard/InspectionsListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Skeleton } from "@core/shared-ui";
import { InspectionCardSkeleton } from "./InspectionCardSkeleton";

/**
* Route-matched loading skeleton for the inspections list (issue #202, Tier 2).
* Mirrors the real page shape inspections.tsx renders inside the auth-layout
* content wrapper: a greeting header + actions, the four-up stat-card grid, the
* workflow tab strip, and a card holding several inspection rows. Showing this
* during navigation keeps the page from going blank and avoids the layout shift
* a generic skeleton causes. (The auth-layout already supplies the
* `max-w-[1080px] … px-9` wrapper, so this renders the inner content only.)
*/
export function InspectionsListSkeleton() {
return (
<div aria-busy="true" aria-live="polite" className="space-y-[18px]">
<span className="sr-only">Loading inspections…</span>

{/* Header: greeting + meta on the left, action buttons on the right */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-2.5">
<Skeleton variant="text" width="220px" className="h-7" />
<Skeleton variant="text" width="300px" className="h-3" />
</div>
<div className="flex items-center gap-2 shrink-0">
<Skeleton variant="block" width="160px" className="h-8 rounded-md" />
<Skeleton variant="block" width="120px" className="h-8 rounded-md" />
</div>
</div>

{/* Stat cards — four-up grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="bg-ih-bg-card border border-ih-border rounded-lg shadow-ih-card p-[14px] flex flex-col gap-3"
>
<Skeleton variant="block" width="40px" className="h-10 rounded-md" />
<Skeleton variant="text" width="48px" className="h-6" />
<Skeleton variant="text" width="80%" className="h-2.5" />
</div>
))}
</div>

{/* Workflow tab strip */}
<div className="flex items-center gap-4 border-b border-ih-border pb-2">
{[64, 80, 72, 88, 60].map((w, i) => (
<Skeleton key={i} variant="text" width={`${w}px`} className="h-3.5" />
))}
</div>

{/* Inspection list card */}
<div className="bg-ih-bg-card border border-ih-border rounded-lg shadow-ih-card overflow-hidden">
<div className="px-4 py-2 border-b border-ih-border">
<Skeleton variant="text" width="80px" className="h-2.5" />
</div>
<div className="divide-y divide-ih-border">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InspectionCardSkeleton key={i} widthPct={72 - i * 6} />
))}
</div>
</div>
</div>
);
}
5 changes: 4 additions & 1 deletion app/components/portal/sections/ReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,10 @@ export function ReportView(props: ReportViewProps) {
<img
src={`${data.coverPhotoUrl}&w=1600`}
alt={`Cover photo — ${data.address}`}
className="w-full max-h-72 object-cover rounded-xl border border-ih-border"
// Fixed height (matching CoverPhotoPlaceholder) reserves the banner
// box before the image loads, so it never reflows content downward
// on load (no CLS) and the loaded/error states share one layout.
className="h-44 w-full sm:h-56 object-cover rounded-xl border border-ih-border"
loading={data.printMode ? "eager" : "lazy"}
onError={() => setCoverFailed(true)}
/>
Expand Down
8 changes: 7 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type SWRegistrarLike,
} from "~/lib/sw-bootstrap";
import { ErrorState } from "~/components/ErrorState";
import { NavProgress } from "~/components/NavProgress";

export function loader({ request }: Route.LoaderArgs): UiPrefs {
return parseUiPrefs(request.headers.get("Cookie"));
Expand Down Expand Up @@ -109,7 +110,12 @@ export default function Root() {
window.localStorage,
);
}, []);
return <Outlet />;
return (
<>
<NavProgress />
<Outlet />
</>
);
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
Expand Down
8 changes: 6 additions & 2 deletions app/routes/auth-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Route } from "./+types/auth-layout";
import { requireToken } from "~/lib/session.server";
import { createApi } from "~/lib/api-client.server";
import { Sidebar, MobileHeader } from "~/components/Sidebar";
import { PageLoadingSkeleton } from "~/components/PageLoadingSkeleton";
import { RouteSkeleton } from "~/components/RouteSkeleton";
import type { SessionContext } from "~/hooks/useSessionContext";

/**
Expand Down Expand Up @@ -81,7 +81,11 @@ export default function AuthLayout() {
<Sidebar />
<main className="flex-1 w-full bg-ih-bg-app overflow-y-auto">
<div className="max-w-[1080px] mx-auto pt-5 pb-[60px] px-9">
{showSkeleton ? <PageLoadingSkeleton /> : <Outlet />}
{showSkeleton ? (
<RouteSkeleton pathname={navigation.location?.pathname ?? location.pathname} />
) : (
<Outlet />
)}
</div>
</main>
</div>
Expand Down
3 changes: 1 addition & 2 deletions app/routes/inspection-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
// a non-OK response degrades to an empty list.
let documents: DocumentItem[] = [];
try {
const apiWorker = (context.cloudflare.env as unknown as { API_WORKER?: { fetch: typeof fetch } })
.API_WORKER;
const apiWorker = context.cloudflare.env.API_WORKER;
const docsRes = await (apiWorker?.fetch ?? fetch)(
new Request(`https://internal/api/inspections/${id}/documents`, {
headers: { cookie: request.headers.get("cookie") ?? "" },
Expand Down
3 changes: 1 addition & 2 deletions app/routes/public/portal-inspection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,7 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
// value used for the overview call. Best-effort: a non-OK response → empty.
documents = [];
try {
const apiWorker = (context.cloudflare.env as unknown as { API_WORKER?: { fetch: typeof fetch } })
.API_WORKER;
const apiWorker = context.cloudflare.env.API_WORKER;
const docsRes = await (apiWorker?.fetch ?? fetch)(
new Request(`https://internal/api/public/inspections/${inspectionId}/documents`, {
headers: { cookie: cookieForApi },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@
"type-check:app": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit",
"type-check:api": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.api.json --noEmit",
"type-check:fast": "react-router typegen && tsgo -p tsconfig.json --noEmit && tsgo -p tsconfig.api.json --noEmit",
"lint": "eslint . && npm run lint:ds && npm run lint:erasure && npm run lint:migrefs && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope",
"lint": "eslint . && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope",
"lint:ds": "node scripts/check-ds-tokens.mjs",
"lint:svg": "node scripts/check-svg-dimensions.mjs",
"lint:erasure": "node scripts/check-erasure-manifest.mjs",
"lint:migrefs": "node scripts/check-migration-refs.mjs",
"lint:filesize": "node scripts/check-file-size.mjs",
Expand Down
2 changes: 1 addition & 1 deletion public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion public/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions scripts/check-svg-dimensions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* SVG-dimensions guardrail.
*
* Every SVG served from `public/` MUST declare an intrinsic `width` AND
* `height` on its root <svg>, not just a `viewBox`. An `<img src="/x.svg">`
* with no intrinsic size falls back to the replaced-element default (~300px)
* until the stylesheet loads and resizes it — a large "logo FOUC" flash on
* every cold load (the bug fixed 2026-06-28). A `viewBox` alone does not set
* intrinsic size, so it does not prevent the flash.
*
* Fix a violation by adding width/height to the root <svg> that preserve the
* viewBox aspect, e.g. viewBox="0 0 470 400" -> width="47" height="40".
*
* Runs in `npm run lint` (CI gate) and the pre-commit hook.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

const ROOT = "public";

function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.toLowerCase().endsWith(".svg")) out.push(p);
}
return out;
}

let files = [];
try {
files = walk(ROOT);
} catch {
// No public/ directory (e.g. a package with no static assets) → nothing to check.
console.log("SVG-dimensions gate: OK (no public/ directory).");
process.exit(0);
}

const violations = [];
for (const f of files) {
const src = readFileSync(f, "utf8");
const m = src.match(/<svg\b[^>]*>/i);
if (!m) {
violations.push([f, "no <svg> root tag found"]);
continue;
}
const tag = m[0];
const hasWidth = /\bwidth\s*=/.test(tag);
const hasHeight = /\bheight\s*=/.test(tag);
if (!hasWidth || !hasHeight) {
const missing = [!hasWidth && "width", !hasHeight && "height"].filter(Boolean).join(" + ");
violations.push([f, `missing ${missing}`]);
}
}

if (violations.length) {
console.error("SVG-dimensions gate: FAIL — public SVGs must declare intrinsic width + height (prevents logo FOUC).");
for (const [f, why] of violations) console.error(` ${f} — ${why}`);
console.error('\nFix: add width="N" height="M" to the root <svg>, preserving the viewBox aspect.');
process.exit(1);
}

console.log(`SVG-dimensions gate: OK (${files.length} public SVG${files.length === 1 ? "" : "s"} checked).`);
2 changes: 1 addition & 1 deletion scripts/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"app/routes/settings-communication.tsx": 721,
"app/components/media-studio/PhotoAnnotator.tsx": 692,
"app/components/editor/PhotoStudio.tsx": 688,
"app/components/portal/sections/ReportView.tsx": 658,
"app/components/portal/sections/ReportView.tsx": 661,
"server/lib/messaging/providers/telnyx-compliance.ts": 657,
"app/hooks/usePhotoOps.ts": 639,
"server/services/inspection/inspection-analytics.service.ts": 623,
Expand Down
8 changes: 4 additions & 4 deletions server/api/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export const integrationsRoutes = createApiRouter()
return c.json({ success: true as const, data }, 200);
})
.openapi(stripeTestRoute, async (c) => {
const env = c.env as unknown as Record<string, string | undefined>;
const env = c.env;
const tenantId = c.get('tenantId');
const uid = c.get('user')?.sub ?? null;
const secretKey = env.STRIPE_SECRET_KEY;
Expand All @@ -220,7 +220,7 @@ export const integrationsRoutes = createApiRouter()
return c.json({ success: true as const, data: entries }, 200);
})
.openapi(resendTestRoute, async (c) => {
const env = c.env as unknown as Record<string, string | undefined>;
const env = c.env;
const tenantId = c.get('tenantId');
const uid = c.get('user')?.sub ?? null;
const key = env.RESEND_API_KEY;
Expand Down Expand Up @@ -253,7 +253,7 @@ export const integrationsRoutes = createApiRouter()
return c.json({ success: true as const, data: { domains } }, 200);
})
.openapi(geminiTestRoute, async (c) => {
const env = c.env as unknown as Record<string, string | undefined>;
const env = c.env;
const tenantId = c.get('tenantId');
const uid = c.get('user')?.sub ?? null;
const key = env.GEMINI_API_KEY;
Expand All @@ -273,7 +273,7 @@ export const integrationsRoutes = createApiRouter()
})
.openapi(emailValidateRoute, async (c) => {
const { provider } = c.req.valid('json');
const env = c.env as unknown as Record<string, string | undefined>;
const env = c.env;
const tenantId = c.get('tenantId');
const uid = c.get('user')?.sub ?? null;

Expand Down
Loading
Loading