Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
+411
View File
@@ -0,0 +1,411 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Apothecary — A personal inventory system</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="tokens.css" />
<style>
body { background: #1a1a1a; }
deck-stage { background: #1a1a1a; }
deck-stage > section {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
overflow: hidden;
display: flex;
}
.slide-pad { padding: 100px; width: 100%; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.eyebrow {
font-family: var(--mono);
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--ink-3);
font-weight: 500;
}
.h-display {
font-family: var(--serif);
font-size: 140px;
font-weight: 500;
line-height: 0.95;
letter-spacing: -0.025em;
color: var(--ink);
}
.h1 {
font-family: var(--serif);
font-size: 96px;
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ink);
}
.h2 {
font-family: var(--serif);
font-size: 64px;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.015em;
color: var(--ink);
}
.body {
font-size: 34px;
line-height: 1.45;
color: var(--ink-2);
text-wrap: pretty;
}
.small {
font-size: 28px;
line-height: 1.45;
color: var(--ink-2);
}
.micro {
font-size: 24px;
color: var(--ink-3);
}
.mono-label {
font-family: var(--mono);
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-3);
}
.hairline { border-top: 1px solid var(--line); }
.footer {
position: absolute;
bottom: 60px;
left: 100px;
right: 100px;
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 18px;
color: var(--ink-3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.swatch-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 36px;
}
.stat-display {
font-family: var(--serif);
font-size: 200px;
font-weight: 400;
line-height: 0.9;
letter-spacing: -0.03em;
color: var(--ink);
}
.quote {
font-family: var(--serif);
font-size: 76px;
font-weight: 400;
font-style: italic;
line-height: 1.2;
letter-spacing: -0.015em;
color: var(--ink);
text-wrap: balance;
}
.bg-parchment { background: var(--bg-2); }
.bg-ink { background: var(--ink); color: var(--bg); }
.bg-ink .h2, .bg-ink .h1, .bg-ink .h-display { color: var(--bg); }
.bg-ink .eyebrow, .bg-ink .micro, .bg-ink .body, .bg-ink .small { color: oklch(80% 0.012 75); }
.bg-sage { background: oklch(45% 0.06 145); color: oklch(95% 0.02 145); }
.bg-sage .h1, .bg-sage .h2, .bg-sage .h-display, .bg-sage .quote { color: oklch(96% 0.02 145); }
.bg-sage .eyebrow, .bg-sage .body, .bg-sage .micro { color: oklch(85% 0.04 145); }
.bg-terra { background: oklch(48% 0.10 40); color: oklch(96% 0.02 40); }
.bg-terra .h1, .bg-terra .h2, .bg-terra .h-display { color: oklch(96% 0.02 40); }
.bg-terra .eyebrow, .bg-terra .body, .bg-terra .micro { color: oklch(86% 0.04 40); }
</style>
</head>
<body>
<deck-stage width="1920" height="1080">
<!-- 01 — Cover -->
<section data-label="01 Cover" style="position: relative; flex-direction: column; justify-content: space-between;">
<div class="slide-pad" style="justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="width: 56px; height: 56px; border-radius: 50%; border: 1.5px solid var(--ink); display: flex; align-items: center; justify-content: center; font-family: var(--serif); font-size: 30px; font-style: italic;">A</div>
<div>
<div style="font-family: var(--serif); font-size: 32px; font-weight: 500; line-height: 1;">Apothecary</div>
<div class="mono-label" style="font-size: 16px;">A personal log</div>
</div>
</div>
<div>
<div class="eyebrow" style="margin-bottom: 28px;">Concept · Apr 2026</div>
<div class="h-display" style="font-style: italic;">A quiet, careful<br/>record of what's<br/>in your cabinet.</div>
<div class="body" style="margin-top: 36px; max-width: 1100px; font-size: 36px;">
A personal inventory system for keeping clean, professional records of cannabis purchases — what you have, where it lives, what you've used, and what's worth buying again.
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="micro">For personal use · Local-first · No accounts</div>
<div class="mono-label" style="font-size: 16px;">v0.4 · CONCEPT DECK</div>
</div>
</div>
</section>
<!-- 02 — Why -->
<section data-label="02 Why this exists">
<div class="slide-pad">
<div class="eyebrow">Why this exists</div>
<div class="h1" style="margin-top: 32px; max-width: 1500px;">Notes apps and spreadsheets can't keep up with a real apothecary.</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 60px; margin-top: 100px; padding-top: 60px; border-top: 1px solid var(--line);">
<div>
<div class="mono-label" style="margin-bottom: 20px;">01 Memory fades</div>
<div class="small">You forget what you paid, where you bought it, or whether the last batch was any good.</div>
</div>
<div>
<div class="mono-label" style="margin-bottom: 20px;">02 Spend goes unmeasured</div>
<div class="small">Without a real per-gram view, it's easy to over-pay and not realize the trend.</div>
</div>
<div>
<div class="mono-label" style="margin-bottom: 20px;">03 Habits are invisible</div>
<div class="small">Daily, weekly, and monthly use are hard to see clearly without a structured log.</div>
</div>
</div>
</div>
</section>
<!-- 03 — Big stat -->
<section data-label="03 The market" class="bg-parchment">
<div class="slide-pad" style="justify-content: center;">
<div class="eyebrow" style="margin-bottom: 40px;">The premise</div>
<div class="stat-display">$1,847</div>
<div class="h2" style="margin-top: 30px; max-width: 1300px;">is what a moderate consumer might spend in a year — without a single line item to show for it.</div>
<div class="micro" style="margin-top: 60px;">Apothecary turns that into a record you actually own.</div>
</div>
</section>
<!-- 04 — What it is -->
<section data-label="04 What it is">
<div class="slide-pad">
<div class="eyebrow">What it is</div>
<div class="h1" style="margin-top: 32px;">An inventory system,<br/><span style="font-style: italic; color: var(--ink-2);">designed like a library catalog.</span></div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px; margin-top: 80px;">
<div class="swatch-card">
<div class="mono-label">Every item is tracked</div>
<div class="h2" style="font-size: 42px; margin-top: 14px;">SKU, asset tag, weight, THC, CBD, total cannabinoids, shop, brand, type, date.</div>
</div>
<div class="swatch-card">
<div class="mono-label">Every item has a place</div>
<div class="h2" style="font-size: 42px; margin-top: 14px;">Physical bins — Top Drawer, Apothecary Box, The Safe — with capacity limits.</div>
</div>
<div class="swatch-card">
<div class="mono-label">Mark when finished</div>
<div class="h2" style="font-size: 42px; margin-top: 14px;">Date finished, rating, and final notes — the archive becomes a tasting library.</div>
</div>
<div class="swatch-card">
<div class="mono-label">Nothing leaves the archive</div>
<div class="h2" style="font-size: 42px; margin-top: 14px;">Consumed items keep their record — final notes, rating, lifespan, all preserved.</div>
</div>
</div>
</div>
</section>
<!-- 05 — Section header -->
<section data-label="05 Section: Dashboard" class="bg-ink">
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
<div class="mono-label" style="color: oklch(70% 0.04 145);">Part 01 / 03</div>
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Dashboard.</div>
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px; color: oklch(80% 0.012 75);">The morning glance. Daily averages, spend trends, days of supply — everything you need to see in a single look.</div>
</div>
</section>
<!-- 06 — Stats showcase -->
<section data-label="06 The numbers">
<div class="slide-pad">
<div class="eyebrow">What the dashboard answers</div>
<div class="h2" style="margin-top: 28px; max-width: 1500px;">Ten quiet questions you've never asked out loud.</div>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin-top: 80px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;">
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Daily avg</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">0.52<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">g</span></div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg $/g</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$13.40</div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">30d spend</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$284</div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">7d THC</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">766<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">mg</span></div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg lifespan</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">37<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Inv. value</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">$398</div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Days supply</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">28<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Avg gap</div><div style="font-family: var(--serif); font-size: 56px; line-height: 1;">8.4<span style="font-size: 22px; color: var(--ink-3); margin-left: 6px;">d</span></div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Top shop</div><div style="font-family: var(--serif); font-size: 36px; line-height: 1.05; margin-top: 14px;">Greenleaf Co-op</div></div>
<div style="background: var(--surface); padding: 36px;"><div class="mono-label" style="font-size: 18px; margin-bottom: 12px;">Top brand</div><div style="font-family: var(--serif); font-size: 36px; line-height: 1.05; margin-top: 14px;">Foxglove Farms</div></div>
</div>
</div>
</section>
<!-- 07 — Section header -->
<section data-label="07 Section: Inventory" class="bg-sage">
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
<div class="mono-label">Part 02 / 03</div>
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Inventory.</div>
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px;">Each product gets a SKU, an optional asset tag, a bin, a price, a chemistry. The catalog of a small, careful library.</div>
</div>
</section>
<!-- 08 — Product anatomy -->
<section data-label="08 Product anatomy">
<div class="slide-pad">
<div class="eyebrow">Anatomy of a product</div>
<div class="h2" style="margin-top: 28px;">Every entry, comprehensive.</div>
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 80px; margin-top: 60px; align-items: flex-start;">
<div style="border: 1px solid var(--line); border-radius: 14px; padding: 50px; background: var(--surface);">
<div class="mono-label" style="font-size: 18px;">◆ Concentrate · Active</div>
<div style="font-family: var(--serif); font-size: 72px; font-weight: 500; line-height: 1; margin-top: 14px; letter-spacing: -0.02em;">Indigo Cellar<br/>Live Rosin</div>
<div class="small" style="margin-top: 14px; font-size: 28px;">Heirloom Botanicals · from The Field House</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-top: 50px; background: var(--line); border: 1px solid var(--line); border-radius: 10px; overflow: hidden;">
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">Price</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">$65</div></div>
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">Weight</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">1.0 g</div></div>
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">THC</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">78.4%</div></div>
<div style="background: var(--surface); padding: 22px;"><div class="mono-label" style="font-size: 14px;">CBD</div><div style="font-family: var(--serif); font-size: 38px; margin-top: 8px;">0.2%</div></div>
</div>
<div style="margin-top: 30px; font-family: var(--mono); font-size: 22px; color: var(--ink-3); display: flex; gap: 24px;">
<span>SKU-A39FQX</span>
<span>·</span>
<span>AT-0042</span>
<span>·</span>
<span>The Safe</span>
</div>
</div>
<div>
<div class="mono-label" style="margin-bottom: 30px;">Tracked fields</div>
<ul style="list-style: none; padding: 0; margin: 0; font-size: 26px; line-height: 1.9; color: var(--ink-2);">
<li>— Name &amp; brand</li>
<li>— SKU + optional asset tag</li>
<li>— Type (flower, concentrate, edible, vape, pre-roll, tincture)</li>
<li>— Shop &amp; purchase date</li>
<li>— Price + computed cost-per-gram</li>
<li>— Weight, THC%, CBD%, total cannabinoids%</li>
<li>— Bin location &amp; remaining quantity</li>
<li>— Final notes &amp; rating after consumed</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 09 — Bins -->
<section data-label="09 Bins" class="bg-parchment">
<div class="slide-pad">
<div class="eyebrow">Storage</div>
<div class="h1" style="margin-top: 32px;">Every product knows<br/>where it lives.</div>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 24px; margin-top: 90px;">
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
<div class="mono-label" style="font-size: 16px;">Bin 01</div>
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Top Drawer</div>
<div class="micro" style="margin-top: 8px;">Bedroom</div>
<div style="flex: 1;"></div>
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 30%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
<div class="mono-label" style="font-size: 14px;">4 / 14</div>
</div>
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
<div class="mono-label" style="font-size: 16px;">Bin 02</div>
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Apothecary Box</div>
<div class="micro" style="margin-top: 8px;">Office shelf</div>
<div style="flex: 1;"></div>
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 20%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
<div class="mono-label" style="font-size: 14px;">2 / 10</div>
</div>
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
<div class="mono-label" style="font-size: 16px;">Bin 03</div>
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">The Safe</div>
<div class="micro" style="margin-top: 8px;">Closet</div>
<div style="flex: 1;"></div>
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 38%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
<div class="mono-label" style="font-size: 14px;">3 / 8</div>
</div>
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
<div class="mono-label" style="font-size: 16px;">Bin 04</div>
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Travel Tin</div>
<div class="micro" style="margin-top: 8px;">Backpack</div>
<div style="flex: 1;"></div>
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 50%; height: 100%; background: var(--amber); border-radius: 2px;"></div></div>
<div class="mono-label" style="font-size: 14px;">2 / 4</div>
</div>
<div style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 28px; aspect-ratio: 0.85; display: flex; flex-direction: column;">
<div class="mono-label" style="font-size: 16px;">Bin 05</div>
<div style="font-family: var(--serif); font-size: 40px; font-weight: 500; line-height: 1.05; margin-top: 14px;">Cold Storage</div>
<div class="micro" style="margin-top: 8px;">Fridge — back</div>
<div style="flex: 1;"></div>
<div style="height: 4px; background: oklch(85% 0.02 75); border-radius: 2px; margin-bottom: 10px;"><div style="width: 17%; height: 100%; background: var(--sage); border-radius: 2px;"></div></div>
<div class="mono-label" style="font-size: 14px;">1 / 6</div>
</div>
</div>
<div class="micro" style="margin-top: 60px; max-width: 1400px;">When a product is consumed, it leaves its bin but stays in the archive — keeping the record of what worked, what didn't, and what's worth a rebuy.</div>
</div>
</section>
<!-- 10 — Section: Habit -->
<section data-label="10 Section: Patterns" class="bg-terra">
<div class="slide-pad" style="justify-content: center; align-items: flex-start;">
<div class="mono-label">Part 03 / 03</div>
<div class="h-display" style="font-size: 160px; margin-top: 24px;">Patterns.</div>
<div class="body" style="margin-top: 40px; max-width: 1200px; font-size: 36px;">Where the data quietly becomes useful — habits made visible, spend made measurable.</div>
</div>
</section>
<!-- 11 — Quote -->
<section data-label="11 Quote">
<div class="slide-pad" style="justify-content: center;">
<div style="max-width: 1500px;">
<div class="quote">"You can't keep<br/>what you don't<br/>write down."</div>
<div class="mono-label" style="margin-top: 60px;">— Operating principle</div>
</div>
</div>
</section>
<!-- 12 — Roadmap / next -->
<section data-label="12 Next">
<div class="slide-pad">
<div class="eyebrow">What's next</div>
<div class="h1" style="margin-top: 32px;">Three releases<br/>to feel done.</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 60px; margin-top: 100px; padding-top: 50px; border-top: 1px solid var(--line);">
<div>
<div class="mono-label">v0.5 · May</div>
<div class="h2" style="font-size: 44px; margin-top: 16px;">Add &amp; consume</div>
<div class="small" style="margin-top: 18px; font-size: 26px;">Polish on the entry forms, barcode &amp; receipt photo capture, faster bulk add.</div>
</div>
<div>
<div class="mono-label">v0.7 · July</div>
<div class="h2" style="font-size: 44px; margin-top: 16px;">Charts &amp; export</div>
<div class="small" style="margin-top: 18px; font-size: 26px;">Heatmap, monthly spend, shop comparisons. CSV &amp; JSON export with no cloud.</div>
</div>
<div>
<div class="mono-label">v1.0 · September</div>
<div class="h2" style="font-size: 44px; margin-top: 16px;">Mobile app</div>
<div class="small" style="margin-top: 18px; font-size: 26px;">Native iOS / Android with biometric lock. Same local-first principle.</div>
</div>
</div>
</div>
</section>
<!-- 13 — End -->
<section data-label="13 End" class="bg-ink">
<div class="slide-pad" style="justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="width: 56px; height: 56px; border-radius: 50%; border: 1.5px solid var(--bg); display: flex; align-items: center; justify-content: center; font-family: var(--serif); font-size: 30px; font-style: italic; color: var(--bg);">A</div>
<div>
<div style="font-family: var(--serif); font-size: 32px; font-weight: 500; line-height: 1; color: var(--bg);">Apothecary</div>
<div class="mono-label" style="font-size: 16px; color: oklch(70% 0.04 145);">A personal log</div>
</div>
</div>
<div>
<div class="h-display" style="font-style: italic; color: var(--bg); font-size: 180px;">Keep<br/>a record.</div>
<div class="body" style="margin-top: 50px; font-size: 36px; color: oklch(80% 0.012 75); max-width: 1100px;">A small, careful inventory of a small, careful library.</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="mono-label" style="color: oklch(70% 0.04 145);">Live prototype available</div>
<div class="mono-label" style="color: oklch(70% 0.04 145); font-size: 16px;">END · 13 / 13</div>
</div>
</div>
</section>
</deck-stage>
<script src="deck-stage.js"></script>
</body>
</html>
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Apothecary — Personal Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="tokens.css" />
<style>
/* App chrome */
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 220px 1fr;
}
.sidebar {
border-right: 1px solid var(--line);
background: var(--bg-2);
padding: 28px 18px;
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 28px;
}
.brand-mark {
width: 32px; height: 32px;
border-radius: 50%;
border: 1px solid var(--ink);
display: flex; align-items: center; justify-content: center;
font-family: var(--serif);
font-size: 18px;
font-style: italic;
color: var(--ink);
}
.nav-link {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px;
border-radius: var(--r-md);
color: var(--ink-2);
cursor: pointer;
font-size: 13px;
font-weight: 500;
border: none;
background: transparent;
text-align: left;
transition: background 100ms;
}
.nav-link:hover { background: var(--bg-3); color: var(--ink); }
.nav-link.active { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); border: 1px solid var(--line); }
.nav-section {
font-size: 10px; text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-3);
padding: 16px 12px 6px;
font-weight: 500;
}
.inv-row:hover { background: var(--bg-2); }
@media (max-width: 880px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed; bottom: 0; left: 0; right: 0; top: auto;
height: auto;
flex-direction: row;
padding: 8px 12px;
border-top: 1px solid var(--line);
border-right: none;
z-index: 30;
overflow-x: auto;
}
.brand, .nav-section { display: none; }
.nav-link { white-space: nowrap; padding: 8px 12px; }
.main { padding-bottom: 60px; }
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js?v=4"></script>
<script type="text/babel" src="primitives.jsx?v=4"></script>
<script type="text/babel" src="screens-1.jsx?v=4"></script>
<script type="text/babel" src="screens-2.jsx?v=4"></script>
<script type="text/babel" src="tweaks-panel.jsx?v=4"></script>
<script type="text/babel">
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"dashboard": "editorial",
"tone": "botanical",
"accent": "sage"
}/*EDITMODE-END*/;
const NAV = [
{ key: "dashboard", label: "Dashboard", icon: "home" },
{ key: "inventory", label: "Inventory", icon: "box" },
{ key: "bins", label: "Bins", icon: "bin" },
{ key: "charts", label: "Patterns", icon: "chart" },
{ key: "settings", label: "Settings", icon: "settings" }
];
const TONE_LABELS = {
botanical: { brand: "Apothecary", tagline: "A personal log" },
neutral: { brand: "Inventory", tagline: "Personal stockkeeping" },
discreet: { brand: "The Cabinet", tagline: "Private register" }
};
function App() {
const [tweaks, setTweaks] = useTweaks(TWEAK_DEFAULTS);
const [view, setView] = React.useState("dashboard");
const [selected, setSelected] = React.useState(null);
const [modal, setModal] = React.useState(null); // 'add' | 'consume' | 'gone' | 'audit'
const [modalProduct, setModalProduct] = React.useState(null);
const [tweaksOpen, setTweaksOpen] = React.useState(false);
const data = window.SAMPLE_DATA;
const stats = React.useMemo(() => computeStats(data), [data]);
// Apply theme
React.useEffect(() => {
document.documentElement.setAttribute("data-theme", tweaks.theme);
}, [tweaks.theme]);
// Tweaks toggle wiring
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type === "__activate_edit_mode") setTweaksOpen(true);
if (e.data?.type === "__deactivate_edit_mode") setTweaksOpen(false);
};
window.addEventListener("message", handler);
window.parent.postMessage({type: "__edit_mode_available"}, "*");
return () => window.removeEventListener("message", handler);
}, []);
const labels = TONE_LABELS[tweaks.tone] || TONE_LABELS.botanical;
const onNav = (target) => {
if (target === "add") { setModalProduct(null); setModal("add"); }
else if (target === "consume") { setModalProduct(null); setModal("consume"); }
else if (target === "gone") { setModalProduct(null); setModal("gone"); }
else if (target === "audit") { setModalProduct(null); setModal("audit"); }
else setView(target);
};
const openAudit = (p) => { setModalProduct(p); setModal("audit"); };
const openGone = (p) => { setModalProduct(p); setSelected(null); setModal("gone"); };
const openConsume = (p) => { setModalProduct(p); setSelected(null); setModal("consume"); };
const handleSave = () => {
setModal(null);
};
return (
<div className="app-shell" data-screen-label="App">
{/* Sidebar */}
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">A</div>
<div>
<div className="serif" style={{fontSize: 18, fontWeight: 500, lineHeight: 1}}>{labels.brand}</div>
<div style={{fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.1em"}}>{labels.tagline}</div>
</div>
</div>
<div className="nav-section">Workspace</div>
{NAV.map(n => (
<button key={n.key} className={"nav-link " + (view === n.key ? "active" : "")} onClick={() => setView(n.key)}>
<Icon name={n.icon} size={16} />
{n.label}
</button>
))}
<div className="nav-section">Quick</div>
<button className="nav-link" onClick={() => setModal("add")}>
<Icon name="plus" size={16} /> Add product
</button>
<button className="nav-link" onClick={() => setModal("consume")}>
<Icon name="check" size={16} /> Mark finished
</button>
<div style={{flex: 1}} />
<div style={{padding: 12, fontSize: 11, color: "var(--ink-3)", borderTop: "1px solid var(--line)", marginTop: 12}}>
<div className="mono">v0.4 · {data.products.length} items</div>
<div style={{marginTop: 4}}>Local-only · Apr 2026</div>
</div>
</aside>
{/* Main */}
<main className="main parchment" style={{minWidth: 0}}>
{view === "dashboard" && (
<DashboardSwitch
variant={tweaks.dashboard}
data={data}
stats={stats}
onNav={onNav}
onSelectProduct={setSelected}
onAudit={openAudit}
/>
)}
{view === "inventory" && <Inventory data={data} onSelectProduct={setSelected} onNav={onNav} />}
{view === "bins" && <BinsView data={data} onSelectProduct={setSelected} />}
{view === "charts" && <ChartsView data={data} stats={stats} />}
{view === "settings" && <SettingsView data={data} tweaks={tweaks} onTweakChange={(k,v) => setTweaks({...tweaks, [k]: v})} />}
</main>
{selected && (
<ProductDetail
product={selected}
data={data}
onClose={() => setSelected(null)}
onConsume={openConsume}
onMarkGone={openGone}
onAudit={openAudit}
onEdit={() => {}}
/>
)}
{modal === "add" && <AddProductFlow data={data} onClose={() => setModal(null)} onSave={handleSave} />}
{modal === "consume" && <ConsumeFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{modal === "gone" && <MarkGoneFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{modal === "audit" && <AuditFlow data={data} onClose={() => setModal(null)} product={modalProduct} />}
{tweaksOpen && (
<TweaksPanel onClose={() => setTweaksOpen(false)} title="Tweaks">
<TweakSection title="Appearance">
<TweakRadio label="Theme" value={tweaks.theme} options={[["light","Light"],["dark","Dark"]]} onChange={v => setTweaks({...tweaks, theme: v})} />
<TweakRadio label="Dashboard layout" value={tweaks.dashboard} options={[["editorial","Editorial"],["dense","Data-dense"],["minimal","Minimal"]]} onChange={v => setTweaks({...tweaks, dashboard: v})} />
</TweakSection>
<TweakSection title="Copy">
<TweakRadio label="Tone" value={tweaks.tone} options={[["botanical","Botanical"],["neutral","Neutral"],["discreet","Discreet"]]} onChange={v => setTweaks({...tweaks, tone: v})} />
</TweakSection>
</TweaksPanel>
)}
</div>
);
}
// Switches between 3 dashboard variants
const DashboardSwitch = ({variant, ...props}) => {
if (variant === "dense") return <DashboardDense {...props} />;
if (variant === "minimal") return <DashboardMinimal {...props} />;
return <Dashboard {...props} />;
};
// Variation 2: data-dense
const DashboardDense = ({data, stats, onNav, onSelectProduct}) => {
const series = stats.series90.map(s => ({date: s.date, value: s.grams}));
return (
<div style={{padding: "20px 28px 60px"}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18, paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
<div style={{display: "flex", alignItems: "baseline", gap: 16}}>
<h1 className="mono" style={{fontSize: 13, margin: 0, color: "var(--ink-2)", textTransform: "uppercase", letterSpacing: "0.08em"}}>Dashboard / 2026-04-25</h1>
<span style={{fontSize: 11, color: "var(--ink-3)"}}>· {stats.activeCount} active · {stats.consumedCount} archived</span>
</div>
<div style={{display: "flex", gap: 6}}>
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Product</Btn>
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Finish</Btn>
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 1, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden", marginBottom: 14}}>
{[
["DAILY g", stats.dailyAvg.toFixed(2)],
["WEEKLY g", stats.weeklyAvg.toFixed(1)],
["MONTHLY g", stats.monthlyAvg.toFixed(1)],
["AVG $/g", fmt.money(stats.avgPerGram)],
["30D SPEND", fmt.moneyShort(stats.spend30)],
["INV VALUE", fmt.moneyShort(stats.inventoryValue)],
["7D THC mg", stats.thcLast7],
["DAYS SUPPLY", Math.round(stats.daysOfSupply)]
].map(([l, v]) => (
<div key={l} style={{padding: "12px 14px", background: "var(--surface)"}}>
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.04em"}}>{l}</div>
<div className="mono" style={{fontSize: 20, marginTop: 4, fontWeight: 500}}>{v}</div>
</div>
))}
</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>g / day · 90d</span>
<span className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{series.reduce((s,e)=>s+e.value,0).toFixed(1)} g total</span>
</div>
<div style={{padding: 14}}>
<BarChart data={series} height={120} color="var(--sage)" />
</div>
</Card>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>recent purchases</span>
</div>
<div>
{[...data.products].sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 16px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: "pointer", fontSize: 12}}>
<div>
<div style={{fontWeight: 500, fontSize: 13}}>{p.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)}</div>
</div>
<div className="mono" style={{textAlign: "right"}}>
<div>{fmt.money(p.price)}</div>
<div style={{fontSize: 10, color: "var(--ink-3)"}}>{fmt.dateShort(p.purchaseDate)}</div>
</div>
</div>
))}
</div>
</Card>
</div>
<Card padded={false}>
<div style={{padding: "12px 16px", borderBottom: "1px solid var(--line)"}}>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>active inventory · {stats.activeCount} rows</span>
</div>
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 10, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em", fontFamily: "var(--mono)"}}>
<div>NAME / SKU</div><div>BRAND</div><div style={{textAlign: "right"}}>THC%</div><div style={{textAlign: "right"}}>$</div><div style={{textAlign: "right"}}>REM g</div><div>BIN</div>
</div>
{data.products.filter(p=>p.status==="active").slice(0, 8).map(p => {
const bin = data.bins.find(b => b.id === p.binId);
return (
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{display: "grid", gridTemplateColumns: "2fr 1fr 0.6fr 0.6fr 0.6fr 0.8fr", padding: "8px 16px", borderBottom: "1px solid var(--line)", fontSize: 12, cursor: "pointer", alignItems: "center"}}>
<div>
<div style={{fontWeight: 500}}>{p.name}</div>
<div className="mono" style={{fontSize: 10, color: "var(--ink-3)"}}>{p.sku}</div>
</div>
<div style={{color: "var(--ink-2)"}}>{window.DATA_HELPERS.brandName(data, p.brandId)}</div>
<div className="mono" style={{textAlign: "right"}}>{p.thc.toFixed(1)}</div>
<div className="mono" style={{textAlign: "right"}}>{fmt.money(p.price)}</div>
<div className="mono" style={{textAlign: "right"}}>{window.remainingShort(p)}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{bin?.name || "—"}</div>
</div>
);
})}
</Card>
</div>
);
};
// Variation 3: minimal / editorial-quiet
const DashboardMinimal = ({data, stats, onNav, onSelectProduct}) => {
return (
<div style={{padding: "80px 60px", maxWidth: 900, margin: "0 auto"}}>
<div style={{textAlign: "center", marginBottom: 80}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 20}}>Saturday · April 25, 2026</div>
<div className="serif" style={{fontSize: 96, fontWeight: 400, letterSpacing: "-0.03em", lineHeight: 1, fontStyle: "italic"}}>
{stats.dailyAvg.toFixed(2)}<span style={{fontSize: 36, fontStyle: "normal", color: "var(--ink-3)", marginLeft: 8}}>g/day</span>
</div>
<div style={{marginTop: 20, fontSize: 14, color: "var(--ink-2)", maxWidth: 520, margin: "20px auto 0"}}>
{stats.activeCount} items in your cabinet, valued around {fmt.moneyShort(stats.inventoryValue)}, with roughly {Math.round(stats.daysOfSupply)} days of flower at this pace.
</div>
<div style={{marginTop: 8, fontSize: 13, color: "var(--ink-3)", maxWidth: 520, margin: "8px auto 0"}}>
{stats.consumedCount} items archived · avg lifespan {Math.round(stats.avgLifespan)} days.
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0, marginBottom: 80}}>
{[
["Avg $ per gram", fmt.money(stats.avgPerGram)],
["30-day spend", fmt.moneyShort(stats.spend30)],
["7-day THC", `${stats.thcLast7} mg`]
].map(([l,v], i) => (
<div key={l} style={{textAlign: "center", padding: "0 20px", borderRight: i < 2 ? "1px solid var(--line)" : "none"}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 10}}>{l}</div>
<div className="serif" style={{fontSize: 36, fontWeight: 500}}>{v}</div>
</div>
))}
</div>
<div style={{borderTop: "1px solid var(--line)", paddingTop: 40}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Recent additions</div>
<button onClick={() => onNav("inventory")} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>See all </button>
</div>
{[...data.products].filter(p=>p.status==="active").sort((a,b)=>new Date(b.purchaseDate)-new Date(a.purchaseDate)).slice(0, 5).map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "20px 0", borderBottom: "1px solid var(--line)", cursor: "pointer"}}>
<div>
<div className="serif" style={{fontSize: 24, fontWeight: 500}}>{p.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{window.DATA_HELPERS.brandName(data, p.brandId)} · {window.DATA_HELPERS.shopName(data, p.shopId)} · {fmt.dateShort(p.purchaseDate)}</div>
</div>
<div className="mono" style={{fontSize: 13, color: "var(--ink-2)"}}>{window.remainingShort(p)} · {fmt.money(p.price)}</div>
</div>
))}
</div>
<div style={{marginTop: 60, display: "flex", gap: 12, justifyContent: "center"}}>
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>Add product</Btn>
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+378
View File
@@ -0,0 +1,378 @@
// Sample inventory data
window.SAMPLE_DATA = (function() {
const TODAY = "2026-04-25";
const daysAgo = (n) => {
const d = new Date(TODAY); d.setDate(d.getDate() - n);
return d.toISOString().slice(0, 10);
};
// Shops are objects now
const SHOPS = [
{ id: "shp-01", name: "Greenleaf Co-op", location: "Capitol Hill" },
{ id: "shp-02", name: "Verdant Apothecary", location: "Ballard" },
{ id: "shp-03", name: "The Field House", location: "Fremont" },
{ id: "shp-04", name: "Northstar Dispensary",location: "Roosevelt" },
{ id: "shp-05", name: "Wildwood Provisions", location: "West Seattle" }
];
// Brands are objects too
const BRANDS = [
{ id: "brd-01", name: "Foxglove Farms" },
{ id: "brd-02", name: "Slow Burn" },
{ id: "brd-03", name: "Heirloom Botanicals" },
{ id: "brd-04", name: "Terra Vera" },
{ id: "brd-05", name: "North Field" },
{ id: "brd-06", name: "Cinder & Sage" },
{ id: "brd-07", name: "Old Forest" },
{ id: "brd-08", name: "Marigold Ext." }
];
// Type config — kind + audit cadence
// bulk: track weight (g) — audit re-weighs (or estimates for concentrate)
// discrete: track count (units) — audit confirms presence by SKU/asset
const TYPES = [
{ id: "Flower", kind: "bulk", auditMode: "weigh", cadenceDays: 14, unit: "g", weighable: true },
{ id: "Concentrate", kind: "bulk", auditMode: "estimate", cadenceDays: 21, unit: "g", weighable: true },
{ id: "Tincture", kind: "bulk", auditMode: "estimate", cadenceDays: 30, unit: "ml", weighable: false },
{ id: "Pre-roll", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false },
{ id: "Edible", kind: "discrete", auditMode: "presence", cadenceDays: 60, unit: "ct", weighable: false },
{ id: "Vaporizer", kind: "discrete", auditMode: "presence", cadenceDays: 30, unit: "ct", weighable: false }
];
const BINS = [
{ id: "bin-01", name: "Top Drawer", location: "Bedroom", capacity: 14 },
{ id: "bin-02", name: "Apothecary Box", location: "Office shelf", capacity: 10 },
{ id: "bin-03", name: "The Safe", location: "Closet", capacity: 8 },
{ id: "bin-04", name: "Travel Tin", location: "Backpack", capacity: 4 },
{ id: "bin-05", name: "Cold Storage", location: "Fridge — back", capacity: 6 }
];
let n = 1;
const mk = (o) => {
const id = "prd-" + String(n).padStart(4, "0");
n++;
const sku = o.sku || ("SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase());
return {
id,
sku,
assetTag: null,
name: "",
brandId: null,
shopId: null,
type: "Flower",
kind: "bulk", // "bulk" or "discrete"
// Bulk fields
weight: 0, // total at purchase (g/ml)
lastAuditWeight: null, // last measured weight
// Discrete fields
countOriginal: 0, // units at purchase
countLastAudit: null, // units confirmed at last audit
unitWeight: 0, // bulk-equivalent grams per unit (for grams stats)
// Pricing
price: 0,
thc: 0,
cbd: 0,
totalCannabinoids: 0,
purchaseDate: TODAY,
binId: "bin-01",
// Lifecycle
status: "active", // "active" | "consumed" | "gone"
consumedDate: null,
goneDate: null,
rating: null,
notes: null,
// Audits — newest last
audits: [],
...o
};
};
const products = [];
// ─── BULK: FLOWER ────────────────────────────────────────────────
products.push(mk({
name: "Garden Ghost", brandId: "brd-01", shopId: "shp-01",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 2.4,
price: 48, thc: 24.6, cbd: 0.3, totalCannabinoids: 28.4,
purchaseDate: daysAgo(17), binId: "bin-01",
audits: [
{ date: daysAgo(10), mode: "weigh", value: 3.0, prev: 3.5 },
{ date: daysAgo(3), mode: "weigh", value: 2.4, prev: 3.0 }
]
}));
products.push(mk({
name: "Honeydew Pine", brandId: "brd-02", shopId: "shp-02",
type: "Flower", kind: "bulk",
weight: 7, lastAuditWeight: 5.6,
price: 85, thc: 21.0, cbd: 0.5, totalCannabinoids: 25.1,
purchaseDate: daysAgo(11), binId: "bin-01",
audits: [
{ date: daysAgo(2), mode: "weigh", value: 5.6, prev: 7.0 }
]
}));
products.push(mk({
name: "Late Pear", brandId: "brd-01", shopId: "shp-01",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 3.5,
price: 50, thc: 25.2, cbd: 0.2, totalCannabinoids: 28.9,
purchaseDate: daysAgo(6), binId: "bin-05"
// recently bought, no audit yet
}));
products.push(mk({
name: "Copper Fennel", brandId: "brd-02", shopId: "shp-02",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 0.5,
price: 42, thc: 20.4, cbd: 0.5, totalCannabinoids: 24.0,
purchaseDate: daysAgo(26), binId: "bin-01",
audits: [
{ date: daysAgo(12), mode: "weigh", value: 1.6, prev: 3.5 },
{ date: daysAgo(2), mode: "weigh", value: 0.5, prev: 1.6 }
]
}));
// Overdue audit — Flower past 14d cadence
products.push(mk({
name: "Slate Cherry", brandId: "brd-06", shopId: "shp-04",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 3.5,
price: 46, thc: 22.8, cbd: 0.4, totalCannabinoids: 26.5,
purchaseDate: daysAgo(22), binId: "bin-01"
// No audit since purchase, 22d > 14d cadence → overdue
}));
// ─── BULK: CONCENTRATE ──────────────────────────────────────────
products.push(mk({
name: "Indigo Cellar Live Rosin", brandId: "brd-03", shopId: "shp-03",
type: "Concentrate", kind: "bulk",
weight: 1, lastAuditWeight: 0.6,
price: 65, thc: 78.4, cbd: 0.2, totalCannabinoids: 84.0,
purchaseDate: daysAgo(28), binId: "bin-03",
assetTag: "AT-0042",
audits: [
{ date: daysAgo(15), mode: "estimate", value: 0.8, prev: 1.0 },
{ date: daysAgo(2), mode: "estimate", value: 0.6, prev: 0.8 }
]
}));
products.push(mk({
name: "Slate Apricot Hash", brandId: "brd-04", shopId: "shp-04",
type: "Concentrate", kind: "bulk",
weight: 2, lastAuditWeight: 1.4,
price: 80, thc: 62.0, cbd: 1.1, totalCannabinoids: 70.5,
purchaseDate: daysAgo(23), binId: "bin-03",
audits: [
{ date: daysAgo(8), mode: "estimate", value: 1.4, prev: 2.0 }
]
}));
products.push(mk({
name: "Birchwater Live Resin", brandId: "brd-03", shopId: "shp-04",
type: "Concentrate", kind: "bulk",
weight: 1, lastAuditWeight: 1.0,
price: 70, thc: 76.0, cbd: 0.3, totalCannabinoids: 82.1,
purchaseDate: daysAgo(4), binId: "bin-03"
}));
// ─── BULK: TINCTURE ─────────────────────────────────────────────
products.push(mk({
name: "Nightjar Tincture 30ml", brandId: "brd-08", shopId: "shp-02",
type: "Tincture", kind: "bulk",
weight: 30, lastAuditWeight: 22,
price: 60, thc: 0.5, cbd: 18.0, totalCannabinoids: 19.2,
purchaseDate: daysAgo(62), binId: "bin-02",
audits: [
{ date: daysAgo(31), mode: "estimate", value: 26, prev: 30 },
{ date: daysAgo(5), mode: "estimate", value: 22, prev: 26 }
]
}));
// ─── DISCRETE: PRE-ROLLS ───────────────────────────────────────
products.push(mk({
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
type: "Pre-roll", kind: "discrete",
countOriginal: 5, countLastAudit: 3, unitWeight: 0.7,
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
purchaseDate: daysAgo(9), binId: "bin-04",
audits: [
{ date: daysAgo(2), mode: "presence", value: 3, prev: 5, confirmedBy: "SKU" }
]
}));
products.push(mk({
name: "Mossback Pre-rolls", brandId: "brd-07", shopId: "shp-03",
type: "Pre-roll", kind: "discrete",
countOriginal: 5, countLastAudit: 5, unitWeight: 0.7,
price: 38, thc: 19.8, cbd: 0.4, totalCannabinoids: 23.0,
purchaseDate: daysAgo(3), binId: "bin-04"
}));
products.push(mk({
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
type: "Pre-roll", kind: "discrete",
countOriginal: 3, countLastAudit: 1, unitWeight: 1.0,
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
purchaseDate: daysAgo(34), binId: "bin-04",
audits: [
{ date: daysAgo(20), mode: "presence", value: 2, prev: 3, confirmedBy: "SKU" },
{ date: daysAgo(2), mode: "presence", value: 1, prev: 2, confirmedBy: "SKU" }
]
}));
// ─── DISCRETE: EDIBLES ─────────────────────────────────────────
products.push(mk({
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
type: "Edible", kind: "discrete",
countOriginal: 10, countLastAudit: 6, unitWeight: 0,
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
purchaseDate: daysAgo(20), binId: "bin-02",
audits: [
{ date: daysAgo(5), mode: "presence", value: 6, prev: 10, confirmedBy: "SKU" }
]
}));
products.push(mk({
name: "Marigold Mints (20 ct)", brandId: "brd-08", shopId: "shp-02",
type: "Edible", kind: "discrete",
countOriginal: 20, countLastAudit: 14, unitWeight: 0,
price: 28, thc: 2.5, cbd: 0, totalCannabinoids: 3.0,
purchaseDate: daysAgo(48), binId: "bin-02",
audits: [
{ date: daysAgo(7), mode: "presence", value: 14, prev: 20, confirmedBy: "SKU" }
]
}));
// ─── DISCRETE: VAPORIZER ───────────────────────────────────────
products.push(mk({
name: "Quiet Meadow Disposable", brandId: "brd-05", shopId: "shp-05",
type: "Vaporizer", kind: "discrete",
countOriginal: 1, countLastAudit: 1, unitWeight: 1.0,
price: 55, thc: 84.0, cbd: 0.1, totalCannabinoids: 88.2,
purchaseDate: daysAgo(14), binId: "bin-04",
audits: [
{ date: daysAgo(1), mode: "presence", value: 1, prev: 1, confirmedBy: "SKU" }
]
}));
// ─── CONSUMED (used up — counts as consumption) ─────────────────
products.push(mk({
name: "Oolong 19", brandId: "brd-04", shopId: "shp-01",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 0,
price: 46, thc: 23.0, cbd: 0.4, totalCannabinoids: 27.0,
purchaseDate: daysAgo(66), binId: null,
status: "consumed", consumedDate: daysAgo(31),
rating: 4, notes: "Smooth, citrusy. Daytime favorite."
}));
products.push(mk({
name: "Stonefruit OG", brandId: "brd-07", shopId: "shp-03",
type: "Flower", kind: "bulk",
weight: 7, lastAuditWeight: 0,
price: 78, thc: 22.5, cbd: 0.6, totalCannabinoids: 26.4,
purchaseDate: daysAgo(103), binId: null,
status: "consumed", consumedDate: daysAgo(56),
rating: 5, notes: "Best of the season. Rebuy."
}));
products.push(mk({
name: "Lavender Coast", brandId: "brd-01", shopId: "shp-05",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 0,
price: 44, thc: 19.0, cbd: 0.8, totalCannabinoids: 22.2,
purchaseDate: daysAgo(80), binId: null,
status: "consumed", consumedDate: daysAgo(47),
rating: 3, notes: "Mellow but underwhelming."
}));
products.push(mk({
name: "Violet Tea", brandId: "brd-06", shopId: "shp-04",
type: "Flower", kind: "bulk",
weight: 3.5, lastAuditWeight: 0,
price: 50, thc: 24.1, cbd: 0.3, totalCannabinoids: 28.0,
purchaseDate: daysAgo(55), binId: null,
status: "consumed", consumedDate: daysAgo(21),
rating: 4, notes: "Floral nose, nice evenings."
}));
// ─── GONE (lost / damaged — counts as $ spent, NOT consumption) ─
products.push(mk({
name: "Quiet Meadow Singles", brandId: "brd-05", shopId: "shp-05",
type: "Pre-roll", kind: "discrete",
countOriginal: 3, countLastAudit: 0, unitWeight: 1.0,
price: 24, thc: 22.0, cbd: 0.3, totalCannabinoids: 25.0,
purchaseDate: daysAgo(72), binId: null,
status: "gone", goneDate: daysAgo(40),
notes: "Pack went through the wash. Lesson learned.",
audits: [
{ date: daysAgo(40), mode: "presence", value: 0, prev: 3, confirmedBy: "lost" }
]
}));
products.push(mk({
name: "Ember Lily Gummies (10 ct)", brandId: "brd-06", shopId: "shp-01",
type: "Edible", kind: "discrete",
countOriginal: 10, countLastAudit: 4, unitWeight: 0,
price: 22, thc: 5.0, cbd: 1.0, totalCannabinoids: 6.4,
purchaseDate: daysAgo(95), binId: null,
status: "gone", goneDate: daysAgo(15),
notes: "Expired. Tossed the rest."
}));
return {
products,
bins: BINS,
shops: SHOPS,
brands: BRANDS,
types: TYPES,
today: TODAY
};
})();
// Helpers — exported so screens can use consistent logic
window.DATA_HELPERS = {
shopName: (data, id) => data.shops.find(s => s.id === id)?.name || "—",
brandName: (data, id) => data.brands.find(b => b.id === id)?.name || "—",
typeConfig: (data, id) => data.types.find(t => t.id === id) || data.types[0],
// Days since a given ISO date string
daysSince: (iso, today = "2026-04-25") => {
if (!iso) return Infinity;
return Math.floor((new Date(today) - new Date(iso)) / 86400000);
},
// Last audit record (newest)
lastAudit: (p) => p.audits && p.audits.length ? p.audits[p.audits.length - 1] : null,
// Days since last audit (or since purchase if none)
daysSinceCheck: (p, today = "2026-04-25") => {
const last = p.audits && p.audits.length ? p.audits[p.audits.length - 1].date : p.purchaseDate;
return Math.floor((new Date(today) - new Date(last)) / 86400000);
},
// Is audit overdue based on per-type cadence
auditOverdue: (data, p, today = "2026-04-25") => {
if (p.status !== "active") return false;
const cfg = data.types.find(t => t.id === p.type);
if (!cfg) return false;
return window.DATA_HELPERS.daysSinceCheck(p, today) >= cfg.cadenceDays;
},
// Estimated remaining (bulk: decay from last audit; discrete: countLastAudit)
estimatedRemaining: (p, today = "2026-04-25") => {
if (p.status !== "active") return 0;
if (p.kind === "discrete") {
return p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
}
// Bulk: linear decay between last audit and average lifespan estimate
const last = window.DATA_HELPERS.lastAudit(p);
const baseDate = last ? last.date : p.purchaseDate;
const baseValue = last ? last.value : p.weight;
const daysSinceBase = Math.max(0, Math.floor((new Date(today) - new Date(baseDate)) / 86400000));
// Decay rate: assume original weight is consumed over (purchase → expected lifespan ~ 35d for flower, 40 concentrate, 90 tincture)
const expectedLifespan = p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
const dailyBurn = p.weight / expectedLifespan;
const est = Math.max(0, baseValue - dailyBurn * daysSinceBase);
return est;
},
// Original-percent remaining (for low-stock on bulk)
pctRemaining: (p, today = "2026-04-25") => {
if (p.kind === "discrete") {
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
}
const est = window.DATA_HELPERS.estimatedRemaining(p, today);
return p.weight > 0 ? est / p.weight : 0;
}
};
+619
View File
@@ -0,0 +1,619 @@
/**
* <deck-stage> — reusable web component for HTML decks.
*
* Handles:
* (a) speaker notes — reads <script type="application/json" id="speaker-notes">
* and posts {slideIndexChanged: N} to the parent window on nav.
* (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
* (c) press R to reset to slide 0 (with a tasteful keyboard hint).
* (d) bottom-center overlay showing slide count + hints, fades out on idle.
* (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
* scaled with `transform: scale()` to fit the viewport, letterboxed.
* Set the `noscale` attribute to render at authored size (1:1) — the
* PPTX exporter sets this so its DOM capture sees unscaled geometry.
* (f) print — `@media print` lays every slide out as its own page at the
* design size, so the browser's Print → Save as PDF produces a clean
* one-page-per-slide PDF with no extra setup.
*
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
* form inputs, React trees) is preserved across navigation.
*
* Lifecycle event — the component dispatches a `slidechange` CustomEvent on
* itself whenever the active slide changes (including the initial mount).
* The event bubbles and composes out of shadow DOM, so you can listen on
* the <deck-stage> element or on document:
*
* document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
* e.detail.index // new 0-based index
* e.detail.previousIndex // previous index, or -1 on init
* e.detail.total // total slide count
* e.detail.slide // the new active slide element
* e.detail.previousSlide // the prior slide element, or null on init
* e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
* });
*
* Persistence: none at the deck level. The host app keeps the current slide
* in its own URL (?slide=) and re-delivers it via location.hash on load, so a
* bare load with no hash always starts at slide 1.
*
* Usage:
* <deck-stage width="1920" height="1080">
* <section data-label="Title">...</section>
* <section data-label="Agenda">...</section>
* </deck-stage>
*
* Slides are the direct element children of <deck-stage>. Each slide is
* automatically tagged with:
* - data-screen-label="NN Label" (1-indexed, for comment flow)
* - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
*/
(() => {
const DESIGN_W_DEFAULT = 1920;
const DESIGN_H_DEFAULT = 1080;
const OVERLAY_HIDE_MS = 1800;
const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
const pad2 = (n) => String(n).padStart(2, '0');
const stylesheet = `
:host {
position: fixed;
inset: 0;
display: block;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow: hidden;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.canvas {
position: relative;
transform-origin: center center;
flex-shrink: 0;
background: #fff;
will-change: transform;
}
/* Slides live in light DOM (via <slot>) so authored CSS still applies.
We absolutely position each slotted child to stack them. */
::slotted(*) {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
box-sizing: border-box !important;
overflow: hidden;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
::slotted([data-deck-active]) {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
/* Tap zones for mobile — back/forward thirds like Stories.
Transparent, no visible UI, don't block the overlay. */
.tapzones {
position: fixed;
inset: 0;
display: flex;
z-index: 2147482000;
pointer-events: none;
}
.tapzone {
flex: 1;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
/* Only activate tap zones on coarse pointers (touch devices). */
@media (hover: hover) and (pointer: fine) {
.tapzones { display: none; }
}
.overlay {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 6px) scale(0.92);
filter: blur(6px);
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: #000;
color: #fff;
border-radius: 999px;
font-size: 12px;
font-feature-settings: "tnum" 1;
letter-spacing: 0.01em;
opacity: 0;
pointer-events: none;
transition: opacity 260ms ease, transform 260ms cubic-bezier(.2,.8,.2,1), filter 260ms ease;
transform-origin: center bottom;
z-index: 2147483000;
user-select: none;
}
.overlay[data-visible] {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0) scale(1);
filter: blur(0);
}
.btn {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
margin: 0;
padding: 0;
color: inherit;
font: inherit;
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
min-width: 28px;
border-radius: 999px;
color: rgba(255,255,255,0.72);
transition: background 140ms ease, color 140ms ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
.btn:active { background: rgba(255,255,255,0.18); }
.btn:focus { outline: none; }
.btn:focus-visible { outline: none; }
.btn::-moz-focus-inner { border: 0; }
.btn svg { width: 14px; height: 14px; display: block; }
.btn.reset {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
padding: 0 10px 0 12px;
gap: 6px;
color: rgba(255,255,255,0.72);
}
.btn.reset .kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1;
color: rgba(255,255,255,0.88);
background: rgba(255,255,255,0.12);
border-radius: 4px;
}
.count {
font-variant-numeric: tabular-nums;
color: #fff;
font-weight: 500;
padding: 0 8px;
min-width: 42px;
text-align: center;
font-size: 12px;
}
.count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
.count .total { color: rgba(255,255,255,0.55); }
.divider {
width: 1px;
height: 14px;
background: rgba(255,255,255,0.18);
margin: 0 2px;
}
/* ── Print: one page per slide, no chrome ────────────────────────────
The screen layout stacks every slide at inset:0 inside a scaled
canvas; for print we want them in document flow at the authored
design size so the browser paginates one slide per sheet. The
@page size is set from the width/height attributes via the inline
<style id="deck-stage-print-page"> that connectedCallback injects
into <head> (the @page at-rule has no effect inside shadow DOM). */
@media print {
:host {
position: static;
inset: auto;
background: none;
overflow: visible;
color: inherit;
}
.stage { position: static; display: block; }
.canvas {
transform: none !important;
width: auto !important;
height: auto !important;
background: none;
will-change: auto;
}
::slotted(*) {
position: relative !important;
inset: auto !important;
width: var(--deck-design-w) !important;
height: var(--deck-design-h) !important;
box-sizing: border-box !important;
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto;
break-after: page;
page-break-after: always;
break-inside: avoid;
overflow: hidden;
}
::slotted(*:last-child) {
break-after: auto;
page-break-after: auto;
}
.overlay, .tapzones { display: none !important; }
}
`;
class DeckStage extends HTMLElement {
static get observedAttributes() { return ['width', 'height', 'noscale']; }
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
this._index = 0;
this._slides = [];
this._notes = [];
this._hideTimer = null;
this._mouseIdleTimer = null;
this._onKey = this._onKey.bind(this);
this._onResize = this._onResize.bind(this);
this._onSlotChange = this._onSlotChange.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onTapBack = this._onTapBack.bind(this);
this._onTapForward = this._onTapForward.bind(this);
}
get designWidth() {
return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
}
get designHeight() {
return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
}
connectedCallback() {
this._render();
this._loadNotes();
this._syncPrintPageRule();
window.addEventListener('keydown', this._onKey);
window.addEventListener('resize', this._onResize);
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
// Initial collection + layout happens via slotchange, which fires on mount.
}
disconnectedCallback() {
window.removeEventListener('keydown', this._onKey);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('mousemove', this._onMouseMove);
if (this._hideTimer) clearTimeout(this._hideTimer);
if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
}
attributeChangedCallback() {
if (this._canvas) {
this._canvas.style.width = this.designWidth + 'px';
this._canvas.style.height = this.designHeight + 'px';
this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
this._fit();
this._syncPrintPageRule();
}
}
_render() {
const style = document.createElement('style');
style.textContent = stylesheet;
const stage = document.createElement('div');
stage.className = 'stage';
const canvas = document.createElement('div');
canvas.className = 'canvas';
canvas.style.width = this.designWidth + 'px';
canvas.style.height = this.designHeight + 'px';
canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
const slot = document.createElement('slot');
slot.addEventListener('slotchange', this._onSlotChange);
canvas.appendChild(slot);
stage.appendChild(canvas);
// Tap zones (mobile): left third = back, right third = forward.
const tapzones = document.createElement('div');
tapzones.className = 'tapzones export-hidden';
tapzones.setAttribute('aria-hidden', 'true');
tapzones.setAttribute('data-noncommentable', '');
const tzBack = document.createElement('div');
tzBack.className = 'tapzone tapzone--back';
const tzMid = document.createElement('div');
tzMid.className = 'tapzone tapzone--mid';
tzMid.style.pointerEvents = 'none';
const tzFwd = document.createElement('div');
tzFwd.className = 'tapzone tapzone--fwd';
tzBack.addEventListener('click', this._onTapBack);
tzFwd.addEventListener('click', this._onTapForward);
tapzones.append(tzBack, tzMid, tzFwd);
// Overlay: compact, solid black, with clickable controls.
const overlay = document.createElement('div');
overlay.className = 'overlay export-hidden';
overlay.setAttribute('role', 'toolbar');
overlay.setAttribute('aria-label', 'Deck controls');
overlay.setAttribute('data-noncommentable', '');
overlay.innerHTML = `
<button class="btn prev" type="button" aria-label="Previous slide" title="Previous (←)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3L5 8l5 5"/></svg>
</button>
<span class="count" aria-live="polite"><span class="current">1</span><span class="sep">/</span><span class="total">1</span></span>
<button class="btn next" type="button" aria-label="Next slide" title="Next (→)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
</button>
<span class="divider"></span>
<button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
`;
overlay.querySelector('.prev').addEventListener('click', () => this._go(this._index - 1, 'click'));
overlay.querySelector('.next').addEventListener('click', () => this._go(this._index + 1, 'click'));
overlay.querySelector('.reset').addEventListener('click', () => this._go(0, 'click'));
this._root.append(style, stage, tapzones, overlay);
this._canvas = canvas;
this._slot = slot;
this._overlay = overlay;
this._countEl = overlay.querySelector('.current');
this._totalEl = overlay.querySelector('.total');
}
/** @page must live in the document stylesheet — it's a no-op inside
* shadow DOM. Inject/update a single <head> style tag so the print
* sheet matches the design size and Save-as-PDF yields one slide per
* page with no margins. */
_syncPrintPageRule() {
const id = 'deck-stage-print-page';
let tag = document.getElementById(id);
if (!tag) {
tag = document.createElement('style');
tag.id = id;
document.head.appendChild(tag);
}
tag.textContent =
'@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
}
_onSlotChange() {
this._collectSlides();
this._restoreIndex();
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
this._fit();
}
_collectSlides() {
const assigned = this._slot.assignedElements({ flatten: true });
this._slides = assigned.filter((el) => {
// Skip template/style/script nodes even if someone slots them.
const tag = el.tagName;
return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
});
this._slides.forEach((slide, i) => {
const n = i + 1;
// Determine a label for comment flow: prefer explicit data-label,
// then an existing data-screen-label, then first heading, else "Slide".
let label = slide.getAttribute('data-label');
if (!label) {
const existing = slide.getAttribute('data-screen-label');
if (existing) {
// Strip any leading number the author may have included.
label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
}
}
if (!label) {
const h = slide.querySelector('h1, h2, h3, [data-title]');
if (h) label = (h.textContent || '').trim().slice(0, 40);
}
if (!label) label = 'Slide';
slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
// Validation attribute for comment flow / auto-checks.
if (!slide.hasAttribute('data-om-validate')) {
slide.setAttribute('data-om-validate', VALIDATE_ATTR);
}
slide.setAttribute('data-deck-slide', String(i));
});
if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
}
_loadNotes() {
const tag = document.getElementById('speaker-notes');
if (!tag) { this._notes = []; return; }
try {
const parsed = JSON.parse(tag.textContent || '[]');
if (Array.isArray(parsed)) this._notes = parsed;
} catch (e) {
console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
this._notes = [];
}
}
_restoreIndex() {
// The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
// the iframe src. No hash → slide 1; the deck itself keeps no position
// state across loads.
const h = (location.hash || '').match(/^#(\d+)$/);
if (h) {
const n = parseInt(h[1], 10) - 1;
if (n >= 0 && n < this._slides.length) this._index = n;
}
}
_applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
if (!this._slides.length) return;
const prev = this._prevIndex == null ? -1 : this._prevIndex;
const curr = this._index;
// Keep the iframe's own hash in sync so an in-iframe location.reload()
// (reload banner path in viewer-handle.ts) lands on the current slide,
// not the stale deep-link hash from initial load.
try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
this._slides.forEach((s, i) => {
if (i === curr) s.setAttribute('data-deck-active', '');
else s.removeAttribute('data-deck-active');
});
if (this._countEl) this._countEl.textContent = String(curr + 1);
if (broadcast) {
// (1) Legacy: host-window postMessage for speaker-notes renderers.
try { window.postMessage({ slideIndexChanged: curr }, '*'); } catch (e) {}
// (2) In-page CustomEvent on the <deck-stage> element itself.
// Bubbles and composes out of shadow DOM so slide code can listen:
// document.querySelector('deck-stage').addEventListener('slidechange', e => {
// e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
// });
const detail = {
index: curr,
previousIndex: prev,
total: this._slides.length,
slide: this._slides[curr] || null,
previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
};
this.dispatchEvent(new CustomEvent('slidechange', {
detail,
bubbles: true,
composed: true,
}));
}
this._prevIndex = curr;
if (showOverlay) this._flashOverlay();
}
_flashOverlay() {
if (!this._overlay) return;
this._overlay.setAttribute('data-visible', '');
if (this._hideTimer) clearTimeout(this._hideTimer);
this._hideTimer = setTimeout(() => {
this._overlay.removeAttribute('data-visible');
}, OVERLAY_HIDE_MS);
}
_fit() {
if (!this._canvas) return;
// PPTX export sets noscale so the DOM capture sees authored-size
// geometry — the scaled canvas is in shadow DOM, so the exporter's
// resetTransformSelector can't reach .canvas.style.transform directly.
if (this.hasAttribute('noscale')) {
this._canvas.style.transform = 'none';
return;
}
const vw = window.innerWidth;
const vh = window.innerHeight;
const s = Math.min(vw / this.designWidth, vh / this.designHeight);
this._canvas.style.transform = `scale(${s})`;
}
_onResize() { this._fit(); }
_onMouseMove() {
// Keep overlay visible while mouse moves; hide after idle.
this._flashOverlay();
}
_onTapBack(e) {
e.preventDefault();
this._go(this._index - 1, 'tap');
}
_onTapForward(e) {
e.preventDefault();
this._go(this._index + 1, 'tap');
}
_onKey(e) {
// Ignore when the user is typing.
const t = e.target;
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key;
let handled = true;
if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
this._go(this._index + 1, 'keyboard');
} else if (key === 'ArrowLeft' || key === 'PageUp') {
this._go(this._index - 1, 'keyboard');
} else if (key === 'Home') {
this._go(0, 'keyboard');
} else if (key === 'End') {
this._go(this._slides.length - 1, 'keyboard');
} else if (key === 'r' || key === 'R') {
this._go(0, 'keyboard');
} else if (/^[0-9]$/.test(key)) {
// 1..9 jump to that slide; 0 jumps to 10.
const n = key === '0' ? 9 : parseInt(key, 10) - 1;
if (n < this._slides.length) this._go(n, 'keyboard');
} else {
handled = false;
}
if (handled) {
e.preventDefault();
this._flashOverlay();
}
}
_go(i, reason = 'api') {
if (!this._slides.length) return;
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
if (clamped === this._index) {
this._flashOverlay();
return;
}
this._index = clamped;
this._applyIndex({ showOverlay: true, broadcast: true, reason });
}
// Public API ------------------------------------------------------------
/** Current slide index (0-based). */
get index() { return this._index; }
/** Total slide count. */
get length() { return this._slides.length; }
/** Programmatically navigate. */
goTo(i) { this._go(i, 'api'); }
next() { this._go(this._index + 1, 'api'); }
prev() { this._go(this._index - 1, 'api'); }
reset() { this._go(0, 'api'); }
}
if (!customElements.get('deck-stage')) {
customElements.define('deck-stage', DeckStage);
}
})();
+457
View File
@@ -0,0 +1,457 @@
// Shared utilities + small primitives
// Exported via window at bottom
const fmt = {
g: (n) => (n == null ? "—" : `${(+n).toFixed(2).replace(/\.?0+$/, "") || "0"} g`),
money: (n) => (n == null ? "—" : `$${(+n).toFixed(2)}`),
moneyShort: (n) => (n == null ? "—" : n >= 100 ? `$${Math.round(n)}` : `$${(+n).toFixed(2)}`),
pct: (n) => (n == null ? "—" : `${(+n).toFixed(1)}%`),
date: (s) => {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
},
dateShort: (s) => {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
},
daysAgo: (s) => {
if (!s) return "—";
const ms = Date.now() - new Date(s).getTime();
const d = Math.floor(ms / 86400000);
if (d === 0) return "today";
if (d === 1) return "yesterday";
if (d < 30) return `${d}d ago`;
if (d < 365) return `${Math.floor(d/30)}mo ago`;
return `${Math.floor(d/365)}y ago`;
}
};
const TYPE_GLYPHS = {
"Flower": "✿",
"Concentrate": "◆",
"Edible": "◐",
"Vaporizer": "▢",
"Pre-roll": "│",
"Tincture": "◯"
};
// Compute aggregate stats — derived from purchases + consumed/gone records + audits
function computeStats(data) {
const today = new Date(data.today || "2026-04-25");
const todayStr = today.toISOString().slice(0, 10);
const products = data.products;
const H = window.DATA_HELPERS;
const dayKey = (d) => d.toISOString().slice(0, 10);
const active = products.filter(p => p.status === "active");
const consumed = products.filter(p => p.status === "consumed" && p.consumedDate);
const gone = products.filter(p => p.status === "gone");
// Window helper for purchases
const purchasesIn = (days) => {
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days);
return products.filter(p => new Date(p.purchaseDate) >= cutoff);
};
const last7p = purchasesIn(7);
const last30p = purchasesIn(30);
const last90p = purchasesIn(90);
// Bulk-grams equivalent: bulk uses weight (or ml for tincture, ignored from grams);
// discrete uses unitWeight × units consumed
const bulkGrams = (p) => {
if (p.type === "Tincture" || p.type === "Edible") return 0; // not "weed grams"
if (p.kind === "bulk") return p.weight;
return (p.countOriginal || 0) * (p.unitWeight || 0);
};
const bulkGramsConsumed = (p) => {
// For consumed products: full weight equivalent
if (p.type === "Tincture" || p.type === "Edible") return 0;
if (p.kind === "bulk") return p.weight;
return (p.countOriginal || 0) * (p.unitWeight || 0);
};
const bulkGramsUsedSoFar = (p) => {
// For active products: estimated grams used to date
if (p.type === "Tincture" || p.type === "Edible") return 0;
if (p.kind === "bulk") {
const est = H.estimatedRemaining(p, todayStr);
return Math.max(0, p.weight - est);
}
// discrete: (original - current) × unitWeight
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
return Math.max(0, (p.countOriginal - cur)) * (p.unitWeight || 0);
};
// Daily attribution: spread used grams over (purchase → consumed/today) for consumed/active.
// GONE items contribute $0 NOT grams (they were lost, not used).
const dailyGramsAttribution = {};
consumed.forEach(p => {
const g = bulkGramsConsumed(p);
if (g <= 0) return;
const start = new Date(p.purchaseDate);
const end = new Date(p.consumedDate);
const days = Math.max(1, Math.round((end - start) / 86400000));
const perDay = g / days;
for (let i = 0; i < days; i++) {
const d = new Date(start); d.setDate(d.getDate() + i);
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
}
});
active.forEach(p => {
const used = bulkGramsUsedSoFar(p);
if (used <= 0) return;
const start = new Date(p.purchaseDate);
const days = Math.max(1, Math.round((today - start) / 86400000));
const perDay = used / days;
for (let i = 0; i < days; i++) {
const d = new Date(start); d.setDate(d.getDate() + i);
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
}
});
const seriesFor = (days) => {
const out = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today); d.setDate(d.getDate() - i);
const k = dayKey(d);
out.push({ date: k, grams: dailyGramsAttribution[k] || 0 });
}
return out;
};
const series7 = seriesFor(7);
const series30 = seriesFor(30);
const series90 = seriesFor(90);
const sumG = (xs) => xs.reduce((s, x) => s + x.grams, 0);
const dailyAvg = sumG(series30) / 30;
const weeklyAvg = sumG(series30) / (30/7);
const monthlyAvg = sumG(series90) / 3;
// Spend
const totalSpend = products.reduce((s, p) => s + p.price, 0);
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0);
const avgPerGram = totalGrams ? totalSpend / totalGrams : 0;
const spend30 = last30p.reduce((s, p) => s + p.price, 0);
const spend7 = last7p.reduce((s, p) => s + p.price, 0);
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
// Inventory value (active, prorated by est. remaining %)
const inventoryValue = active.reduce((s, p) => {
return s + p.price * H.pctRemaining(p, todayStr);
}, 0);
// THC mg using avg THC of products
const avgThc = products.length ? products.reduce((s,p)=>s+p.thc,0) / products.length : 20;
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
// Avg lifespan of consumed
const lifespans = consumed.map(p => Math.max(1, Math.round((new Date(p.consumedDate) - new Date(p.purchaseDate))/86400000)));
const avgLifespan = lifespans.length ? lifespans.reduce((a,b)=>a+b,0) / lifespans.length : 0;
// Favorite shop / brand — keyed by id, look up name
const shopCount = {};
const brandCount = {};
products.forEach(p => {
if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1;
if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1;
});
const topShopEntry = Object.entries(shopCount).sort((a,b)=>b[1]-a[1])[0];
const topBrandEntry = Object.entries(brandCount).sort((a,b)=>b[1]-a[1])[0];
const favShop = topShopEntry ? [H.shopName(data, topShopEntry[0]), topShopEntry[1]] : ["—", 0];
const favBrand = topBrandEntry ? [H.brandName(data, topBrandEntry[0]), topBrandEntry[1]] : ["—", 0];
// Type breakdown by est. grams on hand (active only)
const typeBreakdown = {};
active.forEach(p => {
let g;
if (p.type === "Tincture") g = H.estimatedRemaining(p, todayStr) * 0.5; // ml → display weight rough
else if (p.type === "Edible") g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * 0.3; // each gummy ~0.3g for chart only
else if (p.kind === "bulk") g = H.estimatedRemaining(p, todayStr);
else g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
});
// Flower-equivalent supply
const flowerEquivalent = active
.filter(p => p.type === "Flower" || p.type === "Pre-roll")
.reduce((s, p) => {
if (p.kind === "bulk") return s + H.estimatedRemaining(p, todayStr);
return s + (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
}, 0);
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
// Avg days between buys
const sortedDates = [...products].sort((a,b)=>new Date(a.purchaseDate)-new Date(b.purchaseDate)).map(p => new Date(p.purchaseDate));
const gaps = [];
for (let i = 1; i < sortedDates.length; i++) {
gaps.push((sortedDates[i] - sortedDates[i-1]) / 86400000);
}
const avgGap = gaps.length ? gaps.reduce((a,b)=>a+b,0)/gaps.length : 0;
// ─── Audits & low stock ───────────────────────────────────────
const overdueAudits = active.filter(p => H.auditOverdue(data, p, todayStr));
// Low stock:
// - Bulk: pctRemaining < 0.25
// - Discrete: GROUPED BY (brand + type) — total count ≤ 2
const lowStockBulk = active.filter(p => p.kind === "bulk" && H.pctRemaining(p, todayStr) < 0.25);
const discreteBrandGroups = {};
active.filter(p => p.kind === "discrete").forEach(p => {
const k = `${p.brandId}|${p.type}|${p.name}`;
if (!discreteBrandGroups[k]) {
discreteBrandGroups[k] = {
key: k, name: p.name, type: p.type, brandId: p.brandId,
items: [], totalCount: 0
};
}
discreteBrandGroups[k].items.push(p);
discreteBrandGroups[k].totalCount += (p.countLastAudit != null ? p.countLastAudit : p.countOriginal);
});
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(g => g.totalCount <= 2);
return {
dailyAvg, weeklyAvg, monthlyAvg,
totalSpend, avgPerGram, spend7, spend30, spend90, goneSpend,
inventoryValue,
thcLast7, thcLast30,
avgLifespan,
favShop, favBrand,
typeBreakdown,
daysOfSupply,
avgGap,
series7, series30, series90,
activeCount: active.length,
consumedCount: consumed.length,
goneCount: gone.length,
archivedCount: consumed.length + gone.length,
overdueAudits,
lowStockBulk,
lowStockDiscreteGroups
};
}
// Subtle inline icons (1px stroke)
const Icon = ({name, size = 18, color = "currentColor"}) => {
const paths = {
home: "M3 11l9-8 9 8M5 9v12h5v-7h4v7h5V9",
box: "M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10",
chart: "M3 21V3M3 21h18M7 17v-7M11 17v-4M15 17v-9M19 17v-2",
plus: "M12 5v14M5 12h14",
check: "M5 13l4 4L19 7",
settings: "M12 3v3M12 18v3M5 5l2 2M17 17l2 2M3 12h3M18 12h3M5 19l2-2M17 7l2-2M12 8a4 4 0 100 8 4 4 0 000-8z",
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4-4",
filter: "M3 5h18M6 12h12M10 19h4",
bin: "M4 7h16M9 7V4h6v3M6 7v13h12V7",
leaf: "M5 19c0-7 5-14 14-14 0 9-5 14-14 14zM5 19l7-7",
flame: "M12 3c1 4 4 5 4 9a4 4 0 11-8 0c0-2 2-3 2-6 0-1 1-2 2-3z",
droplet: "M12 3l5 7a5 5 0 11-10 0l5-7z",
arrow: "M5 12h14M13 5l7 7-7 7",
arrowDown: "M12 5v14M5 13l7 7 7-7",
close: "M6 6l12 12M18 6L6 18",
edit: "M4 20h4l10-10-4-4L4 16v4zM14 6l4 4",
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01"
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink: 0}}>
<path d={paths[name] || ""} />
</svg>
);
};
// Sparkline
const Sparkline = ({values, width = 120, height = 32, color = "var(--ink)", fill = false}) => {
if (!values || values.length === 0) return null;
const max = Math.max(...values, 0.001);
const min = Math.min(...values, 0);
const span = max - min || 1;
const step = width / (values.length - 1 || 1);
const pts = values.map((v, i) => [i * step, height - ((v - min) / span) * (height - 4) - 2]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
const fillPath = fill ? path + ` L ${width} ${height} L 0 ${height} Z` : null;
return (
<svg width={width} height={height} style={{display: "block", overflow: "visible"}}>
{fillPath && <path d={fillPath} fill={color} opacity="0.12" />}
<path d={path} stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
};
// Bar chart
const BarChart = ({data, height = 160, color = "var(--sage)", labels = false}) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map(d => d.value), 0.001);
return (
<div style={{display: "flex", alignItems: "flex-end", gap: 2, height, width: "100%"}}>
{data.map((d, i) => (
<div key={i} style={{flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0}}>
<div style={{
width: "100%",
height: `${(d.value / max) * 100}%`,
background: d.value > 0 ? color : "var(--line)",
borderRadius: "2px 2px 0 0",
minHeight: d.value > 0 ? 2 : 1,
opacity: d.muted ? 0.4 : 1
}} />
{labels && <div style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d.label}</div>}
</div>
))}
</div>
);
};
// Donut chart
const Donut = ({segments, size = 160, thickness = 22}) => {
const total = segments.reduce((s, x) => s + x.value, 0);
const r = size / 2 - thickness / 2;
const c = 2 * Math.PI * r;
let offset = 0;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{transform: "rotate(-90deg)"}}>
<circle cx={size/2} cy={size/2} r={r} stroke="var(--line)" strokeWidth={thickness} fill="none" opacity="0.3" />
{segments.map((s, i) => {
const len = (s.value / total) * c;
const dash = `${len} ${c - len}`;
const el = (
<circle key={i} cx={size/2} cy={size/2} r={r} stroke={s.color} strokeWidth={thickness} fill="none"
strokeDasharray={dash} strokeDashoffset={-offset} strokeLinecap="butt" />
);
offset += len;
return el;
})}
</svg>
);
};
// Reusable card
const Card = ({children, style, padded = true, ...rest}) => {
const { padded: _ignored, ...domProps } = rest;
return (
<div {...domProps} style={{
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: padded ? 20 : 0,
...style
}}>{children}</div>
);
};
// Stat card
const Stat = ({label, value, unit, sub, spark, accent, big}) => (
<div style={{
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: 18,
display: "flex",
flexDirection: "column",
gap: 8,
minWidth: 0
}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</div>
<div style={{display: "flex", alignItems: "baseline", gap: 6}}>
<div className="serif" style={{fontSize: big ? 44 : 32, lineHeight: 1, color: accent || "var(--ink)", fontWeight: 500, letterSpacing: "-0.01em"}}>{value}</div>
{unit && <div style={{fontSize: 13, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{unit}</div>}
</div>
{sub && <div style={{fontSize: 12, color: "var(--ink-3)"}}>{sub}</div>}
{spark && <div style={{marginTop: 4}}>{spark}</div>}
</div>
);
// Pill / badge
const Pill = ({children, tone = "neutral", style}) => {
const tones = {
neutral: { bg: "var(--bg-3)", color: "var(--ink-2)" },
sage: { bg: "var(--sage-soft)", color: "var(--sage)" },
terra: { bg: "var(--terracotta-soft)", color: "var(--terracotta)" },
amber: { bg: "var(--amber-soft)", color: "oklch(48% 0.10 75)" },
outline: { bg: "transparent", color: "var(--ink-2)", border: "1px solid var(--line-strong)" }
};
const t = tones[tone] || tones.neutral;
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 999,
background: t.bg,
color: t.color,
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.02em",
border: t.border || "1px solid transparent",
...style
}}>{children}</span>
);
};
// Button — high contrast across themes
const Btn = ({children, variant = "ghost", icon, onClick, style, type, disabled}) => {
// Disabled: keep solid surface, dim the text only — never become low-contrast on bg
const variants = {
primary: disabled
? { background: "var(--bg-3)", color: "var(--ink-3)", border: "1px solid var(--line-strong)" }
: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" },
secondary: { background: "var(--surface)", color: "var(--ink)", border: "1px solid var(--line-strong)" },
ghost: { background: "transparent", color: "var(--ink-2)", border: "1px solid transparent" },
danger: { background: "var(--terracotta)", color: "oklch(98% 0.01 40)", border: "1px solid var(--terracotta)" },
sage: { background: "var(--sage)", color: "oklch(98% 0.01 145)", border: "1px solid var(--sage)" }
};
const v = variants[variant];
return (
<button type={type} onClick={onClick} disabled={disabled} style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: "var(--r-md)",
fontSize: 13,
fontWeight: 600,
transition: "all 120ms",
cursor: disabled ? "not-allowed" : "pointer",
...v,
...style
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
};
// Field
const Field = ({label, children, hint, span = 1}) => (
<label style={{display: "flex", flexDirection: "column", gap: 6, gridColumn: `span ${span}`}}>
<span className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</span>
{children}
{hint && <span style={{fontSize: 11, color: "var(--ink-3)"}}>{hint}</span>}
</label>
);
const inputStyle = {
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "10px 12px",
fontSize: 13,
color: "var(--ink)",
outline: "none",
fontFamily: "var(--sans)",
width: "100%"
};
const Input = (props) => <input style={inputStyle} {...props} />;
const Select = ({children, ...rest}) => <select style={{...inputStyle, appearance: "auto"}} {...rest}>{children}</select>;
const Textarea = (props) => <textarea style={{...inputStyle, minHeight: 80, resize: "vertical"}} {...props} />;
Object.assign(window, {
fmt, computeStats, TYPE_GLYPHS,
Icon, Sparkline, BarChart, Donut,
Card, Stat, Pill, Btn, Field, Input, Select, Textarea, inputStyle
});
+578
View File
@@ -0,0 +1,578 @@
// Dashboard, Inventory, Product detail screens
const H = window.DATA_HELPERS;
const TODAY_STR = "2026-04-25";
// ─── Helpers shared across screens ─────────────────────────────────
const remainingDisplay = (p) => {
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
if (p.kind === "discrete") {
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
return `${cur} / ${p.countOriginal} ${cfg?.unit || "ct"}`;
}
const est = H.estimatedRemaining(p, TODAY_STR);
return `${est.toFixed(2).replace(/\.?0+$/,"")} / ${p.weight} ${cfg?.unit || "g"}`;
};
const remainingShort = (p) => {
if (p.kind === "discrete") {
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
return `${cur} ct`;
}
const cfg = window.SAMPLE_DATA.types.find(t => t.id === p.type);
const est = H.estimatedRemaining(p, TODAY_STR);
return `${est.toFixed(2).replace(/\.?0+$/,"") || "0"} ${cfg?.unit || "g"}`;
};
const Dashboard = ({data, stats, onNav, onSelectProduct, onAudit, onMarkGone}) => {
const series30 = stats.series30.map(d => ({ date: d.date, value: d.grams, label: "" }));
// Type breakdown
const typeColors = {
"Flower": "var(--sage)",
"Concentrate": "var(--terracotta)",
"Edible": "var(--amber)",
"Vaporizer": "var(--plum)",
"Pre-roll": "oklch(50% 0.06 200)",
"Tincture": "oklch(55% 0.06 270)"
};
const segments = Object.entries(stats.typeBreakdown).map(([k, v]) => ({
label: k, value: v, color: typeColors[k] || "var(--ink-3)"
}));
// Sparklines
const last7Series = stats.series7.map(l => l.grams);
const last30Series = series30.map(d => d.value);
const overdue = stats.overdueAudits;
const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups;
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
{/* Header */}
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", gap: 24, marginBottom: 16, flexWrap: "wrap"}}>
<div style={{minWidth: 0}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Saturday · April 25, 2026</div>
<h1 className="serif" style={{fontSize: 36, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1.1, whiteSpace: "nowrap"}}>Good evening.</h1>
</div>
<div style={{display: "flex", gap: 8, flexShrink: 0}}>
<Btn variant="secondary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
<Btn variant="primary" icon="check" onClick={() => onNav("consume")}>Mark finished</Btn>
</div>
</div>
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 28, maxWidth: 700}}>
{stats.activeCount} active items across {data.bins.length} bins · {stats.consumedCount} consumed · {stats.goneCount} gone.
{overdue.length > 0 && <span style={{color: "var(--terracotta)"}}> · {overdue.length} audit{overdue.length === 1 ? "" : "s"} overdue.</span>}
</div>
{/* Top stats row */}
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 14}}>
<Stat
label="Daily average"
value={stats.dailyAvg.toFixed(2)}
unit="g / day"
sub={`${fmt.g(stats.weeklyAvg)} weekly · ${fmt.g(stats.monthlyAvg)} monthly`}
spark={<Sparkline values={last30Series} width={240} height={28} color="var(--sage)" fill />}
/>
<Stat
label="Avg cost per gram"
value={fmt.money(stats.avgPerGram)}
sub={`Across ${data.products.length} purchases`}
/>
<Stat
label="30-day spend"
value={fmt.moneyShort(stats.spend30)}
sub={`Inventory value: ${fmt.money(stats.inventoryValue)}${stats.goneSpend > 0 ? ` · ${fmt.money(stats.goneSpend)} lost` : ""}`}
/>
<Stat
label="THC last 7 days"
value={stats.thcLast7.toLocaleString()}
unit="mg"
sub={`Last 30: ${(stats.thcLast30/1000).toFixed(1)} g THC`}
spark={<Sparkline values={last7Series} width={240} height={28} color="var(--terracotta)" />}
/>
</div>
{/* Audit alert strip */}
{overdue.length > 0 && (
<Card style={{marginBottom: 14, borderColor: "var(--amber)", background: "var(--amber-soft)"}}>
<div style={{display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap"}}>
<div style={{flex: 1, minWidth: 240}}>
<div className="smallcaps" style={{color: "oklch(48% 0.10 75)"}}>Audit overdue</div>
<div className="serif" style={{fontSize: 20, marginTop: 4, color: "var(--ink)"}}>
{overdue.length} item{overdue.length === 1 ? "" : "s"} haven't been checked in a while
</div>
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 4}}>
{overdue.slice(0, 3).map(p => p.name).join(" · ")}
{overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div>
</div>
<Btn variant="secondary" icon="check" onClick={() => onAudit && onAudit(overdue[0])}>Run audit</Btn>
</div>
</Card>
)}
{/* Main grid */}
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 14, marginBottom: 14}}>
{/* Consumption chart */}
<Card>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Consumption</div>
<div className="serif" style={{fontSize: 22, marginTop: 4}}>Last 30 days</div>
</div>
<div style={{display: "flex", gap: 16, fontSize: 12, color: "var(--ink-3)"}}>
<div><span style={{color: "var(--ink)"}} className="serif" >{fmt.g(stats.series30.reduce((s,l)=>s+l.grams,0))}</span> est. total</div>
<div><span style={{color: "var(--ink)"}} className="serif">{stats.avgGap.toFixed(0)}</span> day avg between buys</div>
</div>
</div>
<BarChart data={series30.map(d => ({...d, label: ""}))} height={140} color="var(--sage)" />
<div style={{display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
<span>30 days ago</span>
<span>15 days ago</span>
<span>today</span>
</div>
</Card>
{/* Type breakdown */}
<Card>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>By type · grams on hand</div>
<div className="serif" style={{fontSize: 22, marginTop: 4, marginBottom: 16}}>Inventory</div>
<div style={{display: "flex", alignItems: "center", gap: 20}}>
<Donut segments={segments} size={140} thickness={20} />
<div style={{flex: 1, display: "flex", flexDirection: "column", gap: 8}}>
{segments.map(s => (
<div key={s.label} style={{display: "flex", alignItems: "center", gap: 8, fontSize: 12}}>
<div style={{width: 8, height: 8, borderRadius: 2, background: s.color}} />
<div style={{flex: 1, color: "var(--ink-2)"}}>{s.label}</div>
<div className="mono" style={{color: "var(--ink)"}}>{s.value.toFixed(1)}g</div>
</div>
))}
</div>
</div>
</Card>
</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14, marginBottom: 14}}>
<Stat
label="Days of supply"
value={Math.round(stats.daysOfSupply)}
unit="days"
sub="Flower & pre-rolls at current pace"
/>
<Stat
label="Avg lifespan"
value={Math.round(stats.avgLifespan)}
unit="days"
sub="From purchase to finished"
/>
<Stat
label="Days between buys"
value={stats.avgGap.toFixed(1)}
unit="days"
sub="Average across all purchases"
/>
</div>
{/* Bottom row: shop + brand + low stock */}
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr 1.4fr", gap: 14}}>
<Card>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite shop</div>
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favShop[0]}</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favShop[1]} of {data.products.length} purchases</div>
</Card>
<Card>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Favorite brand</div>
<div className="serif" style={{fontSize: 28, marginTop: 6, fontWeight: 500}}>{stats.favBrand[0]}</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 4}}>{stats.favBrand[1]} purchases</div>
</Card>
<Card padded={false}>
<div style={{padding: "20px 20px 12px", display: "flex", justifyContent: "space-between", alignItems: "baseline"}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Low stock · running out</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{lowBulk.length + lowDiscrete.length} item{(lowBulk.length + lowDiscrete.length) === 1 ? "" : "s"}</div>
</div>
<div>
{(lowBulk.length + lowDiscrete.length) === 0 && <div style={{padding: "0 20px 20px", fontSize: 13, color: "var(--ink-3)"}}>Nothing running low.</div>}
{lowBulk.slice(0, 3).map(p => {
const pct = H.pctRemaining(p, TODAY_STR);
return (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
<div style={{flex: 1, minWidth: 0}}>
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)} · {p.type}</div>
</div>
<div style={{width: 60, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
<div style={{width: `${pct * 100}%`, height: "100%", background: pct < 0.15 ? "var(--terracotta)" : "var(--amber)"}} />
</div>
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)", width: 60, textAlign: "right"}}>{remainingShort(p)}</div>
</div>
);
})}
{lowDiscrete.slice(0, 2).map(g => (
<div key={g.key} onClick={() => onSelectProduct(g.items[0])} style={{padding: "10px 20px", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, cursor: "pointer"}}>
<div style={{flex: 1, minWidth: 0}}>
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{g.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, g.brandId)} · {g.type}</div>
</div>
<Pill tone="amber" style={{fontSize: 10}}>{g.totalCount} left</Pill>
</div>
))}
</div>
</Card>
</div>
</div>
);
};
// ─── INVENTORY ─────────────────────────────────────────────────────
const Inventory = ({data, onSelectProduct, onNav}) => {
const [filter, setFilter] = React.useState("active"); // active | consumed | gone | all
const [typeFilter, setTypeFilter] = React.useState("all");
const [sortBy, setSortBy] = React.useState("recent");
const [search, setSearch] = React.useState("");
let products = data.products;
if (filter === "active") products = products.filter(p => p.status === "active");
else if (filter === "consumed") products = products.filter(p => p.status === "consumed");
else if (filter === "gone") products = products.filter(p => p.status === "gone");
if (typeFilter !== "all") products = products.filter(p => p.type === typeFilter);
if (search) {
const q = search.toLowerCase();
products = products.filter(p => {
const brand = H.brandName(data, p.brandId).toLowerCase();
const shop = H.shopName(data, p.shopId).toLowerCase();
return p.name.toLowerCase().includes(q) ||
brand.includes(q) ||
shop.includes(q) ||
p.sku.toLowerCase().includes(q);
});
}
products = [...products].sort((a, b) => {
if (sortBy === "recent") return new Date(b.purchaseDate) - new Date(a.purchaseDate);
if (sortBy === "name") return a.name.localeCompare(b.name);
if (sortBy === "thc") return b.thc - a.thc;
if (sortBy === "remaining") return H.estimatedRemaining(b, TODAY_STR) - H.estimatedRemaining(a, TODAY_STR);
if (sortBy === "price") return b.price - a.price;
if (sortBy === "audit") return H.daysSinceCheck(b, TODAY_STR) - H.daysSinceCheck(a, TODAY_STR);
return 0;
});
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{products.length} items</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Inventory</h1>
</div>
<div style={{display: "flex", gap: 8}}>
<Btn variant="secondary" icon="check" onClick={() => onNav("audit")}>Audit</Btn>
<Btn variant="primary" icon="plus" onClick={() => onNav("add")}>New product</Btn>
</div>
</div>
{/* Toolbar */}
<Card style={{marginBottom: 14, padding: 14}}>
<div style={{display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap"}}>
{/* Tabs */}
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
{[["active", "Active"], ["consumed", "Consumed"], ["gone", "Gone"], ["all", "All"]].map(([k, l]) => (
<button key={k} onClick={() => setFilter(k)} style={{
padding: "6px 14px",
fontSize: 12,
fontWeight: 500,
borderRadius: 6,
border: "none",
background: filter === k ? "var(--surface)" : "transparent",
color: filter === k ? "var(--ink)" : "var(--ink-3)",
boxShadow: filter === k ? "var(--shadow-sm)" : "none",
cursor: "pointer"
}}>{l}</button>
))}
</div>
{/* Search */}
<div style={{flex: 1, minWidth: 220, display: "flex", alignItems: "center", gap: 8, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: "0 10px"}}>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
placeholder="Search by name, brand, shop, SKU…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{border: "none", outline: "none", background: "transparent", padding: "8px 0", fontSize: 13, flex: 1, color: "var(--ink)"}}
/>
</div>
<Select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
<option value="all">All types</option>
{data.types.map(t => <option key={t.id} value={t.id}>{t.id}</option>)}
</Select>
<Select value={sortBy} onChange={e => setSortBy(e.target.value)} style={{...inputStyle, width: "auto", padding: "8px 10px"}}>
<option value="recent">Recent first</option>
<option value="name">Name (AZ)</option>
<option value="thc">THC % (high)</option>
<option value="remaining">Remaining (high)</option>
<option value="price">Price (high)</option>
<option value="audit">Audit overdue first</option>
</Select>
</div>
</Card>
{/* Table */}
<Card padded={false}>
<div style={{display: "grid", gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr", padding: "12px 20px", borderBottom: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase", letterSpacing: "0.08em"}}>
<div></div>
<div>Product</div>
<div>Brand</div>
<div>Shop</div>
<div style={{textAlign: "right"}}>THC %</div>
<div style={{textAlign: "right"}}>Price</div>
<div style={{textAlign: "right"}}>Remaining</div>
<div>Last checked</div>
<div>Bin</div>
</div>
{products.length === 0 && (
<div style={{padding: 60, textAlign: "center", color: "var(--ink-3)"}}>No items match these filters.</div>
)}
{products.map(p => {
const bin = data.bins.find(b => b.id === p.binId);
const pctRemaining = H.pctRemaining(p, TODAY_STR);
const overdue = H.auditOverdue(data, p, TODAY_STR);
const sinceCheck = H.daysSinceCheck(p, TODAY_STR);
const last = H.lastAudit(p);
const isInactive = p.status !== "active";
return (
<div key={p.id} onClick={() => onSelectProduct(p)} className="inv-row" style={{
display: "grid",
gridTemplateColumns: "32px 2fr 1fr 1fr 0.6fr 0.6fr 0.9fr 0.9fr 0.8fr",
padding: "14px 20px",
borderBottom: "1px solid var(--line)",
alignItems: "center",
cursor: "pointer",
opacity: isInactive ? 0.55 : 1,
fontSize: 13
}}>
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)"}}>{TYPE_GLYPHS[p.type]}</div>
<div style={{minWidth: 0}}>
<div style={{fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>
{p.name}
{p.status === "consumed" && <Pill tone="terra" style={{marginLeft: 6, fontSize: 10}}>Consumed</Pill>}
{p.status === "gone" && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Gone</Pill>}
{p.status === "active" && overdue && <Pill tone="amber" style={{marginLeft: 6, fontSize: 10}}>Audit due</Pill>}
</div>
<div style={{fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{p.sku}{p.assetTag ? ` · ${p.assetTag}` : ""}</div>
</div>
<div style={{color: "var(--ink-2)"}}>{H.brandName(data, p.brandId)}</div>
<div style={{color: "var(--ink-3)", fontSize: 12}}>{H.shopName(data, p.shopId)}</div>
<div style={{textAlign: "right", fontFamily: "var(--mono)", color: "var(--ink-2)"}}>{p.thc.toFixed(1)}</div>
<div style={{textAlign: "right", fontFamily: "var(--mono)"}}>{fmt.money(p.price)}</div>
<div style={{textAlign: "right", display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4}}>
<div style={{fontFamily: "var(--mono)", fontSize: 12}}>{remainingShort(p)}</div>
{p.status === "active" && p.kind === "bulk" && (
<div style={{width: 50, height: 3, background: "var(--bg-3)", borderRadius: 2}}>
<div style={{width: `${pctRemaining*100}%`, height: "100%", background: pctRemaining < 0.25 ? "var(--terracotta)" : pctRemaining < 0.5 ? "var(--amber)" : "var(--sage)", borderRadius: 2}} />
</div>
)}
</div>
<div style={{fontSize: 11, color: overdue ? "var(--terracotta)" : "var(--ink-3)"}}>
{p.status !== "active"
? <span style={{fontStyle: "italic"}}>archived</span>
: last
? <span><span className="mono">{sinceCheck}d</span> ago · {last.mode}</span>
: <span style={{fontStyle: "italic"}}>never</span>}
</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{bin ? bin.name : <span style={{fontStyle: "italic"}}>—</span>}</div>
</div>
);
})}
</Card>
</div>
);
};
// ─── PRODUCT DETAIL ────────────────────────────────────────────────
const ProductDetail = ({product, data, onClose, onConsume, onMarkGone, onAudit, onEdit}) => {
const bin = data.bins.find(b => b.id === product.binId);
const cfg = data.types.find(t => t.id === product.type);
const pctRemaining = H.pctRemaining(product, TODAY_STR);
const est = H.estimatedRemaining(product, TODAY_STR);
const last = H.lastAudit(product);
const overdue = H.auditOverdue(data, product, TODAY_STR);
const sinceCheck = H.daysSinceCheck(product, TODAY_STR);
const isActive = product.status === "active";
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "flex-end"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(720px, 100vw)",
height: "100%",
background: "var(--bg)",
borderLeft: "1px solid var(--line)",
overflow: "auto",
boxShadow: "var(--shadow-lg)"
}}>
{/* Header */}
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", justifyContent: "space-between", position: "sticky", top: 0, background: "var(--bg)", zIndex: 1}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Product · {product.sku}</div>
<div style={{display: "flex", gap: 6}}>
{isActive && <Btn variant="ghost" icon="check" onClick={() => onAudit(product)}>Audit</Btn>}
{isActive && <Btn variant="secondary" icon="check" onClick={() => onConsume(product)}>Mark finished</Btn>}
{isActive && <Btn variant="ghost" icon="bin" onClick={() => onMarkGone(product)}>Mark gone</Btn>}
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
</div>
<div style={{padding: "32px 32px 60px"}}>
{/* Identity */}
<div style={{display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8}}>
<div className="serif" style={{fontSize: 18, color: "var(--ink-3)"}}>{TYPE_GLYPHS[product.type]} {product.type}</div>
{product.status === "consumed" && <Pill tone="terra">Consumed · {fmt.daysAgo(product.consumedDate)}</Pill>}
{product.status === "gone" && <Pill tone="amber">Gone · {fmt.daysAgo(product.goneDate)}</Pill>}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
<h1 className="serif" style={{fontSize: 48, margin: "0 0 4px", fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1.1}}>{product.name}</h1>
<div style={{fontSize: 16, color: "var(--ink-2)"}}>{H.brandName(data, product.brandId)} · from {H.shopName(data, product.shopId)}</div>
{/* Hero stats */}
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 1, marginTop: 32, background: "var(--line)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
{[
["Price", fmt.money(product.price)],
[product.kind === "discrete" ? "Quantity" : "Size",
product.kind === "discrete"
? `${product.countOriginal} ${cfg?.unit || "ct"}`
: `${product.weight} ${cfg?.unit || "g"}`
],
["THC", `${product.thc.toFixed(1)}%`],
["CBD", `${product.cbd.toFixed(1)}%`]
].map(([l, v]) => (
<div key={l} style={{padding: "18px 16px", background: "var(--surface)"}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{l}</div>
<div className="serif" style={{fontSize: 26, marginTop: 4, fontWeight: 500}}>{v}</div>
</div>
))}
</div>
{/* Remaining + audit */}
{isActive && (
<div style={{marginTop: 20}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>
{product.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
</div>
<div style={{fontFamily: "var(--mono)", fontSize: 13}}>
{product.kind === "discrete"
? `${product.countLastAudit != null ? product.countLastAudit : product.countOriginal} of ${product.countOriginal}`
: `${est.toFixed(2)} of ${product.weight} ${cfg?.unit || "g"}`}
<span style={{color: "var(--ink-3)", marginLeft: 8}}>{Math.round(pctRemaining*100)}%</span>
</div>
</div>
<div style={{height: 8, background: "var(--bg-3)", borderRadius: 4, overflow: "hidden"}}>
<div style={{width: `${pctRemaining*100}%`, height: "100%", background: pctRemaining < 0.25 ? "var(--terracotta)" : pctRemaining < 0.5 ? "var(--amber)" : "var(--sage)"}} />
</div>
{product.kind === "bulk" && last && (
<div style={{fontSize: 11, color: "var(--ink-3)", marginTop: 6, fontStyle: "italic"}}>
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date)} ({last.value}{cfg?.unit}). Re-audit to update.
</div>
)}
</div>
)}
{/* Audit history */}
<div style={{marginTop: 36}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 12}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit history</div>
{isActive && <button onClick={() => onAudit(product)} style={{background: "none", border: "none", fontSize: 12, color: "var(--ink-2)", cursor: "pointer", textDecoration: "underline"}}>+ New audit</button>}
</div>
{(!product.audits || product.audits.length === 0) ? (
<div style={{fontSize: 13, color: "var(--ink-3)", fontStyle: "italic", padding: "12px 0"}}>
No audits recorded. Cadence for {product.type}: every {cfg?.cadenceDays || "—"} days.
</div>
) : (
<div style={{display: "flex", flexDirection: "column", gap: 0, border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
{[...product.audits].reverse().map((a, i) => (
<div key={i} style={{padding: "12px 16px", borderBottom: i < product.audits.length - 1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12, background: "var(--surface)"}}>
<div style={{width: 8, height: 8, borderRadius: "50%", background: a.mode === "weigh" ? "var(--sage)" : a.mode === "estimate" ? "var(--amber)" : "var(--plum)"}} />
<div style={{flex: 1}}>
<div style={{fontSize: 13, fontWeight: 500}}>
{a.mode === "weigh" && "Weighed"}
{a.mode === "estimate" && "Estimated"}
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{fmt.date(a.date)} · {fmt.daysAgo(a.date)}</div>
</div>
<div className="mono" style={{fontSize: 13, textAlign: "right"}}>
<div>{a.value} {cfg?.unit}</div>
<div style={{fontSize: 10, color: "var(--ink-3)"}}>was {a.prev} {cfg?.unit}</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Details list */}
<div style={{marginTop: 36}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 12}}>Details</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: "14px 32px"}}>
{[
["SKU", <span className="mono">{product.sku}</span>],
["Asset tag", product.assetTag ? <span className="mono">{product.assetTag}</span> : <span style={{color: "var(--ink-3)"}}>None</span>],
["Type", `${product.type} · ${product.kind}`],
["Brand", H.brandName(data, product.brandId)],
["Shop", H.shopName(data, product.shopId)],
["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`],
["Purchase date", fmt.date(product.purchaseDate)],
["Bin", bin ? `${bin.name} — ${bin.location}` : <span style={{color: "var(--ink-3)"}}>—</span>],
["Audit cadence", `Every ${cfg?.cadenceDays || "—"} days · ${cfg?.auditMode || "—"}`],
["Cost per gram",
product.kind === "bulk" && product.weight > 0
? fmt.money(product.price / product.weight)
: product.kind === "discrete" && product.unitWeight > 0
? `${fmt.money(product.price / (product.countOriginal * product.unitWeight))} (effective)`
: "—"
],
...(product.status === "consumed" ? [
["Date finished", fmt.date(product.consumedDate)],
["Lasted", `${Math.round((new Date(product.consumedDate) - new Date(product.purchaseDate))/86400000)} days`]
] : []),
...(product.status === "gone" ? [
["Date gone", fmt.date(product.goneDate)],
["After", `${Math.round((new Date(product.goneDate) - new Date(product.purchaseDate))/86400000)} days`]
] : [])
].map(([l, v], i) => (
<div key={i} style={{display: "flex", justifyContent: "space-between", paddingBottom: 12, borderBottom: "1px solid var(--line)"}}>
<span style={{color: "var(--ink-3)", fontSize: 12}}>{l}</span>
<span style={{fontSize: 13, fontWeight: 500, textAlign: "right"}}>{v}</span>
</div>
))}
</div>
</div>
{/* Final notes */}
{(product.status === "consumed" || product.status === "gone") && (
<div style={{marginTop: 36, padding: 24, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 12}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{product.status === "gone" ? "Why it's gone" : "Final notes"}</div>
{product.status === "consumed" && (
<div style={{display: "flex", gap: 2}}>
{[1,2,3,4,5].map(n => (
<Icon key={n} name="star" size={14} color={n <= (product.rating || 0) ? "var(--amber)" : "var(--ink-4)"} />
))}
</div>
)}
</div>
<div className="serif" style={{fontSize: 18, lineHeight: 1.5, color: "var(--ink-2)", fontStyle: "italic"}}>
"{product.notes || 'No notes recorded.'}"
</div>
</div>
)}
</div>
</div>
</div>
);
};
Object.assign(window, { Dashboard, Inventory, ProductDetail, remainingDisplay, remainingShort });
+664
View File
@@ -0,0 +1,664 @@
// Add Product, Mark Consumed/Gone, Audit, Bins, Charts, Settings
const AddProductFlow = ({data, onClose, onSave}) => {
const [form, setForm] = React.useState({
name: "", brandId: data.brands[0].id, shopId: data.shops[0].id, type: "Flower",
weight: 3.5, countOriginal: 1, unitWeight: 0.7,
price: 45, thc: 22, cbd: 0.4, totalCannabinoids: 26,
purchaseDate: "2026-04-25", binId: data.bins[0].id,
sku: "", assetTag: ""
});
const update = (k, v) => setForm(f => ({...f, [k]: v}));
const cfg = data.types.find(t => t.id === form.type);
const isDiscrete = cfg?.kind === "discrete";
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(840px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>New entry</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Add a product</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Identity</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28}}>
<Field label="Product name" span={2}><Input value={form.name} placeholder="e.g. Garden Ghost" onChange={e => update("name", e.target.value)} /></Field>
<Field label="Brand"><Select value={form.brandId} onChange={e => update("brandId", e.target.value)}>{data.brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}</Select></Field>
<Field label="Shop"><Select value={form.shopId} onChange={e => update("shopId", e.target.value)}>{data.shops.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}</Select></Field>
<Field label="Type"><Select value={form.type} onChange={e => update("type", e.target.value)}>{data.types.map(t => <option key={t.id} value={t.id}>{t.id} ({t.kind})</option>)}</Select></Field>
<Field label="Bin"><Select value={form.binId} onChange={e => update("binId", e.target.value)}>{data.bins.map(b => <option key={b.id} value={b.id}>{b.name} {b.location}</option>)}</Select></Field>
<Field label="SKU" hint="Leave blank — we'll generate one"><Input value={form.sku} placeholder="SKU-…" onChange={e => update("sku", e.target.value)} /></Field>
<Field label="Asset tag (optional)" hint="If you've physically tagged the item"><Input value={form.assetTag} placeholder="AT-0000" onChange={e => update("assetTag", e.target.value)} /></Field>
</div>
<div className="smallcaps" style={{color: "var(--ink-3)", marginBottom: 16}}>Acquisition</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8}}>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg.unit})`}><Input type="number" step="1" value={form.countOriginal} onChange={e => update("countOriginal", +e.target.value)} /></Field>
<Field label="Per-unit weight (g)" hint="For grams stats"><Input type="number" step="0.1" value={form.unitWeight} onChange={e => update("unitWeight", +e.target.value)} /></Field>
</>
) : (
<Field label={`Size (${cfg?.unit || "g"})`} span={2}><Input type="number" step="0.1" value={form.weight} onChange={e => update("weight", +e.target.value)} /></Field>
)}
<Field label="Price ($)"><Input type="number" step="0.01" value={form.price} onChange={e => update("price", +e.target.value)} /></Field>
<Field label="Purchase date"><Input type="date" value={form.purchaseDate} onChange={e => update("purchaseDate", e.target.value)} /></Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{marginTop: 12, fontSize: 12, color: "var(--ink-3)"}}>
Cost per {cfg?.unit || "g"}: <span className="mono" style={{color: "var(--ink-2)"}}>{fmt.money(cpg)}</span>
</div>
)}
<div className="smallcaps" style={{color: "var(--ink-3)", margin: "28px 0 16px"}}>Cannabinoid profile</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16}}>
<Field label="THC %"><Input type="number" step="0.1" value={form.thc} onChange={e => update("thc", +e.target.value)} /></Field>
<Field label="CBD %"><Input type="number" step="0.1" value={form.cbd} onChange={e => update("cbd", +e.target.value)} /></Field>
<Field label="Total cannabinoids %"><Input type="number" step="0.1" value={form.totalCannabinoids} onChange={e => update("totalCannabinoids", +e.target.value)} /></Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
{form.name ? `"${form.name}" → ${data.bins.find(b=>b.id===form.binId)?.name}.` : "Fill in the name to continue."}
</div>
<div style={{display: "flex", gap: 8}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" disabled={!form.name} onClick={() => onSave(form)}>Save product</Btn>
</div>
</div>
</div>
</div>
);
};
// ─── CONSUME (mark finished) ──────────────────────────────────────
const ConsumeFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [rating, setRating] = React.useState(4);
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const bin = data.bins.find(b => b.id === product.binId);
const lifespan = Math.round((new Date(date) - new Date(product.purchaseDate))/86400000);
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Archive · used up</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as finished</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{active.map(p => <option key={p.id} value={p.id}>{p.name} {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
</Select>
</Field>
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="serif" style={{fontSize: 22, fontWeight: 500}}>{product.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>{H.brandName(data, product.brandId)} · {bin?.name} · purchased {fmt.dateShort(product.purchaseDate)}</div>
</div>
<div style={{textAlign: "right"}}>
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LASTED</div>
<div className="serif" style={{fontSize: 24}}>{lifespan} days</div>
</div>
</div>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24}}>
<Field label="Date finished"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
<Field label="Rating">
<div style={{display: "flex", gap: 4, alignItems: "center", padding: "10px 12px", background: "var(--bg)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
{[1,2,3,4,5].map(n => (
<button key={n} onClick={() => setRating(n)} style={{border: "none", background: "transparent", cursor: "pointer", padding: 2}}>
<Icon name="star" size={20} color={n <= rating ? "var(--amber)" : "var(--ink-4)"} />
</button>
))}
<span style={{marginLeft: "auto", fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{rating}/5</span>
</div>
</Field>
</div>
<div style={{marginTop: 16}}>
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="What stood out?" />
</Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>Mark finished</Btn>
</div>
</div>
</div>
);
};
// ─── MARK GONE (lost / damaged / expired) ─────────────────────────
const MarkGoneFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [reason, setReason] = React.useState("lost");
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const reasons = [
["lost", "Lost / misplaced"],
["damaged", "Damaged"],
["expired", "Expired"],
["gifted", "Gifted away"],
["other", "Other"]
];
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(640px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--terracotta)"}}>Archive · not consumed</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>Mark as gone</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<div style={{fontSize: 13, color: "var(--ink-2)", marginBottom: 20, padding: 14, background: "var(--amber-soft)", borderRadius: "var(--r-md)"}}>
Use this when an item is lost, damaged, expired, or gifted away. Counts as <strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
</div>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{active.map(p => <option key={p.id} value={p.id}>{p.name} {H.brandName(data, p.brandId)} ({remainingShort(p)} left)</option>)}
</Select>
</Field>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16}}>
<Field label="Reason">
<Select value={reason} onChange={e => setReason(e.target.value)}>
{reasons.map(([k,l]) => <option key={k} value={k}>{l}</option>)}
</Select>
</Field>
<Field label="Date">
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
</Field>
</div>
<div style={{marginTop: 16}}>
<Field label="Notes (optional)" hint="What happened">
<Textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. Pack went through the wash" />
</Field>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "flex-end", gap: 8, background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="danger" icon="bin" onClick={onClose}>Mark gone</Btn>
</div>
</div>
</div>
);
};
// ─── AUDIT MODAL ──────────────────────────────────────────────────
const AuditFlow = ({data, onClose, product: initialProduct}) => {
const overdueFirst = [...data.products]
.filter(p => p.status === "active")
.sort((a, b) => H.daysSinceCheck(b) - H.daysSinceCheck(a));
const [productId, setProductId] = React.useState(initialProduct?.id || overdueFirst[0]?.id);
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
const cfg = product ? data.types.find(t => t.id === product.type) : null;
// For bulk: weighed/estimated value. For discrete: count + confirmedBy.
const last = product ? H.lastAudit(product) : null;
const initialValue = product
? (product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2))
: 0;
const [value, setValue] = React.useState(initialValue);
const [confirmedBy, setConfirmedBy] = React.useState("SKU");
React.useEffect(() => {
if (product) {
setValue(product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2));
}
}, [productId]);
if (!product) return null;
const auditMode = cfg?.auditMode || "weigh";
const prevValue = product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: (last ? last.value : product.weight);
const auditModeLabels = {
weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight." },
estimate: { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate." },
presence: { title: "Confirm presence", desc: "Verify the item is still where you left it. Count units if applicable." }
};
const ml = auditModeLabels[auditMode];
return (
<div style={{position: "fixed", inset: 0, background: "oklch(20% 0.02 60 / 0.4)", zIndex: 50, display: "flex", justifyContent: "center", alignItems: "flex-start", overflow: "auto"}} onClick={onClose}>
<div onClick={e => e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
<div style={{padding: "20px 32px", borderBottom: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Audit</div>
<h2 className="serif" style={{fontSize: 28, margin: "4px 0 0", fontWeight: 500}}>{ml.title}</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
<div style={{padding: 32}}>
<Field label="Product">
<Select value={productId} onChange={e => setProductId(e.target.value)}>
{overdueFirst.map(p => {
const od = H.auditOverdue(data, p);
const sc = H.daysSinceCheck(p);
return <option key={p.id} value={p.id}>{od ? "⚠ " : ""}{p.name} {H.brandName(data, p.brandId)} · {sc}d since check</option>;
})}
</Select>
</Field>
<div style={{marginTop: 16, padding: 16, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)"}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "center"}}>
<div>
<div className="serif" style={{fontSize: 20, fontWeight: 500}}>{product.name}</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
</div>
</div>
<div style={{textAlign: "right"}}>
<div className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>LAST CHECKED</div>
<div className="serif" style={{fontSize: 18}}>{last ? `${H.daysSinceCheck(product)}d ago` : "Never"}</div>
</div>
</div>
<div style={{fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic"}}>{ml.desc}</div>
</div>
<div style={{display: "grid", gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr", gap: 16, marginTop: 24}}>
<Field label={
product.kind === "discrete"
? `Count now (${cfg?.unit})`
: auditMode === "weigh" ? `Weight now (${cfg?.unit})` : `Estimate now (${cfg?.unit})`
}>
<Input
type="number"
step={product.kind === "discrete" ? "1" : "0.1"}
value={value}
onChange={e => setValue(e.target.value)}
/>
</Field>
<Field label="Date"><Input type="date" value={date} onChange={e => setDate(e.target.value)} /></Field>
{auditMode === "presence" && (
<Field label="Confirmed by">
<Select value={confirmedBy} onChange={e => setConfirmedBy(e.target.value)}>
<option value="SKU">SKU label</option>
<option value="asset">Asset tag</option>
<option value="visual">Visual ID</option>
</Select>
</Field>
)}
</div>
<div style={{marginTop: 20, padding: 14, background: "var(--surface)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Was</div>
<div className="serif" style={{fontSize: 22}}>{prevValue} {cfg?.unit}</div>
</div>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Now</div>
<div className="serif" style={{fontSize: 22, color: "var(--sage)"}}>{value} {cfg?.unit}</div>
</div>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Δ since last</div>
<div className="serif" style={{fontSize: 22, color: (+value - +prevValue) < 0 ? "var(--terracotta)" : "var(--ink)"}}>
{(+value - +prevValue).toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
</div>
</div>
</div>
</div>
<div style={{padding: "16px 32px", borderTop: "1px solid var(--line)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "var(--bg-2)", borderRadius: "0 0 var(--r-lg) var(--r-lg)"}}>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>Next audit due in {cfg?.cadenceDays}d</div>
<div style={{display: "flex", gap: 8}}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="primary" icon="check" onClick={onClose}>Save audit</Btn>
</div>
</div>
</div>
</div>
);
};
// ─── BINS ─────────────────────────────────────────────────────────
const BinsView = ({data, onSelectProduct}) => {
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 24}}>
<div>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{data.bins.length} bins</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Bins & storage</h1>
</div>
<Btn variant="secondary" icon="plus">New bin</Btn>
</div>
<div style={{fontSize: 14, color: "var(--ink-2)", marginBottom: 24, maxWidth: 600}}>
Where each active product physically lives. Archived items aren't assigned to a bin.
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))", gap: 14}}>
{data.bins.map(bin => {
const items = data.products.filter(p => p.binId === bin.id && p.status === "active");
const fillPct = items.length / bin.capacity;
const totalValue = items.reduce((s, p) => s + p.price * H.pctRemaining(p, "2026-04-25"), 0);
return (
<Card key={bin.id} padded={false} style={{display: "flex", flexDirection: "column"}}>
<div style={{padding: "20px 22px 16px", borderBottom: "1px solid var(--line)"}}>
<div style={{display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 4}}>
<h3 className="serif" style={{fontSize: 24, margin: 0, fontWeight: 500}}>{bin.name}</h3>
<Pill tone="outline">{items.length} / {bin.capacity}</Pill>
</div>
<div style={{fontSize: 12, color: "var(--ink-3)", display: "flex", justifyContent: "space-between"}}>
<span>{bin.location}</span>
<span className="mono">{fmt.money(totalValue)}</span>
</div>
<div style={{marginTop: 12, height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden"}}>
<div style={{width: `${Math.min(fillPct, 1)*100}%`, height: "100%", background: fillPct > 0.9 ? "var(--terracotta)" : fillPct > 0.7 ? "var(--amber)" : "var(--sage)"}} />
</div>
</div>
<div style={{padding: 8, flex: 1}}>
{items.length === 0 && <div style={{padding: 30, textAlign: "center", fontSize: 12, color: "var(--ink-3)", fontStyle: "italic"}}>Empty</div>}
{items.map(p => (
<div key={p.id} onClick={() => onSelectProduct(p)} style={{display: "flex", alignItems: "center", gap: 10, padding: "8px 14px", borderRadius: "var(--r-sm)", cursor: "pointer"}}>
<div style={{fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink-3)", width: 18}}>{TYPE_GLYPHS[p.type]}</div>
<div style={{flex: 1, minWidth: 0}}>
<div style={{fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>{p.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{H.brandName(data, p.brandId)}</div>
</div>
<div className="mono" style={{fontSize: 11, color: "var(--ink-2)"}}>{remainingShort(p)}</div>
</div>
))}
</div>
</Card>
);
})}
</div>
</div>
);
};
// ─── CHARTS ───────────────────────────────────────────────────────
const ChartsView = ({data, stats}) => {
const series = stats.series90.map(s => ({date: s.date, grams: s.grams, label: ""}));
const spendByMonth = {};
data.products.forEach(p => {
const k = p.purchaseDate.slice(0, 7);
spendByMonth[k] = (spendByMonth[k] || 0) + p.price;
});
const months = Object.entries(spendByMonth).sort();
const spendByShop = {};
data.products.forEach(p => {
const name = H.shopName(data, p.shopId);
spendByShop[name] = (spendByShop[name] || 0) + p.price;
});
const shopRanked = Object.entries(spendByShop).sort((a,b) => b[1]-a[1]);
return (
<div style={{padding: "32px 40px 80px", maxWidth: 1400, margin: "0 auto"}}>
<div style={{marginBottom: 24}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Last 90 days</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Patterns & spend</h1>
</div>
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 18}}>
<div className="serif" style={{fontSize: 22}}>Daily grams · 90 days</div>
<div style={{display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)"}}>
<div>Total <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{series.reduce((s,e)=>s+e.grams,0).toFixed(1)} g</span></div>
<div>Avg <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{(series.reduce((s,e)=>s+e.grams,0)/90).toFixed(2)} g/day</span></div>
<div>Items finished <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.consumedCount}</span></div>
{stats.goneCount > 0 && <div>Items gone <span className="serif" style={{fontSize: 18, color: "var(--ink)"}}>{stats.goneCount}</span></div>}
</div>
</div>
<BarChart data={series.map(s => ({value: s.grams, label: ""}))} height={180} color="var(--sage)" />
</Card>
<div style={{display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14}}>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by month</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
{months.map(([m, v]) => {
const max = Math.max(...months.map(x => x[1]));
const d = new Date(m + "-01");
return (
<div key={m} style={{display: "flex", alignItems: "center", gap: 12}}>
<div className="smallcaps" style={{color: "var(--ink-3)", width: 60}}>{d.toLocaleDateString("en-US", {month: "short", year: "2-digit"})}</div>
<div style={{flex: 1, height: 24, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--terracotta)", borderRadius: 4, opacity: 0.85}} />
</div>
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
</div>
);
})}
</div>
</Card>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 18}}>Spend by shop</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
{shopRanked.map(([s, v]) => {
const max = shopRanked[0][1];
return (
<div key={s} style={{display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1.5, fontSize: 13, color: "var(--ink-2)"}}>{s}</div>
<div style={{flex: 2, height: 8, background: "var(--bg-2)", borderRadius: 4, position: "relative"}}>
<div style={{width: `${(v/max)*100}%`, height: "100%", background: "var(--sage)", borderRadius: 4}} />
</div>
<div className="mono" style={{width: 70, textAlign: "right", fontSize: 13}}>{fmt.moneyShort(v)}</div>
</div>
);
})}
</div>
</Card>
</div>
<Card>
<div className="serif" style={{fontSize: 22, marginBottom: 6}}>Inferred consumption heatmap</div>
<div style={{fontSize: 12, color: "var(--ink-3)", marginBottom: 18}}>13 weeks · darker = higher inferred daily use, prorated across each item's lifespan</div>
<Heatmap series={series} />
</Card>
</div>
);
};
const Heatmap = ({series}) => {
const first = new Date(series[0].date);
const offset = first.getDay();
const cells = [];
for (let i = 0; i < offset; i++) cells.push(null);
series.forEach(s => cells.push(s));
while (cells.length < 13 * 7) cells.push(null);
const max = Math.max(...series.map(s => s.grams), 0.001);
const colorFor = (g) => {
if (g === 0) return "var(--bg-3)";
const t = g / max;
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
};
const days = ["S","M","T","W","T","F","S"];
return (
<div style={{display: "flex", gap: 8, alignItems: "flex-start"}}>
<div style={{display: "flex", flexDirection: "column", gap: 3, paddingTop: 18}}>
{days.map((d, i) => <div key={i} style={{height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d}</div>)}
</div>
<div style={{flex: 1}}>
<div style={{display: "grid", gridTemplateColumns: "repeat(13, 1fr)", gap: 3, marginBottom: 4}}>
{Array.from({length: 13}).map((_, w) => {
const firstDay = cells[w * 7];
return <div key={w} style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)", textAlign: "center"}}>{firstDay && new Date(firstDay.date).getDate() <= 7 ? new Date(firstDay.date).toLocaleDateString("en-US", {month: "short"}) : ""}</div>;
})}
</div>
<div style={{display: "grid", gridTemplateRows: "repeat(7, 1fr)", gridAutoFlow: "column", gap: 3}}>
{cells.map((c, i) => (
<div key={i} title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""} style={{
aspectRatio: "1",
minHeight: 14,
background: c ? colorFor(c.grams) : "transparent",
borderRadius: 2
}} />
))}
</div>
<div style={{display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 14, fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(t => (
<div key={t} style={{width: 14, height: 14, background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`, borderRadius: 2}} />
))}
<span>More</span>
</div>
</div>
</div>
);
};
// ─── SETTINGS ─────────────────────────────────────────────────────
const SettingsView = ({data, tweaks, onTweakChange}) => {
return (
<div style={{padding: "32px 40px 80px", maxWidth: 800, margin: "0 auto"}}>
<div style={{marginBottom: 24}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>Settings</div>
<h1 className="serif" style={{fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em"}}>Preferences</h1>
</div>
<Card style={{marginBottom: 14}}>
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Appearance</div>
<div style={{display: "flex", flexDirection: "column", gap: 14}}>
<SettingRow label="Theme" hint="Light parchment or dim ink">
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
{["light", "dark"].map(t => (
<button key={t} onClick={() => onTweakChange("theme", t)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.theme === t ? "var(--surface)" : "transparent", color: tweaks.theme === t ? "var(--ink)" : "var(--ink-3)", cursor: "pointer", textTransform: "capitalize"}}>{t}</button>
))}
</div>
</SettingRow>
<SettingRow label="Dashboard layout" hint="Editorial leans on type; data-dense packs more in">
<div style={{display: "inline-flex", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-md)", padding: 3}}>
{[["editorial","Editorial"], ["dense","Data-dense"], ["minimal","Minimal"]].map(([k,l]) => (
<button key={k} onClick={() => onTweakChange("dashboard", k)} style={{padding: "6px 14px", fontSize: 12, fontWeight: 500, borderRadius: 6, border: "none", background: tweaks.dashboard === k ? "var(--surface)" : "transparent", color: tweaks.dashboard === k ? "var(--ink)" : "var(--ink-3)", cursor: "pointer"}}>{l}</button>
))}
</div>
</SettingRow>
<SettingRow label="Tone">
<Select value={tweaks.tone} onChange={e => onTweakChange("tone", e.target.value)} style={{...inputStyle, width: 200}}>
<option value="botanical">Botanical (default)</option>
<option value="neutral">Neutral inventory</option>
<option value="discreet">Discreet (code names)</option>
</Select>
</SettingRow>
</div>
</Card>
{/* Shops */}
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
<div className="serif" style={{fontSize: 22}}>Shops</div>
<Btn variant="ghost" icon="plus">Add shop</Btn>
</div>
<div style={{border: "1px solid var(--line)", borderRadius: "var(--r-md)", overflow: "hidden"}}>
{data.shops.map((s, i) => {
const count = data.products.filter(p => p.shopId === s.id).length;
return (
<div key={s.id} style={{padding: "12px 16px", borderBottom: i < data.shops.length-1 ? "1px solid var(--line)" : "none", display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1}}>
<div style={{fontSize: 14, fontWeight: 500}}>{s.name}</div>
<div style={{fontSize: 11, color: "var(--ink-3)"}}>{s.location}</div>
</div>
<Pill tone="outline">{count} purchase{count===1?"":"s"}</Pill>
<Btn variant="ghost" icon="edit" />
</div>
);
})}
</div>
</Card>
{/* Brands */}
<Card style={{marginBottom: 14}}>
<div style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 14}}>
<div className="serif" style={{fontSize: 22}}>Brands</div>
<Btn variant="ghost" icon="plus">Add brand</Btn>
</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8}}>
{data.brands.map(b => {
const count = data.products.filter(p => p.brandId === b.id).length;
return (
<div key={b.id} style={{padding: "10px 14px", border: "1px solid var(--line)", borderRadius: "var(--r-md)", display: "flex", alignItems: "center", gap: 12}}>
<div style={{flex: 1, fontSize: 13, fontWeight: 500}}>{b.name}</div>
<span className="mono" style={{fontSize: 11, color: "var(--ink-3)"}}>{count}</span>
</div>
);
})}
</div>
</Card>
<Card style={{marginBottom: 14}}>
<div className="serif" style={{fontSize: 22, marginBottom: 16}}>Library</div>
<div style={{display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 16}}>
<Stat label="Active" value={data.products.filter(p=>p.status==="active").length} />
<Stat label="Consumed" value={data.products.filter(p=>p.status==="consumed").length} />
<Stat label="Gone" value={data.products.filter(p=>p.status==="gone").length} />
<Stat label="Bins" value={data.bins.length} />
</div>
<div style={{fontSize: 12, color: "var(--ink-3)"}}>All data is stored locally. Export anytime.</div>
<div style={{display: "flex", gap: 8, marginTop: 12}}>
<Btn variant="secondary">Export CSV</Btn>
<Btn variant="secondary">Export JSON</Btn>
<Btn variant="ghost" style={{color: "var(--terracotta)"}}>Reset all data</Btn>
</div>
</Card>
</div>
);
};
const SettingRow = ({label, hint, children}) => (
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: 14, borderBottom: "1px solid var(--line)"}}>
<div style={{flex: 1}}>
<div style={{fontSize: 13, fontWeight: 500}}>{label}</div>
{hint && <div style={{fontSize: 12, color: "var(--ink-3)", marginTop: 2}}>{hint}</div>}
</div>
{children}
</div>
);
Object.assign(window, { AddProductFlow, ConsumeFlow, MarkGoneFlow, AuditFlow, BinsView, ChartsView, SettingsView });
+113
View File
@@ -0,0 +1,113 @@
/* Apothecary / Botanical design tokens
Warm earthy neutrals — parchment, ink, sage, terracotta */
:root {
/* Surfaces — parchment-toned */
--bg: oklch(96% 0.012 80); /* parchment */
--bg-2: oklch(93% 0.014 80); /* slightly darker parchment */
--bg-3: oklch(89% 0.016 78); /* card divider, hover */
--surface: oklch(98% 0.008 82); /* card surface */
--surface-sunken: oklch(91% 0.014 80);
/* Ink — warm near-blacks */
--ink: oklch(22% 0.012 60); /* primary text */
--ink-2: oklch(38% 0.012 60); /* secondary text */
--ink-3: oklch(56% 0.014 65); /* tertiary, captions */
--ink-4: oklch(72% 0.014 70); /* faint, dividers */
/* Lines */
--line: oklch(82% 0.014 75);
--line-strong: oklch(68% 0.016 70);
/* Accents — share chroma 0.08, lightness 52% */
--sage: oklch(52% 0.06 145); /* primary action / good */
--sage-2: oklch(64% 0.05 145);
--sage-soft: oklch(88% 0.03 145);
--terracotta: oklch(58% 0.10 40); /* warning / consumed */
--terracotta-soft: oklch(90% 0.04 40);
--amber: oklch(68% 0.10 75); /* low stock / attention */
--amber-soft: oklch(91% 0.04 75);
--plum: oklch(48% 0.06 340); /* secondary accent */
/* Typography */
--serif: "Cormorant Garamond", "GT Sectra", "Playfair Display", Georgia, serif;
--sans: "Inter", "Söhne", -apple-system, BlinkMacSystemFont, sans-serif;
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
/* Radii */
--r-sm: 4px;
--r-md: 8px;
--r-lg: 14px;
--r-xl: 20px;
/* Shadow — subtle */
--shadow-sm: 0 1px 2px oklch(20% 0.02 60 / 0.06);
--shadow-md: 0 2px 8px oklch(20% 0.02 60 / 0.08);
--shadow-lg: 0 8px 24px oklch(20% 0.02 60 / 0.10);
}
[data-theme="dark"] {
--bg: oklch(20% 0.012 60);
--bg-2: oklch(23% 0.012 60);
--bg-3: oklch(27% 0.014 65);
--surface: oklch(25% 0.012 60);
--surface-sunken: oklch(18% 0.012 60);
--ink: oklch(94% 0.008 80);
--ink-2: oklch(78% 0.012 75);
--ink-3: oklch(62% 0.014 70);
--ink-4: oklch(46% 0.014 65);
--line: oklch(34% 0.014 65);
--line-strong: oklch(48% 0.016 65);
--sage: oklch(70% 0.07 145);
--sage-2: oklch(60% 0.06 145);
--sage-soft: oklch(32% 0.04 145);
--terracotta: oklch(70% 0.10 40);
--terracotta-soft: oklch(32% 0.05 40);
--amber: oklch(78% 0.10 75);
--amber-soft: oklch(32% 0.05 75);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; }
/* Subtle parchment texture */
.parchment {
background-image:
radial-gradient(oklch(85% 0.03 75 / 0.15) 1px, transparent 1px),
radial-gradient(oklch(80% 0.03 75 / 0.10) 1px, transparent 1px);
background-size: 24px 24px, 36px 36px;
background-position: 0 0, 12px 18px;
}
/* Utility */
.serif { font-family: var(--serif); font-weight: 500; }
.mono { font-family: var(--mono); font-feature-settings: "ss01", "cv11"; }
.smallcaps {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
font-weight: 500;
}
.hairline { border-top: 1px solid var(--line); }
+419
View File
@@ -0,0 +1,419 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
border-radius:6px;cursor:default;padding:0}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
const setTweak = React.useCallback((key, val) => {
setValues((prev) => ({ ...prev, [key]: val }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});