diff --git a/app/components/editor/EditorHumanCheck.test.tsx b/app/components/editor/EditorHumanCheck.test.tsx
new file mode 100644
index 000000000..b26320a50
--- /dev/null
+++ b/app/components/editor/EditorHumanCheck.test.tsx
@@ -0,0 +1,41 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi } from "vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+
+vi.mock("~/lib/turnstile", () => ({ useTurnstileWidget: () => undefined }));
+
+import { EditorHumanCheck } from "./EditorHumanCheck";
+import { useEnsureHuman } from "~/components/OutboundVerification";
+
+function Publish({ onResult }: { onResult: (ok: boolean) => void }) {
+ const ensureHuman = useEnsureHuman();
+ return ;
+}
+
+const click = async () => { await act(async () => { fireEvent.click(screen.getByText("publish")); }); };
+
+describe("EditorHumanCheck", () => {
+ it("makes a publish inside a new company's window ask for the check", async () => {
+ const onResult = vi.fn();
+ render(
+
+
+ ,
+ );
+ await click();
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ expect(onResult).not.toHaveBeenCalled();
+ });
+
+ it("lets a publish through at once when there is no window", async () => {
+ const onResult = vi.fn();
+ render(
+
+
+ ,
+ );
+ await click();
+ expect(onResult).toHaveBeenCalledWith(true);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+});
diff --git a/app/components/editor/EditorHumanCheck.tsx b/app/components/editor/EditorHumanCheck.tsx
new file mode 100644
index 000000000..4652b7692
--- /dev/null
+++ b/app/components/editor/EditorHumanCheck.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from "react";
+import { OutboundVerificationProvider } from "~/components/OutboundVerification";
+
+/**
+ * Mounts the human check for the full-screen editor, which sits outside the
+ * workspace layout and so has none of its own. `coolingWindow` is what the editor's
+ * loader read from the session context: non-null only while a new company's first
+ * 24 hours are open.
+ */
+export function EditorHumanCheck({
+ coolingWindow,
+ children,
+}: {
+ coolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/components/editor/PublishModal.tsx b/app/components/editor/PublishModal.tsx
index 2a49c8f3f..e0c2c67be 100644
--- a/app/components/editor/PublishModal.tsx
+++ b/app/components/editor/PublishModal.tsx
@@ -21,6 +21,12 @@ export interface PublishModalProps {
publishError: string | null;
isSubmitting: boolean;
onClose: () => void;
+ /**
+ * The instant a new company's cooling window closes, when the host route has it.
+ * The full-screen editor sits outside the workspace layout, so the session
+ * context this modal otherwise reads is absent there; it passes its own.
+ */
+ coolingUnlockAtMs?: number | null;
/**
* Publish the report. `markComplete` also closes the order axis first, and
* `extras` carries the fields collected below — the owning route must send them
@@ -57,7 +63,7 @@ export interface PublishModalProps {
onAutoSignToggle: (checked: boolean) => void;
}
-export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment }: PublishModalProps) {
+export function PublishModal({ open, progress, status, publishError, isSubmitting, onClose, onPublish, autoSign, onAutoSignToggle, isAmendment, coolingUnlockAtMs }: PublishModalProps) {
// The order lifecycle does not gate publishing (report and order axes are
// independent). When the on-site work is not yet marked complete, offer an
// advisory choice — do both, or just publish — but never block.
@@ -72,7 +78,8 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin
// Same `unlockAtMs` the banner reads, so the two cannot name different times;
// it is non-null only while the window is open, and the server decides that
// (see resolveCoolingWindowForSession) — no clock arithmetic here.
- const unlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null;
+ const sessionUnlockAtMs = useSessionContext()?.outboundCoolingWindow?.unlockAtMs ?? null;
+ const unlockAtMs = coolingUnlockAtMs ?? sessionUnlockAtMs;
const timeZone = useDisplayTimeZone();
const dtFormat = useChromeDateTimeFormat();
diff --git a/app/components/editor/publish-modal-cooling-notice.test.tsx b/app/components/editor/publish-modal-cooling-notice.test.tsx
index 69e60d0cc..c270507bf 100644
--- a/app/components/editor/publish-modal-cooling-notice.test.tsx
+++ b/app/components/editor/publish-modal-cooling-notice.test.tsx
@@ -39,6 +39,12 @@ const props = {
};
describe('PublishModal — outbound cooling window', () => {
+ it('shows the notice from its own prop when the host route has no session context (the full-screen editor)', () => {
+ unlockAtMs = null;
+ render();
+ expect(screen.getByText(/less than a day old/i)).toBeTruthy();
+ });
+
it('says nothing when the company is not in a window', () => {
unlockAtMs = null;
render();
diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx
index 9081e0d2b..255a1ef6e 100644
--- a/app/routes/inspection-edit.tsx
+++ b/app/routes/inspection-edit.tsx
@@ -8,6 +8,7 @@ import { findingKey } from "~/hooks/findings/shared";
import { useDisplayLocale } from "~/hooks/useSessionContext";
import { useGuardedSubmit } from "~/hooks/useGuardedSubmit";
import { useEnsureHuman } from "~/components/OutboundVerification";
+import { EditorHumanCheck } from "~/components/editor/EditorHumanCheck";
import { useFindings, type AttachedRepairItem } from "~/hooks/useFindings";
import { usePhotoOps } from "~/hooks/usePhotoOps";
import { useScopeLoader } from "~/hooks/useScopeLoader";
@@ -146,7 +147,17 @@ export { action } from "./inspection-edit/action.server";
/* Component */
/* ------------------------------------------------------------------ */
-export default function InspectionEditPage() {
+/** The editor sits outside the workspace layout, so it mounts the human check itself. */
+export default function InspectionEditRoute() {
+ const { outboundCoolingWindow } = useLoaderData();
+ return (
+
+
+
+ );
+}
+
+function InspectionEditPage() {
const loaderData = useLoaderData();
const displayLocale = useDisplayLocale();
const fetcher = useFetcher();
@@ -1876,6 +1887,7 @@ export default function InspectionEditPage() {
status={state.inspection.status as string}
publishError={publishError} isSubmitting={publishBusy}
isAmendment={loaderData.nextPublishIsAmendment}
+ coolingUnlockAtMs={loaderData.outboundCoolingWindow?.unlockAtMs ?? null}
onClose={() => { setPublishError(null); state.setShowPublishModal(false); }}
onPublish={async (markComplete: boolean, extras: PublishExtras) => {
if (!(await ensureHuman())) return;
diff --git a/app/routes/inspection-edit/loader.server.test.ts b/app/routes/inspection-edit/loader.server.test.ts
index 733b046f0..006033220 100644
--- a/app/routes/inspection-edit/loader.server.test.ts
+++ b/app/routes/inspection-edit/loader.server.test.ts
@@ -30,6 +30,7 @@ const reportDataGet = vi.fn();
* — so it is the default, and the tests that care set it.
*/
let statutoryDetailsResponse: Response | null = null;
+let sessionContextBody: unknown = null; // a Response body is single-use, so the stub builds one per call
vi.mock('~/lib/session.server', () => ({
requireToken: vi.fn().mockResolvedValue('tok'),
@@ -64,7 +65,7 @@ vi.mock('~/lib/api-client.server', () => ({
},
},
tags: { index: { $get: async () => null } },
- sessionContext: { context: { $get: async () => null } },
+ sessionContext: { context: { $get: async () => (sessionContextBody === null ? null : json(sessionContextBody)) } },
defectCategories: { 'defect-categories': { $get: async () => null } },
})),
}));
@@ -137,6 +138,7 @@ beforeEach(() => {
ratingLevels: [],
}));
statutoryDetailsResponse = null; // the ordinary inspection: no statutory form
+ sessionContextBody = null;
});
describe('editor loader — item attributes survive the report-data overlay', () => {
@@ -269,3 +271,21 @@ describe('editor loader — nextPublishIsAmendment', () => {
expect(data.nextPublishIsAmendment).toBe(false);
});
});
+
+describe('loader — the human check for a new company', () => {
+ it('passes the open cooling window and the site key through, so the editor can ask for the check', async () => {
+ sessionContextBody = { outboundCoolingWindow: { unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' } };
+ const data = await loadAll();
+ expect(data.outboundCoolingWindow).toEqual({ unlockAtMs: 1_800_000_000_000, turnstileSiteKey: 'site-key-1' });
+ });
+
+ // One load per test: the other stubs hand out single-use Response bodies.
+ it.each([
+ ['the window has closed', { outboundCoolingWindow: null }],
+ ['the field is absent', {}],
+ ['the session context could not be read', null],
+ ])('is null when %s', async (_name, body) => {
+ sessionContextBody = body;
+ expect((await loadAll()).outboundCoolingWindow).toBeNull();
+ });
+});
diff --git a/app/routes/inspection-edit/loader.server.ts b/app/routes/inspection-edit/loader.server.ts
index 550ff8f8e..5465e9d63 100644
--- a/app/routes/inspection-edit/loader.server.ts
+++ b/app/routes/inspection-edit/loader.server.ts
@@ -207,14 +207,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
let tenantSlug: string | null = null;
let videoProvider: "r2" | "stream" = "r2";
let collabEditing = false;
+ // The full-screen editor is declared outside the workspace layout, so it has no
+ // session context of its own; this is what lets it ask for the human check.
+ let outboundCoolingWindow: { unlockAtMs: number; turnstileSiteKey: string | null } | null = null;
if (sessRes?.ok) {
const sb = await sessRes.json() as {
data?: {
branding?: { tenantSlug?: string | null };
videoProvider?: "r2" | "stream";
collabEditing?: boolean;
+ outboundCoolingWindow?: { unlockAtMs: number; turnstileSiteKey: string | null } | null;
};
};
+ outboundCoolingWindow = sb.data?.outboundCoolingWindow ?? null;
tenantSlug = sb.data?.branding?.tenantSlug ?? null;
// Plan 7 — resolved video backend provider for this tenant (default 'r2').
// Drives VideoCapture/VideoPlayer branch selection in the editor.
@@ -371,5 +376,5 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
// `report_versions.length`; both mean "a version already exists".
const nextPublishIsAmendment = isReportPublished(inspection.reportStatus);
- return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment };
+ return { inspection, schema, results, resultId, ratingLevels, token, tagLibrary, tenantSlug, streamCustomerSubdomain, videoProvider, collabEditing, outboundCoolingWindow, templateSnapshot, revisionStatus, pcaNarrative, defectCategories, units, unitProgress, unitInspectionMode, compliance, relianceText, commercialPresets, statutoryDetails, statutoryCoverage, nextPublishIsAmendment };
}
diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json
index 705dcf863..633dbeeb3 100644
--- a/scripts/file-size-baseline.json
+++ b/scripts/file-size-baseline.json
@@ -1,5 +1,5 @@
{
- "app/routes/inspection-edit.tsx": 2605,
+ "app/routes/inspection-edit.tsx": 2617,
"app/routes/inspector-portal.tsx": 1396,
"scripts/verify-statutory-render.mjs": 1283,
"server/durable-objects/inspection-doc.ts": 1028,