diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index f3b9f0a1..a9843d48 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -43,3 +43,21 @@ Adopts the covenant scale (LBTAS, v2 floor) and carries its own record instance.
## Product-specific notes (last, per the ordering rule)
The reference economy. Its operator/federation model is the named reference for the substrate's frontend-as-operator design target.
+
+### Browser garden planner
+
+The Next.js `/garden-planner` page is a member-local planning aid, not a ledger
+or a new protocol implementation. It reuses the existing square-foot gardening
+utility for spacing, adjacent-crop rules, and fixed maturity estimates. The UI
+states the limits of those estimates.
+
+Saving is explicit and browser-local; versioned JSON export/import provides a
+portable editable copy without account or vendor services. The format contains
+only version, planting date, and crop layout. It is not sent to the shared record
+or federation. No settlement, covenant, reputation, identity, or governance
+binding changes, and the in-review statuses above remain in review.
+
+`frontend/tests/gardenPlanner.test.cjs` checks crop totals, leap-day harvest
+dates, adjacent-crop conflicts, resize impact, portable round-tripping, invalid
+imports, and storage errors. This is feature verification, not a claim that the
+delegated JFA invariants are now enforced by this repository.
diff --git a/frontend/README.md b/frontend/README.md
index f5a7b57b..c3431f32 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -7,6 +7,38 @@ This directory hosts the web interfaces for Fruitful.
- `app/` – Next.js site providing the main user interface.
- `chat-ui/` – Conversational interface built with Vite and React. See [chat-ui/ARCHITECTURE.md](chat-ui/ARCHITECTURE.md) for architectural details.
+## Garden planner
+
+Run `npm install` then `npm run dev` in this directory and open `/garden-planner`,
+or follow **Garden planner** in the site navigation. This page needs no backend
+server, account, API key, or external service.
+
+Choose a bed from 1–8 feet in each dimension, set the planting date, select a crop,
+then click or keyboard-activate a square. **Erase a square** clears a planting.
+Counts, adjacent-crop warnings, and estimated harvest dates reuse
+`backend/utils/squareFootGardening.js`. The four supported crops are tomato,
+lettuce, carrot, and basil. Harvest estimates use fixed maturity intervals;
+they do not account for climate, soil, variety, or the local growing season.
+
+**Save plan** explicitly writes one plan to this browser's local storage.
+**Export JSON** downloads a portable copy; **Import JSON** restores an editable
+plan. Imports accept version 1 rectangular beds, supported crops, and a real
+date, with a 32 KB file limit. Invalid imports leave the current bed intact.
+Shrinking a bed requires confirmation if planted squares would be removed.
+Browser storage can be cleared or unavailable, so export a file for a backup.
+This is a local planning format, not a federated Plan protocol message.
+
+Run `npm run test:planner` for dependency-free planner tests, `npm run lint` for
+frontend lint, or `npm test` for planner tests followed by a production build.
+
+Design context: [Agrinet's local-growing purpose (Slack, October 19, 2026)](https://ntari.slack.com/archives/C0AENHR5KG8/p1792416355639889)
+and [modular planting coordination (Confluence)](https://ntari.atlassian.net/wiki/spaces/Agrinet/pages/6815770).
+The current repository's `CLAUDE.md` and `CONFORMANCE.md` govern implementation;
+older roadmap text does not supersede those invariants.
+
+Screenshots: [editable bed](docs/screenshots/garden-planner.png) and
+[neighbor conflicts](docs/screenshots/garden-planner-conflicts.png).
+
## Environment variables
Both sub-apps rely on environment variables to communicate with backend services.
diff --git a/frontend/app/garden-planner/page.js b/frontend/app/garden-planner/page.js
new file mode 100644
index 00000000..218763ea
--- /dev/null
+++ b/frontend/app/garden-planner/page.js
@@ -0,0 +1,10 @@
+import GardenPlanner from '../../components/GardenPlanner';
+
+export const metadata = {
+ title: 'Garden planner · Agrinet',
+ description: 'Plan a square-foot garden with existing Agrinet crop spacing and harvest estimates. Your plan stays in your browser.',
+};
+
+export default function GardenPlannerPage() {
+ return ;
+}
diff --git a/frontend/components/GardenPlanner.js b/frontend/components/GardenPlanner.js
new file mode 100644
index 00000000..6b7b2a3c
--- /dev/null
+++ b/frontend/components/GardenPlanner.js
@@ -0,0 +1,194 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ CROPS, MAX_PLAN_BYTES, createPlan, analyzePlan, previewResize, plantSquare,
+ serializePlan, parsePlan, saveLocalPlan, loadLocalPlan,
+} from '../lib/gardenPlanner';
+import { getPlantsPerSquare } from '../../backend/utils/squareFootGardening';
+import styles from './GardenPlanner.module.css';
+
+const cropNames = { tomato: 'Tomato', lettuce: 'Lettuce', carrot: 'Carrot', basil: 'Basil' };
+const dateLabel = value => new Date(`${value}T00:00:00Z`).toLocaleDateString('en', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
+const squareLabel = ([r, c]) => `row ${r + 1}, column ${c + 1}`;
+
+export default function GardenPlanner() {
+ const [plan, setPlan] = useState(null);
+ const [selectedCrop, setSelectedCrop] = useState('tomato');
+ const [dimensions, setDimensions] = useState({ rows: '4', columns: '4' });
+ const [savedText, setSavedText] = useState('');
+ const [notice, setNotice] = useState('');
+ const [error, setError] = useState('');
+ const [pendingResize, setPendingResize] = useState(null);
+
+ useEffect(() => {
+ const today = new Date();
+ const localDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+ let initial = createPlan(4, 4, localDate);
+ try {
+ const saved = loadLocalPlan(window.localStorage);
+ if (saved) {
+ initial = saved;
+ setSavedText(serializePlan(saved));
+ setNotice('Saved plan restored from this browser.');
+ }
+ } catch {
+ setError('Your saved plan could not be restored. You can still plan and export a file.');
+ }
+ setDimensions({ rows: String(initial.layout.length), columns: String(initial.layout[0].length) });
+ setPlan(initial);
+ }, []);
+
+ if (!plan) return
Garden planner
Opening your garden bed…
;
+
+ let analysis;
+ let validationError = '';
+ try { analysis = analyzePlan(plan); } catch (err) { validationError = err.message; }
+ const dirty = !analysis || serializePlan(plan) !== savedText;
+ const conflictSquares = new Set(analysis?.conflicts.flatMap(pair => [pair.first.join(','), pair.second.join(',')]) || []);
+
+ function edit(next) {
+ setPlan(next);
+ setNotice('');
+ setError('');
+ setPendingResize(null);
+ }
+
+ function applySize(event) {
+ event.preventDefault();
+ try {
+ const preview = previewResize(plan, Number(dimensions.rows), Number(dimensions.columns));
+ if (preview.removedSquares > 0) {
+ setPendingResize(preview);
+ return;
+ }
+ edit(preview.plan);
+ } catch (err) { setNotice(''); setError(err.message); }
+ }
+
+ function save() {
+ try {
+ saveLocalPlan(window.localStorage, plan);
+ setSavedText(serializePlan(plan));
+ setError('');
+ setNotice('Plan saved in this browser. Export a copy to keep it elsewhere.');
+ } catch { setNotice(''); setError('Could not save in this browser. Export your plan to keep a copy.'); }
+ }
+
+ function exportFile() {
+ const url = URL.createObjectURL(new Blob([serializePlan(plan)], { type: 'application/json' }));
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'agrinet-garden-plan.json';
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setNotice('Plan exported. Import the JSON file here to edit it again.');
+ }
+
+ async function importFile(event) {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) return;
+ try {
+ if (file.size > MAX_PLAN_BYTES) throw new Error('Plan file is too large. Maximum size is 32 KB.');
+ const next = parsePlan(await file.text());
+ if (analysis?.occupiedSquares > 0 && !window.confirm('Replace the current bed with this imported plan? Unsaved changes will be lost.')) return;
+ edit(next);
+ setDimensions({ rows: String(next.layout.length), columns: String(next.layout[0].length) });
+ setNotice('Plan imported. Save in this browser to keep it after reloading.');
+ } catch (err) { setNotice(''); setError(err.message); }
+ }
+
+ return (
+
+
+
AGRINET / GROWING TOGETHER
+
A little space. A growing plan.
+
Choose a crop, then plant a square. See what fits and when it could be ready.
+
+
+
+ {dirty ? 'Unsaved plan' : 'Saved in this browser'}
+
+
+
+
+
+
+
Your plan stays on this device. Saving uses this browser’s storage; export a file to move it.
+ {notice &&
{notice}
}
+ {(error || validationError) &&
{error || validationError}
}
+ {pendingResize &&
+
Resizing to {pendingResize.plan.layout[0].length} × {pendingResize.plan.layout.length} ft removes {pendingResize.removedSquares} planted {pendingResize.removedSquares === 1 ? 'square' : 'squares'}.
+
+
+
+
+
}
+
+
+
+
+
+
{plan.layout[0].length} × {plan.layout.length} ft bed
Each square = 1 sq ft
+
{selectedCrop ? `Planting ${cropNames[selectedCrop].toLowerCase()}. Select any square to place it.` : 'Erasing. Select a planted square to clear it.'}
+
+
+ {plan.layout.flatMap((row, r) => row.map((crop, c) => ))}
+
+
+
+
Bed area
{plan.layout.length * plan.layout[0].length} sq ft
+
Planted squares
{analysis?.occupiedSquares ?? '—'}
+
Total plants
{analysis?.totalPlants ?? '—'}
+
+
+
+
+ {analysis?.conflicts.length > 0 &&
+
Give these crops different neighbors
+
{analysis.conflicts.map(pair =>
+ {cropNames[pair.crops[0]]} ({squareLabel(pair.first)}) and {cropNames[pair.crops[1]]} ({squareLabel(pair.second)}) are incompatible in Agrinet’s current crop rules.
+
)}
+
Choose another crop for either square, or erase it.
+ }
+
+
+
A look ahead
Estimated harvests
+ {!analysis ?
Choose a valid planting date to see estimates.
: analysis.crops.length === 0 ?
Your harvest starts with one square. Choose a crop above to begin.
:
+
Crop counts and estimated harvest dates
+
Crop
Squares
Plants
Estimated harvest
+ {analysis.crops.map(crop =>
{cropNames[crop.crop]}
{crop.squares}
{crop.plants}
)}
+
}
+
Uses Agrinet’s existing spacing, neighbor rules, and fixed days to maturity for four crops. Estimates do not account for variety, weather, soil, or your local growing season.