-
- {/* Additional costs (cost mode only) */}
- {mode === 'cost' && (
-
+ {profile.description &&
{profile.description}
}
+ {profile.edible_portions && (
+
Edible portions: {profile.edible_portions}
)}
-
-
- {/* Calculate */}
-
-
-
- {mode === 'cost' ? 'Calculate cost per pound' : 'Calculate pounds to buy'}
-
- {!canCalculate && (
-
- Choose a species, what you have, and what you're making to get started.
-
+ {profile.url && (
+
+ Read more →
+
)}
-
-
- {/* Result: the live region stays mounted (so the first result is announced) and holds only the
- answer. Actions and save status live outside it to avoid duplicate announcements. */}
-
-
-
- {result !== null && (
- <>
-
- {mode === 'cost' ? `Your cost per lb of ${toState}` : `You need to buy (${fromState})`}
-
-
- {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`}
-
-
- {mode === 'cost'
- ? `At ${resultMeta?.yieldPercent}% yield from ${fromState} to ${toState}`
- : `${result.toFixed(1)} lbs of ${fromState} makes ${resultMeta?.targetWeight} lbs of ${toState}`
- }
-
- >
- )}
+ )}
+
+
+ {/* Step 2: the numbers */}
+
+ Your numbers
+
+ {mode === 'cost' ? (
+
+
+ What you pay per lb ({fromState || 'whole fish'})
+
+
+
+ ) : (
+
+
+ Pounds of {toState || 'finished product'} you need
+
+
+
+ )}
+
+
+
+ Yield how much is left after cutting
+
+
+
+ {!currentConversion
+ ? 'Choose what you’re making to fill this in.'
+ : yieldRange
+ ? `Typical ${yieldRange[0]}–${yieldRange[1]}%. It changes with fish size and cutting skill.`
+ : 'No typical range reported for this cut.'}
+
+ {presets.length > 0 && (
+
+ {presets.map(([name, value]) => (
+ setYieldPercent(String(value))}
+ className="min-h-[3.25rem] rounded-xl border-2 border-line-strong bg-surface-raised px-2 text-sm font-bold leading-tight text-text-primary transition-colors hover:border-accent aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-white"
+ >
+ {name}{value}%
+
+ ))}
+ )}
+
- {result !== null && (user ? (
-
-
- Save calculation
-
-
- CSV
-
-
- Excel
-
-
- {saveStatus}
-
-
- ) : (
-
- Want to keep this? Sign in to save your calculations.
-
- ))}
+ {mode === 'cost' && (
+
+
Other costs
+
+
-
-
+ )}
+
+
+ {/* Keep it */}
+ {result !== null && (
+ user ? (
+
+
+ Save calculation
+
+
+ CSV
+
+
+ Excel
+
+
+ {saveStatus}
+
+
+ ) : (
+
+ Want to keep this? Sign in to save your calculations.
+
+ )
+ )}
{/* Abbreviation reference (collapsed: regulars know these, newcomers can open it) */}
@@ -796,18 +785,18 @@ const Calculator = () => {
Recent community calculations
- {publicHistory.slice(0, 8).map((calc) => (
-
+ {publicHistory.slice(0, 8).map((c) => (
+
-
{calc.name || calc.species}
-
{calc.product} · {calc.yield}% yield
+
{c.name || c.species}
+
{c.product} · {c.yield}% yield
- ${parseFloat(calc.result).toFixed(2)}/lb
+ ${parseFloat(c.result).toFixed(2)}/lb
- {new Date(calc.date).toLocaleDateString()}
+ {new Date(c.date).toLocaleDateString()}
@@ -820,6 +809,41 @@ const Calculator = () => {
)}
)}
+
+ {/* The answer, pinned to the bottom of the screen so it is always in view while numbers change */}
+
+
+ {result === null ? (
+
{dockPrompt}
+ ) : (
+ <>
+
+ {mode === 'cost' ? `Your cost per lb of ${toState}` : `Buy this much ${fromState}`}
+
+
+ {mode === 'cost' ? dollars(result) : result.toFixed(1)}
+ {mode === 'cost' ? '/lb' : 'lbs'}
+
+
+ {mode === 'cost'
+ ? `${yieldPercent || 100}% yield · ${fromState} → ${toState}`
+ : `makes ${parseAmount(targetWeight).toLocaleString('en-US')} lbs of ${toState} · ${yieldPercent || 100}% yield`}
+
+ {mode === 'cost' && extras.length > 0 && (
+
+ {[['Fish', calc.breakdown.fish], ...extras].map(([name, v]) => `${name} ${dollars(v)}`).join(' + ')}
+
+ )}
+ >
+ )}
+
+
+ {announcement}
);
};
diff --git a/app/src/lib/calcEngine.js b/app/src/lib/calcEngine.js
index de71238..f000feb 100644
--- a/app/src/lib/calcEngine.js
+++ b/app/src/lib/calcEngine.js
@@ -52,7 +52,10 @@
* 'incoming' — processingCost is divided by yield (applied to Round lbs)
* 'outgoing' — processingCost is added directly (applied to finished-product lbs)
* @property {number|string} [coldStorage=0] $/lb cold-storage addend (finished-product basis)
- * @property {number|string} [shipping=0] $/lb shipping addend (finished-product basis)
+ * @property {number|string} [shipping=0] $/lb shipping charge, applied to either incoming or outgoing weight.
+ * @property {'incoming'|'outgoing'} [shippingWeightType='outgoing']
+ * 'incoming' — shipping the starting fish; divided by yield like incoming processing
+ * 'outgoing' — shipping the finished product; added directly
*
* @property {boolean} [showTimeTracking=false]
* When true, labor costs from processingSteps are summed and added per lb.
@@ -74,6 +77,9 @@
* weight mode: lbs of Round needed
* @property {number} appliedDiscount
* Percentage discount applied (0 when none). Always 0 in weight mode.
+ * @property {{fish: number, processing: number, shipping: number, coldStorage: number, labor: number}|null} breakdown
+ * What each part adds per lb of finished product, before any discount (the parts sum to the
+ * undiscounted result). null in weight mode.
*/
/**
@@ -92,6 +98,7 @@ export function calculate(inputs) {
weightType = 'incoming',
coldStorage = 0,
shipping = 0,
+ shippingWeightType = 'outgoing',
showTimeTracking = false,
processingSteps = [],
showEconomyOfScale = false,
@@ -109,6 +116,7 @@ export function calculate(inputs) {
return {
result: y > 0 ? target / y : 0,
appliedDiscount: 0,
+ breakdown: null,
};
}
@@ -118,30 +126,29 @@ export function calculate(inputs) {
const cold = parseFloat(coldStorage) || 0;
const ship = parseFloat(shipping) || 0;
- // Base cost: raw $/lb divided by yield fraction
- let baseRes = c / y;
-
- // Processing cost: incoming weight → divide by yield; outgoing → add directly
- if (weightType === 'incoming') {
- baseRes += proc / y;
- } else {
- baseRes += proc;
- }
-
- // Cold storage and shipping are per-lb addends on the finished-product basis
- baseRes += cold + ship;
+ // A per-lb charge on incoming (starting) weight is spread over fewer finished pounds, so it is
+ // divided by yield; a charge on outgoing (finished) weight is added as-is.
+ const perFinishedLb = (amount, basis) => (basis === 'incoming' ? amount / y : amount);
// Labor / time-tracking costs: summed across steps, added per lb
+ let labor = 0;
if (showTimeTracking) {
- let totalTimeCost = 0;
processingSteps.forEach(step => {
const time = parseFloat(step.timeMinutes) || 0;
const laborRate = parseFloat(step.laborCostPerHour) || 0;
- totalTimeCost += (time / 60) * laborRate;
+ labor += (time / 60) * laborRate;
});
- baseRes += totalTimeCost;
}
+ const breakdown = {
+ fish: c / y,
+ processing: perFinishedLb(proc, weightType),
+ shipping: perFinishedLb(ship, shippingWeightType),
+ coldStorage: cold, // finished-product basis
+ labor,
+ };
+ let baseRes = breakdown.fish + breakdown.processing + breakdown.shipping + breakdown.coldStorage + breakdown.labor;
+
// Economy-of-scale discount: find highest qualifying tier
let appliedDiscount = 0;
if (showEconomyOfScale && quantity) {
@@ -159,5 +166,5 @@ export function calculate(inputs) {
baseRes = baseRes * (1 - appliedDiscount / 100);
}
- return { result: baseRes, appliedDiscount };
+ return { result: baseRes, appliedDiscount, breakdown };
}
diff --git a/app/src/lib/calcEngine.test.js b/app/src/lib/calcEngine.test.js
index 1728d6a..60e9aba 100644
--- a/app/src/lib/calcEngine.test.js
+++ b/app/src/lib/calcEngine.test.js
@@ -157,6 +157,78 @@ describe('calculate — cold storage and shipping addends', () => {
});
});
+// ---------------------------------------------------------------------------
+// Shipping — incoming vs outgoing weight basis
+// ---------------------------------------------------------------------------
+
+describe('calculate — shipping weight basis', () => {
+ const cases = [
+ {
+ label: 'incoming basis: shipping the starting fish is spread over the finished pounds',
+ // cost=2, ship=0.20, yield=50%: 2/0.5 + 0.20/0.5 = 4.40
+ inputs: { mode: 'cost', yieldPercent: 50, cost: 2, shipping: 0.2, shippingWeightType: 'incoming' },
+ expected: { result: 4.4, appliedDiscount: 0 },
+ },
+ {
+ label: 'outgoing basis: shipping the finished product is added directly',
+ // cost=2, ship=0.20, yield=50%: 2/0.5 + 0.20 = 4.20
+ inputs: { mode: 'cost', yieldPercent: 50, cost: 2, shipping: 0.2, shippingWeightType: 'outgoing' },
+ expected: { result: 4.2, appliedDiscount: 0 },
+ },
+ {
+ label: 'processing and shipping bases are independent',
+ // cost=2, proc=0.50 outgoing, ship=0.20 incoming, yield=50%: 4 + 0.50 + 0.40 = 4.90
+ inputs: {
+ mode: 'cost', yieldPercent: 50, cost: 2,
+ processingCost: 0.5, weightType: 'outgoing', shipping: 0.2, shippingWeightType: 'incoming',
+ },
+ expected: { result: 4.9, appliedDiscount: 0 },
+ },
+ ];
+
+ cases.forEach(({ label, inputs, expected }) => {
+ it(label, () => {
+ const actual = calculate(inputs);
+ expect(actual.result).toBeCloseTo(expected.result, 10);
+ expect(actual.appliedDiscount).toBe(0);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Breakdown — what each cost adds per lb of finished product
+// ---------------------------------------------------------------------------
+
+describe('calculate — breakdown', () => {
+ it('reports each part per lb of finished product, and the parts add up to the result', () => {
+ // cost=2, proc=0.50 incoming, ship=0.20 incoming, cold=0.10, yield=50%
+ const actual = calculate({
+ mode: 'cost', yieldPercent: 50, cost: 2,
+ processingCost: 0.5, weightType: 'incoming', shipping: 0.2, shippingWeightType: 'incoming', coldStorage: 0.1,
+ });
+ expect(actual.breakdown.fish).toBeCloseTo(4, 10);
+ expect(actual.breakdown.processing).toBeCloseTo(1, 10);
+ expect(actual.breakdown.shipping).toBeCloseTo(0.4, 10);
+ expect(actual.breakdown.coldStorage).toBeCloseTo(0.1, 10);
+ expect(actual.breakdown.labor).toBe(0);
+ const sum = Object.values(actual.breakdown).reduce((a, b) => a + b, 0);
+ expect(sum).toBeCloseTo(actual.result, 10);
+ });
+
+ it('is taken before any bulk discount', () => {
+ const actual = calculate({
+ mode: 'cost', yieldPercent: 50, cost: 2,
+ showEconomyOfScale: true, quantity: '100', priceBreaks: DEFAULT_PRICE_BREAKS,
+ });
+ expect(actual.breakdown.fish).toBeCloseTo(4, 10);
+ expect(actual.result).toBeCloseTo(4 * 0.95, 10);
+ });
+
+ it('is null in weight mode', () => {
+ expect(calculate({ mode: 'weight', yieldPercent: 42, targetWeight: 100 }).breakdown).toBeNull();
+ });
+});
+
// ---------------------------------------------------------------------------
// Labor / time-tracking costs
// ---------------------------------------------------------------------------
diff --git a/app/src/lib/numberInput.js b/app/src/lib/numberInput.js
new file mode 100644
index 0000000..ddca59b
--- /dev/null
+++ b/app/src/lib/numberInput.js
@@ -0,0 +1,25 @@
+/**
+ * numberInput.js — read a number the way people type it into a price, weight or yield box.
+ *
+ * The calculator's fields are text inputs (so a "$" or "lbs" can sit beside the number), which means
+ * they accept anything. parseFloat alone gets common entries silently wrong: "$4.50" → NaN, and
+ * "1,000" → 1. This reads what people actually type and returns NaN for anything it can't be sure
+ * of, so the UI can ask for a number instead of showing a wrong answer.
+ */
+
+// "$" in front; "%", "lb" or "lbs" behind
+const DECORATION = /^\$\s*|\s*(%|lbs?)$/gi;
+
+/**
+ * @param {string|number|null|undefined} raw
+ * @returns {number} the amount, or NaN when the text is not a plain non-negative number
+ */
+export function parseAmount(raw) {
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw >= 0 ? raw : NaN;
+ const text = String(raw ?? '').trim().replace(DECORATION, '');
+
+ if (/^\d+,\d{1,2}$/.test(text)) return Number(text.replace(',', '.')); // decimal comma: 4,50
+ if (/^\d{1,3}(,\d{3})+(\.\d*)?$/.test(text)) return Number(text.replace(/,/g, '')); // thousands: 1,000.5
+ if (/^(\d+\.?\d*|\.\d+)$/.test(text)) return Number(text); // plain: 4, 4.5, 4., .5
+ return NaN;
+}
diff --git a/app/src/lib/numberInput.test.js b/app/src/lib/numberInput.test.js
new file mode 100644
index 0000000..6fec144
--- /dev/null
+++ b/app/src/lib/numberInput.test.js
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest';
+import { parseAmount } from './numberInput.js';
+
+describe('parseAmount', () => {
+ it.each([
+ ['4.50', 4.5],
+ ['4', 4],
+ ['4.', 4],
+ ['.5', 0.5],
+ [' 4.50 ', 4.5],
+ ['$4.50', 4.5],
+ ['$ 4.50', 4.5],
+ ['42%', 42],
+ ['100 lbs', 100],
+ ['100lb', 100],
+ ['1,000', 1000],
+ ['12,500.75', 12500.75],
+ ['4,50', 4.5], // decimal comma: one or two digits after a single comma
+ ['4,5', 4.5],
+ [4.5, 4.5],
+ ])('reads %j as %d', (typed, expected) => {
+ expect(parseAmount(typed)).toBe(expected);
+ });
+
+ it.each([
+ [''],
+ [' '],
+ ['abc'],
+ ['4a5'],
+ ['1.2.3'],
+ ['1,00,0'],
+ ['-4'],
+ [null],
+ [undefined],
+ [Number.NaN],
+ ])('rejects %j instead of guessing', (typed) => {
+ expect(parseAmount(typed)).toBeNaN();
+ });
+});
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index ddd7100..7bcac39 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -8,8 +8,8 @@ Supersedes the navy/rust direction in `docs/superpowers/specs/2026-03-27-brandin
Fishers, chefs and fishmongers who use apps every day but are busy: on a dock in glare, in a
cold-room, in a kitchen mid-service, often on a phone, often with wet or gloved hands. So:
-- **Few steps, plain words.** Two numbered steps ("Your fish", "Your numbers"), one primary action,
- no jargon in labels (help text explains the industry terms instead).
+- **Few steps, plain words.** Two numbered steps ("Your fish", "Your numbers"), no Calculate button
+ (the answer is always on screen), no jargon in labels (help text explains the industry terms instead).
- **Big targets.** Buttons and form controls are at least 48px tall; other tappable things at least 44px.
- **Readable in any light.** Text is at least 4.5:1 and form-control edges at least 3:1, in both themes.
- **Nothing hover-only.** Help works on tap, keyboard focus and hover.
@@ -33,6 +33,27 @@ colors in `app/tailwind.config.js`.
Shared classes: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.form-label`,
`.form-input`, `.form-select`, `.section-divider`. Inputs are 16px text so iOS does not zoom on focus.
+## Calculator layout ("Dockside")
+
+Chosen from the three mock-ups in `docs/design-mockups/` (direction A).
+
+- **Tap tiles, not dropdowns,** for "What you have" and "What you're making" (radio buttons styled as
+ tiles, so arrow keys and screen readers work as for any radio group). Species stays a native select:
+ there are 89 of them. Products show their yield on the tile; long lists show 6 plus "Show all".
+- **Steppers** (big − / + either side of a typed field) for price, pounds, yield, processing and shipping.
+- **Live result bar** pinned to the bottom (`bg-brand-teal`, number in `brand-yellow`, 6.7:1). It is worked
+ out from what is on screen via `app/src/lib/calcEngine.js`, so a saved result always matches its inputs.
+ When processing or shipping is set it also shows the parts (Fish + Processing + Shipping). Screen readers
+ get the answer once typing pauses, from a separate live region, not on every keystroke.
+- **Processing and shipping** are each charged per lb of **incoming** weight (the starting fish; spread over
+ fewer finished pounds, so divided by yield) or **outgoing** weight (the finished product; added as is).
+ Both choices are always visible, with a line spelling out what the charge comes to per finished lb.
+- **Number boxes read what people type** (`app/src/lib/numberInput.js`): "$4.50", "1,000", "42%" and "4,50"
+ all work. Text that isn't a number marks that box as invalid and the bar says to use numbers; it never
+ counts as 0, because a wrong answer is worse than none.
+- **Nothing hides behind the bar.** The calculator sets the page's `scroll-padding-bottom` to the bar's
+ height, so whatever you Tab to scrolls into view above it (WCAG 2.4.11).
+
## Guardrails
- `app/src/lib/__tests__/designTokens.test.js` fails if a token pair drops below its contrast target.
@@ -47,10 +68,6 @@ Shared classes: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.form-
them, so nothing here is derived from those sites. Palette and typeface are unchanged from July 2026.
Tokens are centralized, so a palette or font pass is a change to `index.css`, `tailwind.config.js`
and the font link in `index.html`.
-- **Dark-only status styling** (pale `text-red-300` / `text-green-300` on translucent dark boxes, or
- `text-amber-*`) is unreadable in light mode. Move these to `text-danger` / `text-success` and
- theme-aware backgrounds: `UploadData`, `SubmitRequest`, `ConflictResolutionModal`, `RecoveryModal`,
- `DataManagement`, `ContributorProfile`, `CommunityData`, `FeaturesRoadmap`.
- **Unused components** `Footer.jsx` and `InstallPrompt.jsx` are not rendered anywhere and use tokens
that no longer exist (`bg-navy`, `text-teal`, `bg-rust`). Restyle before wiring them in, or delete.
- **Offline behavior.** `Calculator` seeds its data from `FISH_DATA_V3`, whose conversions have no
diff --git a/docs/design-mockups/README.md b/docs/design-mockups/README.md
new file mode 100644
index 0000000..fd09d51
--- /dev/null
+++ b/docs/design-mockups/README.md
@@ -0,0 +1,21 @@
+# Calculator design mock-ups
+
+Three candidate directions for the calculator screen, plus a visual board that shows them side by side. Each file is self-contained (data, styles and images are inlined), so it opens straight from disk in a browser with no build step or network.
+
+| File | What it is |
+|---|---|
+| `visual-board.html` | Overview: phone and desktop views of A, B and C in light and dark mode, plus before/after captures of the accessibility pass merged in #118. Start here. |
+| `mockup-a-dockside.html` | **A · Dockside**: big tap tiles, +/− steppers, a live result bar pinned to the bottom, no Calculate button. |
+| `mockup-b-market-board.html` | **B · Market Board**: a prep-ticket form and a price tag that shows the math and dims when inputs change, with an explicit "Price it" button. |
+| `mockup-c-clear-water.html` | **C · Clear Water**: a fill-in-the-blank sentence with a live, written-out answer. |
+
+## Status
+
+- **Direction A (Dockside) was chosen** and is built into the app (`app/src/components/Calculator.jsx`),
+ with shipping added, and processing and shipping each charged on incoming or outgoing weight (the
+ mock-up hid the processing choice in a collapsed section; the app shows both up front). See
+ "Calculator layout" in `docs/DESIGN_SYSTEM.md`. These files stay as the record of the options; the app
+ does not use them.
+- The directions are original and are **not** derived from the reference sites named in the redesign request (Grace Communications, NAMA, NFFC, Local Catch Network). Those sites could not be reached when the mock-ups were made.
+- Yields are real values from `app/src/data/fish_data_v3.js` for five species (Pink Salmon, Sockeye Salmon, Pacific Halibut, Pacific Cod, Lingcod), copied in at build time. They will not follow later changes to that file.
+- Colors and type follow the current brand tokens in `docs/DESIGN_SYSTEM.md`. Screenshots on the board were taken with a fallback font, so real devices using Inter will look slightly narrower.
diff --git a/docs/design-mockups/mockup-a-dockside.html b/docs/design-mockups/mockup-a-dockside.html
new file mode 100644
index 0000000..7a3428b
--- /dev/null
+++ b/docs/design-mockups/mockup-a-dockside.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+
Fish Cost Calculator — Mock-up A, Dockside
+
+
+
+
Mock-up A · Dockside — live sample with 5 species from the real yield data
+
+
+
+ Fish Cost Calculator
+ Tap your fish. Your real cost per pound shows at the bottom the whole time.
+
+
+ Cost per pound
+ Pounds to buy
+
+
+
+ 1 Your fish
+ Species
+ What you have
+ What you're making
+
+
+
+
+ 2 Your numbers
+
+
+
What you pay per lb
+
+
+
+
Pounds of finished product you need
+
+
+
+ Yield how much is left after cutting
+ –
+
+
+
+
+ Add a processing cost (optional)
+
+
Processing cost per lb
+
+
Charged per lb of
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design-mockups/mockup-b-market-board.html b/docs/design-mockups/mockup-b-market-board.html
new file mode 100644
index 0000000..902d200
--- /dev/null
+++ b/docs/design-mockups/mockup-b-market-board.html
@@ -0,0 +1,215 @@
+
+
+
+
+
+
Fish Cost Calculator — Mock-up B, Market Board
+
+
+
+
Mock-up B · Market Board — live sample with 5 species from the real yield data
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design-mockups/mockup-c-clear-water.html b/docs/design-mockups/mockup-c-clear-water.html
new file mode 100644
index 0000000..2f7c61d
--- /dev/null
+++ b/docs/design-mockups/mockup-c-clear-water.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+
Fish Cost Calculator — Mock-up C, Clear Water
+
+
+
+
Mock-up C · Clear Water — live sample with 5 species from the real yield data
+
+
+
+
+ Fish Cost Calculator
+ What does your fish really cost?
+
+ What it costs me
+ How much to buy
+
+
+
+
+
+
+
+
+ Change the details
+
+
Yield how much is left after cutting
+
+
Yield percent
+
+
Processing cost per lb (optional)
+
+ Charged per lb of
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design-mockups/visual-board.html b/docs/design-mockups/visual-board.html
new file mode 100644
index 0000000..ca8657d
--- /dev/null
+++ b/docs/design-mockups/visual-board.html
@@ -0,0 +1,278 @@
+
+
+
+
+
+
Fish Cost Calculator — Visual Board
+
+
+
+
+
+
+
+
+
+ 1 · What changed in the real app
+ Same fish, same numbers, same moment, captured from the original app (before ) and from the PR branch (after ). The brand colors and typeface are unchanged; this is about being readable and easy to use with wet hands.
+
+
+ The first screen on a phone
+ Fewer decisions at a glance, and every control big enough to hit.
+
+ Two numbered steps, “Your fish” and “Your numbers”, instead of one long form. Plain labels: “What you have” and “What you’re making”, not “From State” and “To Product”. The disabled button now says why, and the mode toggle no longer wraps.
+
+
+
+ The answer, in daylight
+ Result on a phone after tapping Calculate (light theme). The new version scrolls it into view by itself.
+
+ A bigger number with a bordered card, so it reads from arm’s length. Helper text and links now meet WCAG AA contrast (they were 2.6:1 to 4:1). The abbreviation list is collapsed by default and no longer cut off with “…”.
+
+
+
+ The same answer, in the dark
+ Dark theme on a phone, for a dim kitchen or an early boat.
+
+ The button is lighter than its card, so you can find it at a glance. The price uses a light aqua that holds up on dark backgrounds.
+
+
+
+ A bug fix you can see: invisible headings
+ On dark mode, the About page headings used a dark teal on near-black (about 2:1). They were effectively invisible, and the same was true on Data Sources, Community and Upload.
+
+ Headings are now light aqua, readable in dark mode. The current page in the navigation is underlined, not just recolored.
+
+
+
+ On a laptop at the counter
+
+
+
+
+
+ 2 · Three new directions
+ Three genuinely different ways the same screen could work. They are my own directions for this audience, not copies of any other site. Open “Try it live” under each to click through it: switch fish, change the price, flip modes.
+
+
+ A Dockside Loud, high-contrast, one-thumb. The answer is always on screen.
+
+
Phone · light
+
Phone · dark
+
Desktop · light
+
+
+
How it works Tap a fish, what you have, what you’re making. +/− buttons nudge the price by 25¢. The result bar updates live, so there is no Calculate button.
+
Best for Fishers on the dock, in glare, with gloves. Anyone who wants the answer without hunting for it.
+
Trade-offs The loudest look of the three. Big tiles mean a longer scroll (products are collapsed to the top 6).
+
+ ▶ Try Dockside live Phone size Desktop size Use the ◐ inside to flip light and dark.
+
+
+
+ B Market Board Warm and tactile. A prep ticket in, a price tag out.
+
+
Phone · light
+
Phone · dark
+
Desktop · light
+
+
+
How it works Fill in the ticket, then tap “Price it”. The tag shows the math: $4.50 ÷ 42% yield. Change anything and the tag dims, so it never shows a stale price as if it were current.
+
Best for Chefs and fishmongers pricing a case or a menu. Desktop and tablet at the counter.
+
Trade-offs One extra tap to see the answer. Furthest from today’s brand; serif type depends on the device’s fonts.
+
+ ▶ Try Market Board live Phone size Desktop size Use the ◐ inside to flip light and dark.
+
+
+
+ C Clear Water Calm and conversational. Fill in a sentence, read the answer.
+
+
Phone · light
+
Phone · dark
+
Desktop · light
+
+
+
How it works “I’m buying [Pink Salmon] as [Round] for $[4.50] a pound, and turning it into [Skinless Fillet].” The answer appears live, with a plain-language reason.
+
Best for New users and calm, low-pressure moments. Anyone put off by forms and jargon.
+
Trade-offs The sentence wraps awkwardly on small phones. Harder to add more fields later without the sentence getting long.
+
+ ▶ Try Clear Water live Phone size Desktop size Use the ◐ inside to flip light and dark.
+
+
+
+
+ 3 · Side by side
+ Same data and same math in all three. They differ in how much they ask of you and when you see the answer.
+
+ Comparison of the three design directions
+ Aspect A · Dockside B · Market Board C · Clear Water
+
+ Big idea Everything big; the result is always visible A prep ticket in, a price tag out A sentence you fill in
+ When you see the price Live, as you tap After “Price it” (dims when out of date) Live, written out in words
+ Feel Bold, high-contrast, work-boot Warm, tactile, chalkboard and paper Calm, airy, conversational
+ Best in Glare, gloves, one thumb Kitchen or fish counter, desktop and tablet First-time use, low-stress moments
+ Change from today’s app Layout, and a live result Look, and how the result is shown The whole interaction model
+ Watch out for Loud; long scroll on species with many cuts An extra tap; decoration; device-dependent serif Awkward wrapping on small phones; hard to extend
+
+
+
+
My take (an opinion, not a finding)
+
If I had to test one with real users, I’d start with A for the dock and boat, and C for people new to the tool. They also combine well: A’s always-visible result bar plus C’s plain-language reason (“Only 42% of each Round ends up as Skinless Fillet…”).
+
B is the most distinctive and the friendliest at a counter, but the furthest from the brand you have now.
+
+
+
+
+
+
4 · Notes
+
+ Not based on the reference sites. Grace Communications, namanet.org, the National Family Farm Coalition and localcatch.org were blocked from this environment, so nothing here is derived from them. A follow-up pass is possible once those four domains are allowed.
+ Sample data. The live mock-ups use real yields for five species (Pink and Sockeye Salmon, Pacific Halibut, Pacific Cod, Lingcod); the math matches the real calculator. The before/after screenshots use the app’s real fish data.
+ Fonts. Screenshots use a fallback font because this environment could not load Inter. Text will look slightly narrower on a real device.
+ Checked. Each live mock-up passes automated accessibility scans (WCAG 2.2 AA) in light and dark at phone and desktop widths, and its answers were checked against the real formula. Whether the design feels right still needs your eyes and a real phone.
+ Where things live. The real changes are in pull request #118 on the branch claude/adoring-cerf-nzibk1. The three directions are mock-ups only and are not in the repository.
+
+
+
+
+
+