diff --git a/components/estimator/estimator-form.tsx b/components/estimator/estimator-form.tsx
index cc30327..25fca5c 100644
--- a/components/estimator/estimator-form.tsx
+++ b/components/estimator/estimator-form.tsx
@@ -4,6 +4,13 @@
// Copyright (C) 2026 Steel-Tech / StructuPath
import { useEffect, useMemo } from "react";
import { STATE_REGISTRY } from "@/content/state-registry";
+import {
+ Section,
+ Field,
+ TextInput,
+ NumberInput,
+ CheckRow,
+} from "./form-primitives";
import {
CRANE_DAY_RATES,
type CraneType,
@@ -335,168 +342,3 @@ export function EstimatorForm({ input, onChange }: Props) {
);
}
-/* ═══════════ PRIMITIVES ═══════════ */
-
-function Section({
- title,
- accent,
- children,
-}: {
- title: string;
- accent: "cyan" | "green" | "magenta" | "amber";
- children: React.ReactNode;
-}) {
- const colorMap = {
- cyan: "text-neon-cyan text-glow-cyan border-neon-cyan/30",
- green: "text-neon-green text-glow-green border-neon-green/30",
- magenta: "text-neon-magenta text-glow-magenta border-neon-magenta/30",
- amber: "text-neon-amber text-glow-amber border-neon-amber/30",
- };
- return (
-
-
-
- ▸ {title}
-
- {children}
-
- );
-}
-
-function Field({
- label,
- suffix,
- hint,
- children,
-}: {
- label: string;
- suffix?: string;
- hint?: string;
- children: React.ReactNode;
-}) {
- return (
-
-
- {children}
- {hint && (
-
{hint}
- )}
-
- );
-}
-
-function TextInput({
- value,
- onChange,
- placeholder,
-}: {
- value: string;
- onChange: (v: string) => void;
- placeholder?: string;
-}) {
- return (
- onChange(e.target.value)}
- placeholder={placeholder}
- className="cyber-input"
- />
- );
-}
-
-function NumberInput({
- value,
- onChange,
- min,
- step,
-}: {
- value: number;
- onChange: (v: number) => void;
- min?: number;
- step?: number;
-}) {
- return (
- {
- const n = parseFloat(e.target.value);
- onChange(Number.isFinite(n) ? n : 0);
- }}
- onFocus={(e) => e.target.select()}
- min={min}
- step={step}
- className="cyber-input"
- />
- );
-}
-
-function CheckRow({
- label,
- sub,
- checked,
- onChange,
-}: {
- label: string;
- sub?: string;
- checked: boolean;
- onChange: (v: boolean) => void;
-}) {
- return (
-
- );
-}
diff --git a/components/estimator/form-primitives.tsx b/components/estimator/form-primitives.tsx
new file mode 100644
index 0000000..c906297
--- /dev/null
+++ b/components/estimator/form-primitives.tsx
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Steel-Tech / StructuPath
+import type { ReactNode } from "react";
+
+/* Presentational primitives for the estimator form — pure, props-only. Split
+ * out of estimator-form.tsx so that file stays focused on state and logic. */
+
+export function Section({
+ title,
+ accent,
+ children,
+}: {
+ title: string;
+ accent: "cyan" | "green" | "magenta" | "amber";
+ children: ReactNode;
+}) {
+ const colorMap = {
+ cyan: "text-neon-cyan text-glow-cyan border-neon-cyan/30",
+ green: "text-neon-green text-glow-green border-neon-green/30",
+ magenta: "text-neon-magenta text-glow-magenta border-neon-magenta/30",
+ amber: "text-neon-amber text-glow-amber border-neon-amber/30",
+ };
+ return (
+
+
+
+ ▸ {title}
+
+ {children}
+
+ );
+}
+
+export function Field({
+ label,
+ suffix,
+ hint,
+ children,
+}: {
+ label: string;
+ suffix?: string;
+ hint?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {children}
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
+
+export function TextInput({
+ value,
+ onChange,
+ placeholder,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+}) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className="cyber-input"
+ />
+ );
+}
+
+export function NumberInput({
+ value,
+ onChange,
+ min,
+ step,
+}: {
+ value: number;
+ onChange: (v: number) => void;
+ min?: number;
+ step?: number;
+}) {
+ return (
+ {
+ const n = parseFloat(e.target.value);
+ onChange(Number.isFinite(n) ? n : 0);
+ }}
+ onFocus={(e) => e.target.select()}
+ min={min}
+ step={step}
+ className="cyber-input"
+ />
+ );
+}
+
+export function CheckRow({
+ label,
+ sub,
+ checked,
+ onChange,
+}: {
+ label: string;
+ sub?: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/public/sw.js b/public/sw.js
index 45ca676..d31334d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -7,7 +7,7 @@
* - Cache-first for static assets (_next/static, fonts, images)
*/
-const VERSION = "ironforge-v1";
+const VERSION = "ironforge-v2";
const STATIC_CACHE = `${VERSION}-static`;
const RUNTIME_CACHE = `${VERSION}-runtime`;
@@ -77,17 +77,11 @@ self.addEventListener("fetch", (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
- // API: network-first, no offline fallback (let the app surface errors)
+ // API: network-only. Never cache API responses — a stale cached answer from
+ // the AI endpoints (or regulatory state data) would be misleading. Offline
+ // simply fails and the app surfaces the error.
if (isApiRequest(url)) {
- event.respondWith(
- fetch(request)
- .then((response) => {
- const copy = response.clone();
- caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy));
- return response;
- })
- .catch(() => caches.match(request)),
- );
+ event.respondWith(fetch(request));
return;
}