Studio Notebook: vision, Phase 1 designs, and working prototype

- VISION.md: the full studio-backbone product vision (craft core, operations,
  selling, daily driver), Bonna's approved design decisions, and phasing
- design/: two clickable Phase 1 mockups (moonlit palette, CSS-only tabs)
- prototype/: zero-dependency local app (Node built-ins + SQLite) — Today with
  jot-first capture + subtask outliner, Clay & Glazes library, fill-and-go
  Firing Log with emoji unload ratings feeding evidence tables, Pieces with
  core line/tiles/one-of-a-kind + money card, Files with per-piece attachment,
  Sourcing with price ledger + live supplier search links (9 suppliers seeded)
- CLAUDE.md: access notes updated for the provisioned site + this repo's state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonna
2026-08-06 21:46:54 -05:00
commit cd83fd61cb
11 changed files with 1916 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "studio-notebook",
"runtimeExecutable": "node",
"runtimeArgs": ["prototype/server.mjs"],
"port": 6161
}
]
}

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
prototype/data/
.claude/settings.local.json
.DS_Store

104
CLAUDE.md Normal file
View File

@@ -0,0 +1,104 @@
# Bonna Moon Studio — access notes
Status as of 2026-08-06: **provisioned and live** at
https://bonnamoonstudio.poweredbyotm.com (`site` template v0.11.0, container
`site-bonnamoonstudio` on anvil). The repo remains empty by design — see "The plan"
below. This file covers how to reach things. There is no app code here to document.
The long-term product vision — a custom "studio backbone" app in the FMB mold
(glaze recipes, firing log, inventory, accounting, market scanner, kiln control) —
is captured in [VISION.md](VISION.md). The `site` instance is the storefront layer;
the backbone is a separate, phased build.
## The repo
- Gitea: `https://git.poweredbyotm.com/bonna61/Bonna-Moon-Studio.git`
- Remote name here is **`gitea`** (not `origin`) — matches the FMB convention.
- Local branch is `master`; FMB and the platform both use **`main`**. Rename before
the first commit, or let `git checkout main` create the tracking branch after the
remote has one.
```sh
cd "/Users/bonnamoon/Projects/Bonna Moon Studio"
git fetch gitea && git checkout main # once the remote has content
```
**Auth: macOS keychain.** There is no `~/.gitconfig` and no `~/.git-credentials` on
this machine; `credential.helper=osxkeychain` is set at the git *system* level. A
`git ls-remote gitea` completes with no prompt, so pushes and fetches over
`https://git.poweredbyotm.com` just work. Nothing needs a token in the URL.
Commit identity is set **locally** in this repo (there's no global fallback — commits
would fail without it):
```
user.name = Bonna
user.email = bonna@fullmoonbakehouse.com
```
### The other Gitea address
Same Gitea instance, three addresses depending on where you are:
| Address | When it works |
|---|---|
| `https://git.poweredbyotm.com` | Anywhere. Keychain auth. Use this. |
| `http://192.168.0.5:3022` | Home LAN only (NAS). |
| `http://167.237.9.184:3022` | Home public IP — what `anvil` uses, and the fallback when the Mac is off the LAN. |
The sibling repos (`moonbase`, `moon-household-budget`, `otm-platform`) embed an
access token directly in their remote URL against `192.168.0.5:3022`. Those tokens
are **only** recorded in those URLs — the `~/Projects/CLAUDE.md` that
[moonbase/docs/git-deploy-guide.md](../moonbase/docs/git-deploy-guide.md) refers to
does not exist on this machine. Read one back with:
```sh
git -C /Users/bonnamoon/Projects/moonbase remote get-url origin
```
Never commit a token.
## Hosts
- **`anvil`** = `debian@51.81.80.250` — OVH VPS, **production, live**. Serves
`*.poweredbyotm.com` with real Let's Encrypt certs. Wildcard `*` A-record, so any
new `<slug>.poweredbyotm.com` resolves with zero DNS work. Host user is `debian`,
not `tonym`. Can't reach the home LAN — its builds use `167.237.9.184:3022`.
- **`forge`** = `tonym@192.168.0.83` — Unraid VM, staging, `*.local` hostnames with a
local CA. Needs the home LAN.
- OTM Admin: **https://admin.poweredbyotm.com** (anvil) — provisioning, template
builds, per-service ops, logs.
A connected MCP server exposes the same control plane read/write (`platform_status`,
`list_instances`, `list_templates`, `onboard`, deploy + log tools). Call
`platform_status` or `list_instances` first for an overview. It points at **live
production** — treat `onboard`, deploys, and restarts as real-world actions.
Platform source: `/Users/bonnamoon/Projects/platform` (and `otm-platform`). Its
`CLAUDE.md` is the authority on deploys, the CI-gated auto-deploy webhook, and the
long list of anvil-vs-forge gotchas.
## The plan (executed 2026-08-06)
One app with public pages **plus** an `/admin` back office — the **`site`** template
("Web — hosting + site + account", v0.11.0), same as `home`, `overthemoon`, and
`vvts`. Public site plus the drag-and-drop CMS page editor from `@otm/account-panel`.
Provisioned via `onboard`: customer **Bonna Moon Studio**, app/service slug
`bonnamoonstudio`, industry `universal`, owner **bonna@bonnamoonstudio.com**.
Live at **https://bonnamoonstudio.poweredbyotm.com** (admin at `/admin`). The
seeded owner password was captured and handed to Bonna at provisioning time; it is
deliberately not recorded anywhere in this repo.
What it is: Bonna's **art studio** — ceramics, carved wood, and sterling
silver/ceramic jewelry. The prior site at bonnamoonstudio.com (shop temporarily
closed during the studio rebuild) has the source content: product lines, collector
testimonials, newsletter signup. That domain still points at the old site; cutover
is a future DNS step.
Remaining notes:
- **The `site` template does not use this repo.** Its code lives in the platform
monorepo at `apps/site`; per-instance content lives in that instance's Postgres, not
in git. This repo staying empty is expected. Giving it real code means authoring a
new platform template and build chain — days, not minutes.

6
Start Studio Notebook.command Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/zsh
# Double-click me to start the Studio Notebook 🌙
cd "$(dirname "$0")"
open -g "http://localhost:6161" 2>/dev/null || true
(sleep 1 && open "http://localhost:6161") &
exec node prototype/server.mjs

170
VISION.md Normal file
View File

@@ -0,0 +1,170 @@
# Bonna Moon Studio — the system Bonna actually wants
Captured 2026-08-06, in Bonna's words: "a BEAST of a system that is SUPER easy to
use for me day to day... cast a large net and then condense functions down until
they're easy to use... I'm looking to build an EXTENSIVE thing."
This is a **studio backbone** in the FMB sense — a custom back-office app, not the
`site` template plus modules. FMB's backbone (recipes → costed ingredients → stock →
orders → schedule → margin/break-even) is the architectural blueprint; this maps
bakery concepts to a ceramics + woodworking studio.
The public site (live at bonnamoonstudio.poweredbyotm.com, `site` template) stays as
the storefront/marketing layer regardless.
## The feature net (everything named so far)
### Studio craft core
- **Glaze recipe library** — store glaze recipes; the FMB `recipe_cost` analogue.
- **Slipcasting & molds** (added 2026-08-06 — Bonna is getting into this soon):
- **Mold library** — molds are durable studio assets, like glazes: each gets a
page with photos, what it casts, number of parts, plaster notes, and a
**cast counter** (plaster molds wear out — the app should know a mold's age).
- **Casting slip recipes** — same recipe system as glazes but a distinct type:
slips care about specific gravity AND viscosity/deflocculant (Darvan etc.),
and get their own journal (cast times, slip condition).
- **Casting log entries** — analogous to firings: which molds, which slip,
cast time, results.
- Pieces gain a "how it was made" (thrown / handbuilt / carved / slipcast /
wood) — matters for provenance, pricing, and the learning tables.
- Mold-making shopping: plaster, cottle boards, sodium silicate/Darvan — the
one shopping list absorbs all of it.
Phase 1 schema implication: "recipes" are typed (glaze | slip | …) and molds
are first-class assets from day one, even if mold screens land a bit later.
- **Welding / metal sculpture** (added 2026-08-06 — Bonna is "VERY very very
new"): no dedicated screens yet — the generic bones carry it. Notes with
photos and a "welding" tag become the practice journal (beginner logs are
gold later); steel stock, rod, gas, and consumables go in pantry + shopping
list; pieces' "how it was made" gains `welded`. If it grows into a serious
line, it earns its own module later (settings that worked, joint notes,
material costs per sculpture) — same pattern as everything else: start as
notes, graduate to structure when the data asks for it.
- **Firing log** — notes on firings; over time links glazes ↔ clay bodies ↔ cone/
schedule ↔ results.
- **Clay body ↔ glaze compatibility** — the system *learns* what works together,
fed by the firing log. (The "learning" is structured data + photos first; smart
suggestions later.)
- **Glazy integration** — hook into glazy.org (the open glaze-recipe database) for
recipes, chemistry, and photos of fired results.
- **Kiln control** — Tony is programming a kiln controller; the backbone should
eventually schedule/monitor/fire it. Interface TBD with Tony (hardware project,
separate repo presumably).
### Studio operations
- **Inventory** — both raw materials (clay, glaze chemicals, wood stock) and
finished pieces; back-end tracking AND shop-facing availability.
- **Cost tracking** — materials cost per piece, per firing, per line.
- **Accounting** — "like we have on FMB"; full income/expense books.
- **Break-even calculator** — FMB has one; generalize it, esp. for market/show
applications (fees + travel vs expected sales).
### Selling & shows
- **Market scanner** — scan local markets/art fairs/shows worth applying to;
surface deadlines, application costs, distance to travel. Paired with the
break-even calc for a "worth it?" verdict.
### Daily driver
- **Notes** — extensive, effortless note-taking with photos (studio notes, piece
notes, firing notes all interlink).
- **To-do list** — "a really good one."
- **Calendar** — firings, markets, deadlines, commissions.
## Design principle
Cast a large net, then **condense until easy**. Day-to-day ease of use beats
feature count. Every screen should answer "what do I need to do/know right now."
Key context: Bonna has been away from her art for ~2 years and is building out a
brand-new studio space. There is **no existing workflow to preserve** — the app
and the studio habits get designed together, from scratch. The system's job is to
be the "solid organizational net" under her return: she should be able to jump
back in and trust that recipes, firings, notes, and tasks are held somewhere that
isn't her head. Design for re-entry: low friction, forgiving, encouraging.
## Open architecture questions (for Tony + Bonna)
1. **Where does the code live?** Platform templates live in the platform monorepo
with a build chain (per CLAUDE.md, authoring a new template = days). Options:
new `bms-backbone` template in the monorepo (FMB convention), or app code in
this repo with its own build. FMB convention suggests the former.
2. **One app or two?** Backbone (admin/ops) + existing `site` storefront, like
FMB + fullmoonbakehouse.poweredbyotm.com. Probably: keep them separate,
integrate inventory → shop later.
3. **Kiln controller interface** — what protocol/API will Tony's controller
expose? Drives how "fire my kiln" lands in the app.
4. **Market scanner data source** — scraping? manual seed list + assisted search?
This one needs a feasibility pass; deadlines/fees aren't in any clean API.
## Prototype (built 2026-08-06)
Bonna's product structure (2026-08-06): the reintroduced **core line** (solid
kitchen tools, serveware) alongside **small batch / one of a kind** work
(sculptural pieces, home scenting). The app tracks them "together but
separate" — one Pieces tab, distinct sections and behaviors (counts vs
statuses). Money tracking starts as price + rough cost estimate per piece with
one-tap sales; FMB-style true costing comes with Phase 2 accounting.
Phase 1 exists as a **working local prototype** in [prototype/](prototype/) —
zero dependencies (Node built-ins only), runs via `Start Studio Notebook.command`
in the repo root, data in gitignored `prototype/data/`. See
[prototype/README.md](prototype/README.md) for scope. It implements the approved
decisions above (jot-first, fill-and-go firings, emoji unload ratings,
evidence-only compatibility, batch scaling) and serves as the schema/UX
reference for the production build.
## Rough phasing (to refine)
- **Phase 1 — the notebook**: glaze recipes, firing log, notes with photos,
materials list. (Highest daily value, zero external dependencies, and it starts
accumulating the data the "learning" features need.) A clickable design sketch
Bonna liked lives at
[design/phase1-studio-notebook-mockup.html](design/phase1-studio-notebook-mockup.html)
— soft plum/blush/sage "moonlit studio" palette, Today/Glazes/Firing Log/Notes
tabs, jot-box-first capture with optional "file it as…" chips. Note: it uses
CSS-only tabs deliberately (preview panes strip scripts).
Deep screens sketched in
[design/phase1-glaze-firing-screens.html](design/phase1-glaze-firing-screens.html):
a glaze's detail page, new-glaze form, start-a-firing, and unload day.
**Decisions Bonna has approved:**
- Capture model: jot-first, optional one-tap "file it as…" chips (note / glaze /
firing / to-do / piece).
- Glaze page: recipe as % + scaled batch weights (500g/1kg/2kg/5gal); a
"plays well with" clay-body table built ONLY from unload-day evidence, never
edited directly; running journal; photo wall; **Stull chart** ("the chemistry
map") computed from the recipe with region overlay (crazed/gloss/semi-matte/
matte/under-fired) — Glazy-style, regions adjusted per cone; other glazes
appear as faded dots.
- Firing lifecycle: plan → load → fire → unload, as explicit steps. Load rows
are the piece + clay + glaze triple (this feeds the compatibility learning).
Kiln-photo-before-closing nudge.
- Unload day: one-tap rating per item, emoji scale 😍🙂😕💔 — approved "for
now, maybe change in production." One save fans out: pieces → ready-to-sell,
glaze pages gain evidence rows, firing closes.
- New glaze: name-only minimum; cone/atmosphere/surface/source as chips;
materials autocomplete from pantry (ties to inventory later); "Save & mix a
test batch" secondary action; Glazy paste-to-import deferred to Phase 4.
- **Shopping list** (added at Bonna's request): ONE running list for
everything — glaze materials, tools, packaging, business cards, stickers,
paper goods. Lives on Today; items arrive from jots ("order more wax
resist"), from pantry-low signals, or typed directly. Phase 1 scope.
- **Firing flow must not be fussy** — Bonna: "the fussier it is, the less
likely I am to use the tool… more of a fill the kiln and go kind of girl."
The plan→load→fire→unload steps are a trail, not a form: a firing can be
started with nothing but type+cone, and the load can be described later,
even at unload. Efficient load *planning* is a someday-nice-to-have, never
required.
- Context on glaze chemistry: Bonna took a course with Katz (Ceramic
Materials Workshop) — solid theory, thin practice. Don't dumb the chemistry
down; do build experience-confidence (the Stull chart and evidence tables
serve exactly this).
- **Phase 2 — the business**: inventory (materials + pieces), cost tracking,
break-even calc, accounting.
- **Phase 3 — the calendar & todos**: calendar, to-do list, market/show deadline
tracking (manual entry first).
- **Phase 4 — the reach**: market scanner, Glazy integration, shop-facing
inventory on the public site.
- **Phase 5 — the kiln**: controller integration when Tony's hardware is ready.
Phasing is by dependency and daily value, not importance — the kiln being last
doesn't mean it matters least.

View File

@@ -0,0 +1,295 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Glaze & Firing screens — Phase 1 sketch</title>
<style>
:root {
--bg: #fdf7f2; --card: #ffffff; --ink: #4a3b47;
--plum: #8d6b94; --plum-deep: #6e4f78; --blush: #f3dbe4; --blush-deep: #c98da8;
--sage: #8fa68a; --sage-soft: #e4ecdf; --butter: #f7ecd8; --line: #eddfe3;
--moon: #f0e6f4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
.wrap { max-width: 900px; margin: 0 auto; padding: 0 20px 60px; }
header { padding: 30px 0 0; }
.banner { background: var(--butter); border: 1.5px dashed var(--blush-deep); border-radius: 14px; padding: 10px 16px; font-size: 13px; color: #8d6a52; margin-bottom: 22px; }
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 26px; font-weight: 600; color: var(--plum-deep); }
.sub { color: #9c8698; font-size: 14.5px; margin-top: 3px; font-style: italic; font-family: Georgia, serif; }
.tab-radio { position: absolute; opacity: 0; pointer-events: none; }
nav { display: flex; gap: 8px; margin: 22px 0 26px; flex-wrap: wrap; }
nav label { border: 1.5px solid var(--line); background: var(--card); color: #a08fa0; padding: 9px 16px; border-radius: 999px; font-size: 14px; cursor: pointer; font-weight: 600; transition: all .15s; }
nav label:hover { border-color: var(--blush-deep); color: var(--plum-deep); }
.panel { display: none; }
#s-glaze:checked ~ .wrap nav label[for="s-glaze"],
#s-newglaze:checked ~ .wrap nav label[for="s-newglaze"],
#s-firing:checked ~ .wrap nav label[for="s-firing"],
#s-results:checked ~ .wrap nav label[for="s-results"] { background: var(--plum); border-color: var(--plum); color: #fff; }
#s-glaze:checked ~ .wrap #glaze, #s-newglaze:checked ~ .wrap #newglaze,
#s-firing:checked ~ .wrap #firing, #s-results:checked ~ .wrap #results { display: block; }
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; }
@media (max-width: 640px){ .g2 { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 18px 20px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.card h3 { font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--blush-deep); font-weight: 700; margin-bottom: 12px; }
h4 { font-family: Georgia, serif; font-size: 17px; font-weight: 600; color: var(--plum-deep); }
.meta { color: #9c8698; font-size: 13.5px; }
.pill { display: inline-block; font-size: 12px; font-weight: 650; padding: 3px 11px; border-radius: 999px; background: var(--blush); color: var(--plum-deep); }
.pill.sage { background: var(--sage-soft); color: #5c7157; }
.pill.butter { background: var(--butter); color: #8d6a52; }
.pill.ghost { background: transparent; border: 1.5px dashed var(--blush-deep); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #b1a0ae; font-weight: 700; padding: 4px; }
th:last-child, td:last-child { text-align: right; }
td { padding: 7px 4px; border-bottom: 1px solid var(--line); }
td:last-child { color: #9c8698; font-variant-numeric: tabular-nums; }
tr:last-child td { border: none; }
.hero { display: flex; gap: 18px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
.hero .swatch { width: 110px; height: 110px; border-radius: 16px; background: linear-gradient(150deg,#b8cec4,#8fb0a3); border: 1px solid rgba(0,0,0,0.05); flex-shrink: 0; }
.hero .pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; font-size: 14px; }
.photos { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.ph { width: 64px; height: 64px; border-radius: 10px; background: linear-gradient(135deg, var(--blush), var(--moon)); display: flex; align-items: center; justify-content: center; font-size: 22px; }
.ph.add { background: transparent; border: 1.5px dashed var(--blush-deep); color: var(--blush-deep); font-size: 26px; }
.mt { margin-top: 14px; }
.matrix td:first-child { font-weight: 600; }
.yes { color: #5c7157; font-weight: 700; } .no { color: #b3556b; font-weight: 700; } .unk { color: #b1a0ae; }
.field { margin-bottom: 13px; }
.field label { display: block; font-size: 12.5px; font-weight: 700; color: var(--blush-deep); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px; }
.field input[type=text], .field textarea { width: 100%; border: 1.5px solid var(--line); border-radius: 12px; padding: 11px 14px; font-size: 14.5px; background: var(--bg); color: var(--ink); font-family: inherit; }
.choices { display: flex; gap: 6px; flex-wrap: wrap; }
.choice { font-size: 13px; font-weight: 650; padding: 6px 14px; border-radius: 999px; background: var(--card); border: 1.5px solid var(--line); color: #a08fa0; cursor: pointer; }
.choice.on { background: var(--plum); border-color: var(--plum); color: #fff; }
.btn { display: inline-block; border: none; border-radius: 12px; padding: 11px 22px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; }
.btn.primary { background: var(--plum); color: #fff; }
.btn.soft { background: var(--blush); color: var(--plum-deep); }
.steps { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.step { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); }
.step.dim { background: transparent; color: #b1a0ae; border: 1px dashed var(--line); }
.loadrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; flex-wrap: wrap; }
.loadrow:last-child { border: none; }
.verdict { display: flex; gap: 6px; }
.verdict span { font-size: 16px; padding: 5px 10px; border-radius: 10px; background: var(--bg); border: 1.5px solid var(--line); cursor: pointer; }
.verdict span.on { background: var(--sage-soft); border-color: var(--sage); }
.note-inline { font-size: 13px; color: #9c8698; font-style: italic; }
.footnote { color: #b1a0ae; font-size: 13px; margin-top: 28px; text-align: center; font-style: italic; font-family: Georgia, serif; }
.sparkle { background: var(--sage-soft); border-radius: 12px; padding: 10px 14px; font-size: 13.5px; color: #55624c; margin-top: 12px; }
</style>
</head>
<body>
<input class="tab-radio" type="radio" name="tabs" id="s-glaze" checked>
<input class="tab-radio" type="radio" name="tabs" id="s-newglaze">
<input class="tab-radio" type="radio" name="tabs" id="s-firing">
<input class="tab-radio" type="radio" name="tabs" id="s-results">
<div class="wrap">
<header>
<div class="banner">✏️ Sketch round 3 — the deep screens. Four tabs = four different moments in studio life.</div>
<h1>🌙 Glazes & Firings — the deep screens</h1>
<div class="sub">a glaze's home page · adding a new one · loading the kiln · unload day</div>
</header>
<nav>
<label for="s-glaze">🧪 A glaze's page</label>
<label for="s-newglaze"> New glaze</label>
<label for="s-firing">🔥 Start a firing</label>
<label for="s-results">✨ Unload day</label>
</nav>
<!-- ============ GLAZE DETAIL ============ -->
<section class="panel" id="glaze">
<div class="card">
<div class="hero">
<div class="swatch"></div>
<div>
<h4>Sea-glass Celadon</h4>
<div class="meta">∆6 oxidation · glossy · translucent · dip 3s ×3</div>
<div class="pills">
<span class="pill sage">7 firings logged</span>
<span class="pill">S.G. 1.45</span>
<span class="pill butter">from my old notebook</span>
</div>
</div>
</div>
<div class="grid g2">
<div>
<h3>Recipe <span class="note-inline">· tap a number to edit</span></h3>
<table>
<tr><th>Material</th><th>%</th><th>2,000 g batch</th></tr>
<tr><td>Custer Feldspar</td><td>38</td><td>760 g</td></tr>
<tr><td>Silica</td><td>28</td><td>560 g</td></tr>
<tr><td>Whiting</td><td>18</td><td>360 g</td></tr>
<tr><td>EPK Kaolin</td><td>16</td><td>320 g</td></tr>
<tr><td> Copper Carbonate</td><td>0.5</td><td>10 g</td></tr>
<tr><td> Bentonite</td><td>2</td><td>40 g</td></tr>
</table>
<div class="mt"><span class="pill ghost">⚖️ scale batch: 500g · 1kg · 2kg · 5 gal</span></div>
</div>
<div>
<h3>Plays well with…</h3>
<table class="matrix">
<tr><th>Clay body</th><th>Verdict</th><th>Evidence</th></tr>
<tr><td>B-Mix</td><td class="yes">♥ lovely</td><td>4 firings</td></tr>
<tr><td>Speckled Brownstone</td><td class="yes">✓ good</td><td>2 firings</td></tr>
<tr><td>Porcelain</td><td class="unk">? untested</td><td></td></tr>
<tr><td>Dark stoneware</td><td class="no">✗ pinholes</td><td>1 firing</td></tr>
</table>
<div class="sparkle">🌱 This table grows by itself — every unload day where you rate a piece adds a row of evidence. You never fill it in directly.</div>
</div>
</div>
<div class="mt">
<h3>Journal</h3>
<div class="result">📒 <b>Jul 12</b> — thin = washes out. 3 dips or S.G. 1.45. Pools gorgeously in carved lines.</div>
<div class="result mt">📒 <b>Jun 30</b> — variation #2 with +1% copper: too dark, muddy. Staying with original.</div>
</div>
<div class="mt">
<h3>Test tiles & pieces</h3>
<div class="photos"><div class="ph">🧪</div><div class="ph">🏺</div><div class="ph">🫖</div><div class="ph">🥣</div><div class="ph add"></div></div>
</div>
</div>
<div class="card mt">
<h3>Where it lands — the chemistry map <span class="note-inline">· the Stull chart, like Glazy's</span></h3>
<svg viewBox="0 0 380 310" role="img" aria-label="Stull chart: Sea-glass Celadon plotted in the bright gloss region" style="width:100%;max-width:560px;display:block">
<!-- regions (schematic; the real app computes these per cone) -->
<polygon points="40,20 340,20 340,60 90,80 40,90" fill="#f0ece6"/>
<polygon points="40,90 90,80 110,260 40,260" fill="#f3dbe4" opacity="0.75"/>
<polygon points="90,80 340,60 340,120 150,260 110,260" fill="#e4ecdf"/>
<polygon points="340,120 340,180 200,260 150,260" fill="#f0e6f4"/>
<polygon points="340,180 340,260 200,260" fill="#f7ecd8"/>
<!-- region labels -->
<text x="185" y="45" font-size="11" fill="#a08fa0" text-anchor="middle" font-style="italic">under-fired</text>
<text x="63" y="170" font-size="11" fill="#b3849a" text-anchor="middle" font-style="italic" transform="rotate(-78 63 170)">crazed</text>
<text x="205" y="115" font-size="12" fill="#5c7157" text-anchor="middle" font-weight="600" font-style="italic">bright gloss</text>
<text x="252" y="212" font-size="11" fill="#8d6b94" text-anchor="middle" font-style="italic">semi-matte</text>
<text x="303" y="240" font-size="11" fill="#8d6a52" text-anchor="middle" font-style="italic">matte</text>
<!-- axes -->
<line x1="40" y1="20" x2="40" y2="260" stroke="#d8c9ce" stroke-width="1.5"/>
<line x1="40" y1="260" x2="340" y2="260" stroke="#d8c9ce" stroke-width="1.5"/>
<text x="190" y="290" font-size="11.5" fill="#9c8698" text-anchor="middle">alumina (Al₂O₃) →</text>
<text x="16" y="140" font-size="11.5" fill="#9c8698" text-anchor="middle" transform="rotate(-90 16 140)">silica (SiO₂) →</text>
<text x="90" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.2</text>
<text x="190" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.4</text>
<text x="290" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.6</text>
<text x="32" y="216" font-size="10" fill="#b1a0ae" text-anchor="end">2</text>
<text x="32" y="168" font-size="10" fill="#b1a0ae" text-anchor="end">3</text>
<text x="32" y="120" font-size="10" fill="#b1a0ae" text-anchor="end">4</text>
<text x="32" y="72" font-size="10" fill="#b1a0ae" text-anchor="end">5</text>
<!-- other glazes, faded -->
<circle cx="250" cy="195" r="5" fill="#c9b8d4"/>
<text x="250" y="184" font-size="9.5" fill="#b1a0ae" text-anchor="middle">Toasted Amber</text>
<circle cx="290" cy="217" r="5" fill="#c9b8d4"/>
<text x="296" y="232" font-size="9.5" fill="#b1a0ae" text-anchor="middle">Oat Matte</text>
<!-- this glaze -->
<circle cx="165" cy="145" r="8" fill="#6e4f78" stroke="#ffffff" stroke-width="2.5"/>
<text x="165" y="132" font-size="11" fill="#6e4f78" text-anchor="middle" font-weight="700">Sea-glass Celadon</text>
</svg>
<div class="sparkle">🧮 Computed from the recipe automatically — no data entry. The dot says <i>why</i> it's glossy; nudge silica or alumina and watch the dot move before you mix anything. Faded dots are your other glazes, so the map doubles as a family portrait. (Regions shift with cone — the real chart adjusts to ∆6.)</div>
</div>
</section>
<!-- ============ NEW GLAZE ============ -->
<section class="panel" id="newglaze">
<div class="card">
<h4>New glaze</h4>
<div class="meta" style="margin-bottom:16px">Only the name is required — everything else can arrive later.</div>
<div class="field"><label>Name</label><input type="text" placeholder="e.g. Moonrise Blue"></div>
<div class="grid g2">
<div class="field"><label>Cone</label>
<div class="choices"><span class="choice">∆04</span><span class="choice on">∆6</span><span class="choice">∆10</span><span class="choice">other</span></div>
</div>
<div class="field"><label>Atmosphere</label>
<div class="choices"><span class="choice on">oxidation</span><span class="choice">reduction</span></div>
</div>
<div class="field"><label>Surface</label>
<div class="choices"><span class="choice">glossy</span><span class="choice on">satin</span><span class="choice">matte</span></div>
</div>
<div class="field"><label>Where's it from?</label>
<div class="choices"><span class="choice on">mine</span><span class="choice">Glazy</span><span class="choice">book / friend</span></div>
</div>
</div>
<div class="field"><label>Recipe</label>
<table>
<tr><td>Nepheline Syenite</td><td>45</td></tr>
<tr><td>Silica</td><td>25</td></tr>
<tr><td><span class="note-inline"> add material (autocompletes from your pantry)</span></td><td></td></tr>
</table>
</div>
<div class="field"><label>First note</label><textarea rows="2" placeholder="Why this glaze? What are you hoping for?"></textarea></div>
<button class="btn primary">Save glaze</button>
<button class="btn soft">Save & mix a test batch</button>
<div class="sparkle">💡 Later (Phase 4): paste a Glazy link and the recipe fills itself in.</div>
</div>
</section>
<!-- ============ START A FIRING ============ -->
<section class="panel" id="firing">
<div class="card">
<div class="steps">
<span class="step">1 · plan</span><span class="step">2 · load</span><span class="step dim">3 · fire</span><span class="step dim">4 · unload</span>
<span class="note-inline">← a trail, not a form. Skip everything.</span>
</div>
<h4>Firing #5</h4>
<div class="result" style="margin-top:10px">🔥 <b>Fill-the-kiln-and-go mode:</b> tap <i>start firing</i> with nothing filled in — just "glaze ∆6, go." Tell it what went in later, even on unload day. The load list below is for when you feel like it, not homework.</div>
<div class="grid g2 mt">
<div class="field"><label>Type</label>
<div class="choices"><span class="choice">bisque ∆04</span><span class="choice on">glaze ∆6</span></div>
</div>
<div class="field"><label>Schedule</label>
<div class="choices"><span class="choice on">slow ramp + 15 min hold</span><span class="choice">standard</span><span class="choice">custom…</span></div>
</div>
</div>
<h3>What's going in <span class="note-inline">· piece + clay + glaze — this is the magic combo</span></h3>
<div class="loadrow"><span>🏺 6 carved mugs</span><span class="meta">B-Mix · Sea-glass Celadon</span></div>
<div class="loadrow"><span>🥣 2 serving bowls</span><span class="meta">Speckled Brownstone · Toasted Amber</span></div>
<div class="loadrow"><span>🧪 Test tiles ×4</span><span class="meta">Porcelain · Sea-glass Celadon <span class="pill butter">first porcelain test!</span></span></div>
<div class="mt"><span class="pill ghost"> add from "in the studio" list</span></div>
<div class="mt">
<h3>Kiln photo before closing</h3>
<div class="photos"><div class="ph">📷</div><div class="ph add"></div></div>
<div class="note-inline mt">Future-you, on unload day, will be very glad this exists.</div>
</div>
<div class="mt">
<button class="btn primary">Kiln is loaded — start firing 🔥</button>
</div>
<div class="sparkle">🔌 Phase 5: this button actually talks to Tony's kiln controller — ramp status and cone-offs show up right here.</div>
</div>
</section>
<!-- ============ UNLOAD DAY ============ -->
<section class="panel" id="results">
<div class="card">
<div class="steps">
<span class="step dim">1 · plan</span><span class="step dim">2 · load</span><span class="step dim">3 · fire</span><span class="step">4 · unload ✨</span>
</div>
<h4>Firing #5 — unload day!</h4>
<div class="meta" style="margin-bottom:14px">Rate each thing as it comes out. One tap each — notes only where you want them.</div>
<div class="loadrow">
<span>🏺 Carved mugs <span class="meta">B-Mix · Celadon</span></span>
<span class="verdict"><span class="on">😍</span><span>🙂</span><span>😕</span><span>💔</span></span>
</div>
<div class="loadrow">
<span>🥣 Bowls <span class="meta">Brownstone · Amber</span></span>
<span class="verdict"><span>😍</span><span class="on">🙂</span><span>😕</span><span>💔</span></span>
</div>
<div class="loadrow">
<span>🧪 Tiles <span class="meta">Porcelain · Celadon</span></span>
<span class="verdict"><span>😍</span><span>🙂</span><span class="on">😕</span><span>💔</span></span>
</div>
<div class="field mt"><label>Notes on the 😕</label><textarea rows="2" placeholder="Celadon on porcelain: washed out even at 3 dips. Maybe 4? Or S.G. up to 1.5.">Celadon on porcelain: washed out even at 3 dips. Maybe 4 dips? Or S.G. up to 1.5.</textarea></div>
<div class="mt">
<h3>Photos</h3>
<div class="photos"><div class="ph">📷</div><div class="ph">📷</div><div class="ph">📷</div><div class="ph add"></div></div>
</div>
<div class="mt"><button class="btn primary">Save unload ✨</button></div>
<div class="sparkle">🌱 Saving this updates everything automatically: the mugs move to "ready to sell," Celadon's page gains a porcelain row ("😕 washed out — 1 firing"), and Firing #5 closes with its story told. One screen, three systems fed.</div>
</div>
</section>
<div class="footnote">🌙 round 3 · the deep screens · what would make unload day feel like opening presents?</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,251 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Studio Notebook — Phase 1 sketch</title>
<style>
:root {
--bg: #fdf7f2; --card: #ffffff; --ink: #4a3b47;
--plum: #8d6b94; --plum-deep: #6e4f78; --blush: #f3dbe4; --blush-deep: #c98da8;
--sage: #8fa68a; --sage-soft: #e4ecdf; --butter: #f7ecd8; --line: #eddfe3;
--moon: #f0e6f4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
.wrap { max-width: 900px; margin: 0 auto; padding: 0 20px 60px; }
header { padding: 30px 0 0; }
.banner { background: var(--butter); border: 1.5px dashed var(--blush-deep); border-radius: 14px; padding: 10px 16px; font-size: 13px; color: #8d6a52; margin-bottom: 22px; }
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 30px; font-weight: 600; letter-spacing: 0.2px; color: var(--plum-deep); }
h1 .moon { font-style: normal; }
.sub { color: #9c8698; font-size: 15px; margin-top: 3px; font-style: italic; font-family: Georgia, serif; }
/* CSS-only tabs — radio inputs, no scripts needed */
.tab-radio { position: absolute; opacity: 0; pointer-events: none; }
nav { display: flex; gap: 8px; margin: 24px 0 26px; flex-wrap: wrap; }
nav label { border: 1.5px solid var(--line); background: var(--card); color: #a08fa0; padding: 9px 20px; border-radius: 999px; font-size: 14.5px; cursor: pointer; font-weight: 600; transition: all .15s; }
nav label:hover { border-color: var(--blush-deep); color: var(--plum-deep); }
.panel { display: none; }
#t-today:checked ~ .wrap nav label[for="t-today"],
#t-glazes:checked ~ .wrap nav label[for="t-glazes"],
#t-firings:checked ~ .wrap nav label[for="t-firings"],
#t-notes:checked ~ .wrap nav label[for="t-notes"] { background: var(--plum); border-color: var(--plum); color: #fff; }
#t-today:checked ~ .wrap #today, #t-glazes:checked ~ .wrap #glazes,
#t-firings:checked ~ .wrap #firings, #t-notes:checked ~ .wrap #notes { display: block; }
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; } .g3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 640px){ .g2, .g3 { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 18px 20px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.card h3 { font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--blush-deep); font-weight: 700; margin-bottom: 12px; }
.big-capture { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 14px 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.big-capture .row1 { display: flex; gap: 10px; }
.big-capture input[type=text] { flex: 1; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px 15px; font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
.big-capture .cam, .big-capture .go { border: none; border-radius: 12px; padding: 0 18px; cursor: pointer; font-family: inherit; }
.big-capture .cam { background: var(--blush); font-size: 18px; }
.big-capture .go { background: var(--plum); color: #fff; font-size: 14.5px; font-weight: 700; }
.chips { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.chips .hint { font-size: 12.5px; color: #b1a0ae; margin-right: 2px; }
.chip { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); border: 1px solid transparent; cursor: pointer; }
.chip:hover { border-color: var(--plum); }
.todo { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; }
.todo:last-child { border: none; }
.todo input { width: 17px; height: 17px; accent-color: var(--sage); }
.todo .done { text-decoration: line-through; color: #b1a0ae; }
.pill { display: inline-block; font-size: 12px; font-weight: 650; padding: 3px 11px; border-radius: 999px; background: var(--blush); color: var(--plum-deep); }
.pill.sage { background: var(--sage-soft); color: #5c7157; }
.pill.butter { background: var(--butter); color: #8d6a52; }
.swatch { height: 74px; border-radius: 12px; margin-bottom: 12px; border: 1px solid rgba(0,0,0,0.05); }
.glaze h4, .fire h4, .note-card h4 { font-family: Georgia, serif; font-size: 16.5px; font-weight: 600; color: var(--plum-deep); }
.meta { color: #9c8698; font-size: 13.5px; margin: 2px 0 8px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 8px; }
td { padding: 6px 4px; border-bottom: 1px solid var(--line); }
td:last-child { text-align: right; color: #9c8698; font-variant-numeric: tabular-nums; }
tr:last-child td { border: none; }
.fire { margin-bottom: 14px; }
.fire .load { margin-top: 10px; font-size: 14px; }
.fire .load li { margin-left: 18px; padding: 2px 0; }
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; margin-top: 12px; font-size: 14px; }
.photos { display: flex; gap: 8px; margin-top: 12px; }
.ph { width: 64px; height: 64px; border-radius: 10px; background: linear-gradient(135deg, var(--blush), var(--moon)); display: flex; align-items: center; justify-content: center; font-size: 22px; }
.note-card p { font-size: 14.5px; margin-top: 6px; }
.note-card .tags { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.mt { margin-top: 14px; }
.kiln { display: flex; align-items: center; gap: 12px; }
.kiln .dot { width: 10px; height: 10px; border-radius: 50%; background: #cabfd0; }
.footnote { color: #b1a0ae; font-size: 13px; margin-top: 28px; text-align: center; font-style: italic; font-family: Georgia, serif; }
</style>
</head>
<body>
<input class="tab-radio" type="radio" name="tabs" id="t-today" checked>
<input class="tab-radio" type="radio" name="tabs" id="t-glazes">
<input class="tab-radio" type="radio" name="tabs" id="t-firings">
<input class="tab-radio" type="radio" name="tabs" id="t-notes">
<div class="wrap">
<header>
<div class="banner">✏️ Still a <strong>sketch, not the app</strong> — pretend data, made to be reacted to. Tabs work now!</div>
<h1><span class="moon">🌙</span> Studio Notebook</h1>
<div class="sub">Good morning, Bonna — day 3 in the new studio</div>
</header>
<nav>
<label for="t-today">Today</label>
<label for="t-glazes">Glazes</label>
<label for="t-firings">Firing Log</label>
<label for="t-notes">Notes</label>
</nav>
<!-- TODAY -->
<section class="panel" id="today">
<div class="big-capture">
<div class="row1">
<input type="text" placeholder="Jot anything… a thought, a glaze idea, what's drying">
<button class="cam" title="Snap a photo">📷</button>
<button class="go">Save</button>
</div>
<div class="chips">
<span class="hint">file it as…</span>
<span class="chip">✨ just a note</span><span class="chip">🧪 glaze</span><span class="chip">🔥 firing</span><span class="chip">✓ to-do</span><span class="chip">🏺 piece</span>
</div>
</div>
<div class="grid g2">
<div class="card">
<h3>To-do</h3>
<div class="todo"><input type="checkbox"><span>Wedge clay for mug run (B-Mix)</span></div>
<div class="todo"><input type="checkbox"><span>Mix test batch — Celadon variation #2</span></div>
<div class="todo"><input type="checkbox" checked><span class="done">Unload bisque firing</span></div>
<div class="todo"><input type="checkbox"><span>Order silica — running low</span></div>
</div>
<div class="card">
<h3>In the studio</h3>
<div class="todo"><span>🏺 6 mugs — <b>leather hard</b>, trim today</span></div>
<div class="todo"><span>🥣 2 serving bowls — drying, 2 more days</span></div>
<div class="todo"><span>🥄 Walnut spoon blanks — roughed out</span></div>
<div class="mt"><span class="pill butter">next firing: bisque, ~Sat</span></div>
</div>
<div class="card">
<h3>Kiln</h3>
<div class="kiln"><div class="dot"></div><span>Idle — last fired 3 days ago (bisque, ∆04)</span></div>
<div class="mt"><span class="pill sage">Tony's controller plugs in here, Phase 5</span></div>
</div>
<div class="card">
<h3>Recent notes</h3>
<div class="todo"><span>“Celadon breaks beautifully over carved texture…”</span></div>
<div class="todo"><span>“New wheel position feels right by the window”</span></div>
</div>
<div class="card">
<h3>Shopping list 🧺</h3>
<div class="todo"><input type="checkbox"><span>Silica — 10 lb <span class="pill butter">pantry says low</span></span></div>
<div class="todo"><input type="checkbox"><span>Business cards — reorder</span></div>
<div class="todo"><input type="checkbox"><span>Tissue paper + moon stickers</span></div>
<div class="todo"><input type="checkbox"><span>Trimming tool (the sharp little one)</span></div>
<div class="mt"><span class="pill sage">one list for everything — materials, tools, packaging, paper goods</span></div>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h3>How things land here</h3>
<div class="todo"><span>✍️ Jot “order more wax resist” anywhere → chip it as <b>to-do</b> or say “shopping” → it files itself onto this list.</span></div>
</div>
</div>
</section>
<!-- GLAZES -->
<section class="panel" id="glazes">
<div class="grid g3">
<div class="card glaze">
<div class="swatch" style="background:linear-gradient(150deg,#b8cec4,#8fb0a3)"></div>
<h4>Sea-glass Celadon</h4>
<div class="meta">∆6 ox · glossy · translucent</div>
<span class="pill sage">tested · loves B-Mix</span>
</div>
<div class="card glaze">
<div class="swatch" style="background:linear-gradient(150deg,#c98d5f,#8a5637)"></div>
<h4>Toasted Amber</h4>
<div class="meta">∆6 ox · satin · breaks rust</div>
<span class="pill">2 tests · crawls on porcelain</span>
</div>
<div class="card glaze">
<div class="swatch" style="background:linear-gradient(150deg,#ece7de,#cfc8ba)"></div>
<h4>Oat Matte</h4>
<div class="meta">∆6 ox · matte · opaque</div>
<span class="pill butter">untested — from Glazy</span>
</div>
</div>
<div class="card mt">
<h3>Recipe — Sea-glass Celadon</h3>
<table>
<tr><td>Custer Feldspar</td><td>38</td></tr>
<tr><td>Silica</td><td>28</td></tr>
<tr><td>Whiting</td><td>18</td></tr>
<tr><td>EPK Kaolin</td><td>16</td></tr>
<tr><td>+ Copper Carbonate</td><td>0.5</td></tr>
<tr><td>+ Bentonite</td><td>2</td></tr>
</table>
<div class="result">📒 <b>Notebook:</b> thin = washes out; 3 dips or S.G. 1.45. Gorgeous pooling in carved lines. <b>Works with:</b> B-Mix ✓, Speckled Brownstone ✓, Porcelain — untested.</div>
<div class="photos"><div class="ph">🏺</div><div class="ph">🫖</div><div class="ph">🧪</div></div>
</div>
</section>
<!-- FIRING LOG -->
<section class="panel" id="firings">
<div class="card fire">
<h4>Firing #4 — Glaze, ∆6 oxidation</h4>
<div class="meta">Aug 2 · slow ramp, 15 min hold · 11 hrs</div>
<ul class="load">
<li>6 mugs — B-Mix — Sea-glass Celadon</li>
<li>2 bowls — Speckled Brownstone — Toasted Amber</li>
<li>4 test tiles — Oat Matte variations</li>
</ul>
<div class="result"><b>Result:</b> Celadon perfect on carved mugs. Amber ran on bowl #2 — thinner next time or catch plates. Oat tiles: #3 is the keeper.</div>
<div class="photos"><div class="ph">📷</div><div class="ph">📷</div></div>
</div>
<div class="card fire">
<h4>Firing #3 — Bisque, ∆04</h4>
<div class="meta">Jul 29 · standard bisque · 9 hrs</div>
<ul class="load"><li>Full load — mugs, bowls, tiles</li></ul>
<div class="result">All clean, no cracks. New kiln shelves level. 🎉</div>
</div>
</section>
<!-- NOTES -->
<section class="panel" id="notes">
<div class="big-capture">
<div class="row1">
<input type="text" placeholder="New note…">
<button class="cam" title="Snap a photo">📷</button>
<button class="go">Save</button>
</div>
<div class="chips">
<span class="hint">tag it…</span>
<span class="chip">glaze</span><span class="chip">wood</span><span class="chip">new studio</span><span class="chip">collection ideas</span><span class="chip">shows</span><span class="chip">+ new tag</span>
</div>
</div>
<div class="grid g2">
<div class="card note-card">
<h4>Carving + celadon = the thing</h4>
<p>The break over carved texture is exactly the look. Lean the whole first collection into carved surfaces. Chattering tool next?</p>
<div class="photos"><div class="ph">🏺</div></div>
<div class="tags"><span class="pill">glaze</span><span class="pill">collection ideas</span></div>
</div>
<div class="card note-card">
<h4>Studio layout</h4>
<p>Wheel by the window is right. Wedging table needs to move closer to clay storage — too many trips.</p>
<div class="tags"><span class="pill">new studio</span></div>
</div>
<div class="card note-card">
<h4>Spoon shapes</h4>
<p>Walnut roughing went fast. Try a shorter, deeper bowl on the next blanks — more of a tasting spoon.</p>
<div class="photos"><div class="ph">🥄</div><div class="ph">🪵</div></div>
<div class="tags"><span class="pill">wood</span></div>
</div>
<div class="card note-card">
<h4>Market idea — Fall Makers Fair</h4>
<p>Deadline usually mid-September? Check fee. This becomes a real tracked thing in Phase 3.</p>
<div class="tags"><span class="pill">shows</span></div>
</div>
</div>
</section>
<div class="footnote">🌙 Phase 1 sketch · tell me what feels right and what doesn't</div>
</div>
</body>
</html>

73
prototype/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Studio Notebook — Phase 1 prototype
A zero-dependency local prototype of the studio backbone's Phase 1 (see
[../VISION.md](../VISION.md)). Node's built-in `http` + `node:sqlite` — no npm
install, no node_modules, total footprint a few hundred KB plus photos.
**Run it:** double-click `Start Studio Notebook.command` in the repo root, or:
```sh
node prototype/server.mjs
```
Then open http://localhost:6161. Data lives in `prototype/data/` (gitignored):
`studio.db` (SQLite) and `photos/`. Back that folder up = back up the notebook.
## What works (v0.1)
- **Today** — jot box with "file it as…" chips (note / to-do / shopping / glaze
idea), to-dos **with subtasks**, the one shopping list, kiln status, recent notes
- **Keyboard-first capture** — Enter saves and stays in the box; Shift+Enter
adds subtask lines (first line = task, rest = its subtasks); multiline
shopping jots become one item per line; Tab cycles the file-as chips;
`/` focuses the jot box from anywhere; per-todo opens an inline subtask
input (Enter saves, Esc closes)
- **Clay & Glazes** — one library, three kinds: glazes, casting slips, and clay
bodies, each grouped under its own heading; clay bodies feed an autocomplete
in the firing form so compatibility evidence never splits over spelling;
cards with swatches; new glaze/slip form (name-only minimum,
chips for cone/atmosphere/surface/source); detail page with recipe,
batch scaling (500 g5 kg), journal, photo uploads, and the **"plays well
with" table built exclusively from unload-day ratings** (never hand-edited)
- **Firing Log** — fill-the-kiln-and-go start (type + cone, everything else
optional); load items (piece + clay + glaze) addable any time incl. unload;
**unload day** with 😍🙂😕💔 one-tap ratings that feed the glaze evidence
- **Notes** — the jot stream with tags
- **Pieces** — Bonna's "together but separate" product tracking: **Core line**
(repeatable products with on-hand counts, / / "sold one") vs **Small
batch & one of a kind** (individual pieces with in progress → for sale → sold
status). Each piece carries price + rough cost estimate.
- **Money** — sales recorded on one tap; the money card shows this-month
revenue, all-time revenue, and estimated profit (sales cost estimates).
Real materials-based costing is a later phase.
- **Sidebar navigation** (FMB-style) on desktop; collapses to top chips on
narrow screens.
- **Piece pages** (FMB recipe/listing-style) — click any piece name for its
page: price/cost/stock or status controls, sales history, notes, photos, and
attached files.
- **Files** — upload anything (STL master molds, foam form templates, cut
files); tag as master molds / foam forms / prototypes / other; attach to a
specific piece from its page, or keep in the general studio files area.
Downloads preserve original filenames. Stored in `prototype/data/files/`.
- **Sourcing** — suppliers (online vs 🚗 drivable-with-distance, website,
shipping notes) + a price ledger: log every buy or price-check (item,
supplier, price, shipping). Entries group by item; each shows ▲/▼ trend vs
that supplier's previous price, shipping-inclusive totals, and a "best 🏆"
pill on the cheapest current source. Suppliers may carry a search-URL
template (`…?q={q}`); the price-check box then opens any item's live search
on every such supplier in one click. Fully-automated scraping is deliberately
out (fragile, silently stale) — live-open links + the ledger habit instead;
Claude can also be asked to browse suppliers and log price checks.
## Deliberately not here yet
Stull chart (needs UMF chemistry data — Phase 1.5), molds/slip-casting screens,
inventory, costs, calendar, market scanner, Glazy import, kiln control. See
VISION.md phasing.
## Status
Prototype for design validation + real daily use while the platform question
(where the production code lives — Tony's call, see VISION.md "Open architecture
questions") is pending. The schema here (typed recipes, evidence-only
compatibility, firing lifecycle) is the reference for the production build.

612
prototype/public/app.js vendored Normal file
View File

@@ -0,0 +1,612 @@
// Studio Notebook prototype — vanilla JS, no build step
const $ = (s, el = document) => el.querySelector(s);
const view = $("#view");
const api = async (path, body) => {
const r = await fetch(path, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined);
return r.json();
};
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const hour = new Date().getHours();
$("#greeting").textContent = (hour < 12 ? "good morning" : hour < 17 ? "good afternoon" : "good evening") + ", Bonna";
let current = "today";
let subState = null; // inline to-do entry: {anchor: parent todo id, last: last-added id}
document.querySelectorAll("#nav button").forEach((b) =>
b.addEventListener("click", () => {
document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("on", x === b));
current = b.dataset.v;
render();
}));
function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); }
// ── jot box (shared) ─────────────────────────────────────────────
function captureHTML(kinds) {
const chips = kinds.map(([k, label], i) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
return `<div class="capture"><div class="row1">
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea>
<button class="go" id="jot-save">Save</button></div>
<div class="chips"><span class="hint">file it as…</span>${chips}</div>
<div class="chips"><span class="hint">⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot</span></div></div>`;
}
function wireCapture(after) {
const jot = $("#jot");
const chips = [...document.querySelectorAll(".capture .chip")];
const select = (c) => chips.forEach((x) => x.classList.toggle("on", x === c));
chips.forEach((c) => c.addEventListener("click", () => select(c)));
const save = async () => {
const text = jot.value.trim();
if (!text) return;
const kind = $(".capture .chip.on")?.dataset.kind || "note";
await api("/api/jot", { text, kind });
await after();
const j = $("#jot");
if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting
};
$("#jot-save").addEventListener("click", save);
jot.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); }
else if (e.key === "Enter" && e.shiftKey) { autogrow(); }
else if (e.key === "Tab") {
e.preventDefault();
const i = chips.findIndex((c) => c.classList.contains("on"));
select(chips[(i + (e.shiftKey ? chips.length - 1 : 1)) % chips.length]);
}
});
const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; });
jot.addEventListener("input", autogrow);
}
// "/" from anywhere jumps to the jot box
document.addEventListener("keydown", (e) => {
if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) {
const j = $("#jot");
if (j) { e.preventDefault(); j.focus(); }
}
});
// ── Today ────────────────────────────────────────────────────────
function todoRow(t, sub) {
return `<div class="todo" ${sub ? 'style="margin-left:26px"' : ""}>
<input type="checkbox" data-t="${t.id}" ${t.done ? "checked" : ""}>
<span class="${t.done ? "done" : ""}" style="flex:1">${sub ? "↳ " : ""}${esc(t.text)}</span>
${sub ? "" : `<button class="chip" data-sub="${t.id}" title="add subtask"></button>`}</div>
${sub ? "" : `<div class="subform" data-subform="${t.id}" hidden style="margin:2px 0 6px 26px">
<input type="text" placeholder="subtask… ⇧Enter = another · Enter = new task · ⌫ empty = undo · Esc" style="width:88%;padding:7px 12px;font-size:13.5px"></div>`}`;
}
async function today() {
const s = await api("/api/summary");
const parents = s.todos.filter((t) => !t.parent_id);
const kids = (id) => s.todos.filter((t) => t.parent_id === id).sort((a, b) => a.id - b.id);
const open = parents.filter((t) => !t.done || kids(t.id).some((k) => !k.done));
const rows = (list) => list.map((t) => todoRow(t) + kids(t.id).map((k) => todoRow(k, true)).join("")).join("");
const todoRows = rows(open.slice(0, 14)) || `<div class="empty">nothing yet — jot one above ✨</div>`;
const shopRows = s.shopping.map((t) =>
`<div class="todo"><input type="checkbox" data-s="${t.id}" ${t.done ? "checked" : ""}><span class="${t.done ? "done" : ""}">${esc(t.text)}</span></div>`).join("") || `<div class="empty">list is empty — lucky you 🧺</div>`;
const noteRows = s.notes.map((n) => `<div class="todo"><span>“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”</span></div>`).join("") || `<div class="empty">no notes yet</div>`;
const kiln = s.activeFiring
? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div>
<div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>`
: `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`;
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"]]) +
`<div class="grid g2">
<div class="card"><h3>To-do</h3>${todoRows}</div>
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
<div class="card"><h3>Kiln</h3>${kiln}</div>
<div class="card"><h3>Recent notes</h3>${noteRows}</div>
</div>`;
wireCapture(today);
// outliner-style inline entry: ⇧Enter = another subtask, Enter = new top-level
// task (input follows it), ⌫ on empty = undo last add, Esc closes
const openForm = () => {
if (!subState) return;
const form = view.querySelector(`[data-subform="${subState.anchor}"]`);
if (!form) { subState = null; return; }
form.hidden = false;
form.querySelector("input").focus();
};
openForm();
view.querySelectorAll("[data-sub]").forEach((b) =>
b.addEventListener("click", () => {
const id = Number(b.dataset.sub);
subState = subState?.anchor === id ? null : { anchor: id, last: null };
view.querySelectorAll(".subform").forEach((f) => (f.hidden = true));
openForm();
}));
view.querySelectorAll("[data-subform] input").forEach((inp) =>
inp.addEventListener("keydown", async (e) => {
const text = inp.value.trim();
if (e.key === "Enter" && e.shiftKey && text) {
e.preventDefault();
const r = await api("/api/todos/add", { text, parent_id: subState.anchor });
subState.last = r.id;
today();
} else if (e.key === "Enter" && !e.shiftKey && text) {
e.preventDefault();
const r = await api("/api/todos/add", { text });
subState = { anchor: r.id, last: r.id };
today();
} else if (e.key === "Backspace" && !inp.value && subState?.last) {
e.preventDefault();
const undoing = subState.last;
await api("/api/todos/delete", { id: undoing });
subState = undoing === subState.anchor ? null : { ...subState, last: null };
today();
} else if (e.key === "Escape" || (e.key === "Enter" && !text)) {
subState = null;
today();
}
}));
view.querySelectorAll("[data-t]").forEach((c) => c.addEventListener("change", async () => { await api("/api/todos/toggle", { id: Number(c.dataset.t) }); today(); }));
view.querySelectorAll("[data-s]").forEach((c) => c.addEventListener("change", async () => { await api("/api/shopping/toggle", { id: Number(c.dataset.s) }); today(); }));
$("#goto-firing")?.addEventListener("click", () => $('#nav button[data-v="firings"]').click());
}
// ── Glazes ───────────────────────────────────────────────────────
const SWATCHES = ["#8fb0a3", "#c98d5f", "#cfc8ba", "#7f9bb3", "#b3849a", "#8a8f6a", "#6e4f78", "#c9b8a4"];
async function glazes() {
const list = await api("/api/glazes");
const card = (g) => `
<div class="card clickable" data-g="${g.id}">
<div class="swatch" style="background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
<h4>${esc(g.name)}</h4>
<div class="meta">${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}</div>
</div>`;
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
const glazeCards = list.filter((g) => !g.kind || g.kind === "glaze").map(card).join("");
const slipCards = list.filter((g) => g.kind === "casting slip").map(card).join("");
const clayCards = list.filter((g) => g.kind === "clay body").map(card).join("");
view.innerHTML = `
<div class="mt" style="margin-bottom:14px"><button class="btn" id="new-glaze"> New glaze, slip, or clay body</button></div>
<div class="grid g3">${glazeCards || '<div class="empty">no glazes yet — add your first 🧪</div>'}</div>
${slipCards ? section("Casting slips 🏺") + `<div class="grid g3">${slipCards}</div>` : ""}
${clayCards ? section("Clay bodies 🧱") + `<div class="grid g3">${clayCards}</div>` : ""}`;
$("#new-glaze").addEventListener("click", newGlazeForm);
view.querySelectorAll("[data-g]").forEach((c) => c.addEventListener("click", () => glazeDetail(Number(c.dataset.g))));
}
function newGlazeForm() {
const chip = (group, vals, on) => vals.map((v) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
<div class="card">
<h4>New glaze</h4>
<div class="meta" style="margin-bottom:14px">Only the name is required — everything else can arrive later.</div>
<div class="field"><label>Name</label><input type="text" id="g-name" placeholder="e.g. Moonrise Blue" style="width:100%"></div>
<div class="grid g2">
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}</div></div>
<div class="field"><label>Cone</label><div class="chips">${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}</div></div>
<div class="field"><label>Atmosphere</label><div class="chips">${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}</div></div>
<div class="field"><label>Surface</label><div class="chips">${chip("surface", ["glossy", "satin", "matte"], "")}</div></div>
<div class="field"><label>Where's it from?</label><div class="chips">${chip("source", ["mine", "Glazy", "book / friend"], "mine")}</div></div>
<div class="field"><label>Swatch color</label><div class="chips">${SWATCHES.map((s, i) => `<span class="chip${i === 0 ? " on" : ""}" data-group="swatch" data-val="${s}" style="background:${s};color:#fff">●</span>`).join("")}</div></div>
</div>
<div class="field"><label>Recipe (material + %)</label><div id="mats"></div>
<button class="btn soft mini" id="add-mat"> material</button>
<button class="btn soft mini" id="add-addition"> addition (colorant etc.)</button></div>
<button class="btn" id="save-glaze">Save glaze</button>
</div>`;
const mats = $("#mats");
const addRow = (addition) => {
const row = document.createElement("div");
row.className = "matrow";
row.innerHTML = `<input type="text" placeholder="${addition ? "+ e.g. Copper Carbonate" : "e.g. Custer Feldspar"}" data-add="${addition ? 1 : 0}"><input type="text" placeholder="%" inputmode="decimal">`;
mats.appendChild(row);
};
addRow(false); addRow(false); addRow(false);
$("#add-mat").addEventListener("click", () => addRow(false));
$("#add-addition").addEventListener("click", () => addRow(true));
view.querySelectorAll(".chip[data-group]").forEach((c) =>
c.addEventListener("click", () => {
view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c));
}));
$("#back").addEventListener("click", glazes);
$("#save-glaze").addEventListener("click", async () => {
const name = $("#g-name").value.trim();
if (!name) return alert("A name is all it needs 🙂");
const pick = (g) => view.querySelector(`.chip[data-group="${g}"].on`)?.dataset.val || "";
const materials = [...mats.querySelectorAll(".matrow")].map((r) => {
const [n, p] = r.querySelectorAll("input");
return n.value.trim() && p.value.trim() ? { name: n.value.trim(), pct: parseFloat(p.value), addition: n.dataset.add === "1" } : null;
}).filter(Boolean);
await api("/api/glazes", { name, kind: pick("kind"), cone: pick("cone"), atmosphere: pick("atmosphere"), surface: pick("surface"), source: pick("source"), swatch: pick("swatch"), materials });
glazes();
});
}
async function glazeDetail(id) {
const g = await api(`/api/glaze?id=${id}`);
const batchSizes = [500, 1000, 2000, 5000];
const batch = (pct, size) => Math.round(pct * size) / 100;
let size = 2000;
const recipeTable = () => `
<table><tr><th>Material</th><th>%</th><th>${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch</th></tr>
${g.materials.map((m) => `<tr><td>${m.addition ? " " : ""}${esc(m.name)}</td><td>${m.pct}</td><td>${batch(m.pct, size)} g</td></tr>`).join("") || '<tr><td colspan="3" class="empty">no recipe yet</td></tr>'}</table>`;
const compat = g.compatibility.length
? `<table><tr><th>Clay body</th><th>Result</th><th>Evidence</th></tr>
${g.compatibility.map((c) => `<tr><td>${esc(c.clay)}</td><td>${esc(c.rating)}</td><td>${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""}</td></tr>`).join("")}</table>`
: `<div class="empty">no evidence yet — it builds itself from unload days 🌱</div>`;
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
<div class="card">
<div class="swatch" style="max-width:140px;background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
<h4>${esc(g.name)}</h4>
<div class="meta">${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}</div>
<div class="mt"><h3>Recipe</h3><div id="recipe">${recipeTable()}</div>
<div class="chips">${batchSizes.map((b) => `<span class="chip${b === size ? " on" : ""}" data-b="${b}">${b >= 1000 ? b / 1000 + " kg" : b + " g"}</span>`).join("")}</div></div>
<div class="mt"><h3>Plays well with…</h3>${compat}</div>
<div class="mt"><h3>Journal</h3>
${g.journal.map((j) => `<div class="result">📒 <b>${esc(j.created.slice(0, 10))}</b> — ${esc(j.text)}</div>`).join("") || '<div class="empty">no entries yet</div>'}
<div class="capture mt"><div class="row1"><input type="text" id="j-text" placeholder="New journal entry…"><button class="go" id="j-save">Add</button></div></div></div>
<div class="mt"><h3>Photos</h3><div class="photos" id="photos">
${g.photos.map((p) => `<img src="/photos/${p.filename}" alt="">`).join("")}
<button class="ph-add" id="ph-add"></button>
<input type="file" id="ph-file" accept="image/*" hidden></div></div>
</div>`;
$("#back").addEventListener("click", glazes);
view.querySelectorAll("[data-b]").forEach((c) =>
c.addEventListener("click", () => { size = Number(c.dataset.b); $("#recipe").innerHTML = recipeTable();
view.querySelectorAll("[data-b]").forEach((x) => x.classList.toggle("on", x === c)); }));
$("#j-save").addEventListener("click", async () => {
const text = $("#j-text").value.trim();
if (text) { await api("/api/glaze/journal", { glaze_id: id, text }); glazeDetail(id); }
});
$("#ph-add").addEventListener("click", () => $("#ph-file").click());
$("#ph-file").addEventListener("change", async (e) => {
const f = e.target.files[0];
if (!f) return;
await fetch(`/api/photos?entity=glaze&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f });
glazeDetail(id);
});
}
// ── Firing Log ───────────────────────────────────────────────────
async function firings() {
const list = await api("/api/firings");
const library = await api("/api/glazes");
const glazeNames = library.filter((g) => g.kind !== "clay body").map((g) => g.name);
const clayNames = library.filter((g) => g.kind === "clay body").map((g) => g.name);
const active = list.find((f) => f.status === "firing");
let html = "";
if (!active) {
html += `<div class="card" style="margin-bottom:14px"><h3>Start a firing 🔥</h3>
<div class="meta" style="margin-bottom:10px">Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.</div>
<div class="chips">${["bisque", "glaze"].map((t, i) => `<span class="chip${i === 1 ? " on" : ""}" data-ft="${t}">${t}</span>`).join("")}
${["∆04", "∆6", "∆10"].map((c, i) => `<span class="chip${i === 1 ? " on" : ""}" data-fc="${c}">${c}</span>`).join("")}</div>
<div class="mt"><button class="btn" id="start-firing">Kiln is loaded — start 🔥</button></div></div>`;
} else {
const items = active.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></div>`).join("");
html += `<div class="card" style="margin-bottom:14px"><h3>Firing #${active.id} — in progress 🔥</h3>
<div class="meta">${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}</div>
<div class="mt">${items || '<div class="empty">no load list — totally fine! add pieces whenever, even at unload</div>'}</div>
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="piece, e.g. 6 carved mugs"></div>
<div class="row1 mt"><input type="text" id="fi-clay" list="clay-list" placeholder="clay (optional)" style="flex:1">
<datalist id="clay-list">${clayNames.map((n) => `<option>${esc(n)}</option>`).join("")}</datalist>
<select id="fi-glaze"><option value="">glaze (optional)</option>${glazeNames.map((n) => `<option>${esc(n)}</option>`).join("")}</select>
<button class="go" id="fi-add">Add</button></div></div>
<div class="mt"><button class="btn" id="unload">Unload day ✨</button></div></div>`;
}
html += list.filter((f) => f.status === "unloaded").map((f) => `
<div class="card" style="margin-bottom:14px"><h4>Firing #${f.id}${esc(f.type)} ${esc(f.cone)}</h4>
<div class="meta">${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}</div>
${f.items.map((it) => `<div class="loadrow"><span>${it.rating || ""} ${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}</span></div>`).join("")}
${f.result_notes ? `<div class="result">✨ ${esc(f.result_notes)}</div>` : ""}</div>`).join("");
view.innerHTML = html || '<div class="empty">no firings yet</div>';
view.querySelectorAll("[data-ft],[data-fc]").forEach((c) =>
c.addEventListener("click", () => {
const sel = c.dataset.ft !== undefined ? "[data-ft]" : "[data-fc]";
view.querySelectorAll(sel).forEach((x) => x.classList.toggle("on", x === c));
}));
$("#start-firing")?.addEventListener("click", async () => {
await api("/api/firings", { type: view.querySelector("[data-ft].on").dataset.ft, cone: view.querySelector("[data-fc].on").dataset.fc });
firings();
});
$("#fi-add")?.addEventListener("click", async () => {
const piece = $("#fi-piece").value.trim();
if (!piece) return;
await api("/api/firing/items", { firing_id: active.id, piece, clay: $("#fi-clay").value.trim(), glaze: $("#fi-glaze").value });
firings();
});
$("#unload")?.addEventListener("click", () => unloadDay(active));
}
async function unloadDay(f) {
const fresh = (await api("/api/firings")).find((x) => x.id === f.id);
const EMOJI = ["😍", "🙂", "😕", "💔"];
view.innerHTML = `<button class="back" id="back">← firing log</button>
<div class="card"><h4>Firing #${fresh.id} — unload day! ✨</h4>
<div class="meta" style="margin-bottom:10px">Rate each thing as it comes out. One tap each.</div>
${fresh.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)} <span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></span>
<span class="verdict" data-item="${it.id}">${EMOJI.map((e) => `<button data-e="${e}" class="${it.rating === e ? "on" : ""}">${e}</button>`).join("")}</span></div>`).join("") ||
'<div class="empty">no items listed — add what came out below, then rate it</div>'}
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="add something that came out…"><button class="go" id="fi-add">Add</button></div></div>
<div class="field mt"><label>Notes on this firing</label><textarea id="f-notes" rows="2" style="width:100%" placeholder="anything future-you should know"></textarea></div>
<button class="btn" id="save-unload">Save unload ✨</button></div>`;
$("#back").addEventListener("click", firings);
view.querySelectorAll(".verdict").forEach((v) =>
v.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", async () => {
await api("/api/firing/rate", { id: Number(v.dataset.item), rating: b.dataset.e });
v.querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b));
})));
$("#fi-add").addEventListener("click", async () => {
const piece = $("#fi-piece").value.trim();
if (piece) { await api("/api/firing/items", { firing_id: fresh.id, piece }); unloadDay(fresh); }
});
$("#save-unload").addEventListener("click", async () => {
await api("/api/firing/unload", { id: fresh.id, result_notes: $("#f-notes").value.trim() });
firings();
});
}
// ── Pieces ───────────────────────────────────────────────────────
const money = (n) => "$" + (Math.round(n * 100) / 100).toLocaleString();
async function pieces() {
const { products, money: m } = await api("/api/products");
const core = products.filter((p) => p.kind === "core line");
const tiles = products.filter((p) => p.kind === "tiles");
const oneoff = products.filter((p) => p.kind !== "core line" && p.kind !== "tiles");
const catPill = (p) => (p.category ? `<span class="pill butter">${esc(p.category)}</span>` : "");
const coreCard = (p) => `
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta"></span></h4>
<div class="mt">${catPill(p)} <span class="pill">${money(p.price)}</span></div>
<div class="loadrow mt"><span>on hand: <b>${p.qty}</b></span>
<span><button class="chip" data-q="${p.id}" data-d="-1"></button>
<button class="chip" data-q="${p.id}" data-d="1"></button>
<button class="btn mini" data-sell="${p.id}" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span></div></div>`;
const STATUSES = ["in progress", "for sale", "sold"];
const oneoffCard = (p) => `
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta"></span></h4>
<div class="mt">${catPill(p)} <span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span></div>
<div class="loadrow mt"><span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${p.id}" data-v="${s}">${s}</span>`).join("")}</span>
${p.status !== "sold" ? `<button class="btn mini" data-sell="${p.id}">sold 💰</button>` : ""}</div></div>`;
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
view.innerHTML = `
<div class="card" style="margin-bottom:14px"><h3>Money 💰</h3>
<div class="grid g3">
<div><div class="meta">this month</div><h4>${money(m.monthRevenue)}</h4></div>
<div><div class="meta">all-time sales (${m.salesCount})</div><h4>${money(m.revenue)}</h4></div>
<div><div class="meta">est. profit</div><h4>${money(m.revenue - m.cogs)}</h4></div>
</div>
<div class="meta mt">profit = sales minus each piece's cost estimate · real materials-based costing comes later</div></div>
<div style="margin-bottom:14px"><button class="btn" id="new-piece"> New piece</button></div>
${section("Core line ✨")}
<div class="grid g2">${core.map(coreCard).join("") || '<div class="empty">the reintroduced line starts here — kitchen tools, serveware…</div>'}</div>
${tiles.length ? section("Tiles 🔷") + `<div class="grid g2">${tiles.map(coreCard).join("")}</div>` : ""}
${section("Small batch & one of a kind 🌙")}
<div class="grid g2">${oneoff.map(oneoffCard).join("") || '<div class="empty">sculptural pieces, home scenting, experiments…</div>'}</div>`;
$("#new-piece").addEventListener("click", newPieceForm);
view.querySelectorAll("[data-open]").forEach((h) =>
h.addEventListener("click", () => pieceDetail(Number(h.dataset.open))));
view.querySelectorAll("[data-q]").forEach((b) =>
b.addEventListener("click", async () => { await api("/api/products/qty", { id: Number(b.dataset.q), delta: Number(b.dataset.d) }); pieces(); }));
view.querySelectorAll("[data-st]").forEach((c) =>
c.addEventListener("click", async () => { await api("/api/products/status", { id: Number(c.dataset.st), status: c.dataset.v }); pieces(); }));
view.querySelectorAll("[data-sell]").forEach((b) =>
b.addEventListener("click", async () => { await api("/api/sales", { product_id: Number(b.dataset.sell) }); pieces(); }));
}
function newPieceForm() {
const chip = (group, vals, on) => vals.map((v) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
view.innerHTML = `<button class="back" id="back">← back to pieces</button>
<div class="card"><h4>New piece</h4>
<div class="meta" style="margin-bottom:14px">Name is enough — price, cost, and count can arrive later.</div>
<div class="field"><label>Name</label><input type="text" id="p-name" placeholder="e.g. Carved mug — celadon" style="width:100%"></div>
<div class="grid g2">
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["core line", "tiles", "small batch", "one of a kind"], "core line")}</div></div>
<div class="field"><label>Category</label><input type="text" id="p-cat" list="cat-list" placeholder="kitchen tools, serveware, sculpture…" style="width:100%">
<datalist id="cat-list"><option>kitchen tools</option><option>serveware</option><option>sculpture</option><option>home scent</option><option>jewelry</option><option>wood</option></datalist></div>
<div class="field"><label>Price $</label><input type="text" id="p-price" inputmode="decimal" placeholder="0" style="width:100%"></div>
<div class="field"><label>Cost estimate $ <span style="text-transform:none;font-weight:400">(materials etc., rough is fine)</span></label><input type="text" id="p-cost" inputmode="decimal" placeholder="0" style="width:100%"></div>
<div class="field"><label>On hand (core line)</label><input type="text" id="p-qty" inputmode="numeric" placeholder="0" style="width:100%"></div>
</div>
<button class="btn" id="save-piece">Save piece</button></div>`;
view.querySelectorAll(".chip[data-group]").forEach((c) =>
c.addEventListener("click", () => view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c))));
$("#back").addEventListener("click", pieces);
$("#save-piece").addEventListener("click", async () => {
const name = $("#p-name").value.trim();
if (!name) return alert("Just a name is fine 🙂");
await api("/api/products", {
name, kind: view.querySelector('.chip[data-group="kind"].on')?.dataset.val,
category: $("#p-cat").value.trim(), price: $("#p-price").value, cost: $("#p-cost").value, qty: $("#p-qty").value });
pieces();
});
}
// ── Files ────────────────────────────────────────────────────────
const FILE_TAGS = ["master molds", "foam forms", "prototypes", "other"];
const fmtSize = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB";
const fileIcon = (name) => /\.(stl|obj|3mf|step|stp)$/i.test(name) ? "🧊" : /\.(svg|dxf|ai|eps)$/i.test(name) ? "✂️" : /\.(png|jpe?g|webp|heic)$/i.test(name) ? "🖼️" : /\.(pdf)$/i.test(name) ? "📄" : "📁";
function fileRow(f, showHome) {
return `<div class="loadrow"><span>${fileIcon(f.name)} <a href="/file/${f.stored}" style="color:var(--plum-deep);font-weight:600">${esc(f.name)}</a>
<span class="meta">${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))}</span></span>
<span>${f.tag ? `<span class="pill butter">${esc(f.tag)}</span>` : ""}
${showHome && f.product_name ? `<span class="pill sage">🏺 ${esc(f.product_name)}</span>` : ""}
<button class="chip" data-fdel="${f.id}" title="delete">🗑</button></span></div>`;
}
function uploadWidget(entity, entityId, after) {
return {
html: `<div class="capture mt"><div class="row1">
<button class="btn soft" id="file-pick">⬆️ Upload file(s)</button>
<input type="file" id="file-input" multiple hidden></div>
<div class="chips"><span class="hint">tag as…</span>${FILE_TAGS.map((t, i) => `<span class="chip${i === 0 ? " on" : ""}" data-ftag="${t}">${t}</span>`).join("")}</div></div>`,
wire() {
view.querySelectorAll("[data-ftag]").forEach((c) =>
c.addEventListener("click", () => view.querySelectorAll("[data-ftag]").forEach((x) => x.classList.toggle("on", x === c))));
$("#file-pick").addEventListener("click", () => $("#file-input").click());
$("#file-input").addEventListener("change", async (e) => {
const tag = view.querySelector("[data-ftag].on")?.dataset.ftag || "";
for (const f of e.target.files) {
const q = new URLSearchParams({ name: f.name, tag, ...(entity ? { entity, id: entityId } : {}) });
await fetch(`/api/files?${q}`, { method: "POST", body: f });
}
after();
});
},
};
}
function wireFileDeletes(after) {
view.querySelectorAll("[data-fdel]").forEach((b) =>
b.addEventListener("click", async () => {
if (confirm("Delete this file?")) { await api("/api/files/delete", { id: Number(b.dataset.fdel) }); after(); }
}));
}
async function files() {
const list = await api("/api/files");
const up = uploadWidget("", 0, files);
const loose = list.filter((f) => !f.entity_id);
const attached = list.filter((f) => f.entity_id);
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
view.innerHTML = `${up.html}
<div class="card mt">${section("Studio files 🗂️")}
${loose.map((f) => fileRow(f, false)).join("") || '<div class="empty">STLs for master molds, foam form templates, cut files… upload anything ⬆️</div>'}</div>
${attached.length ? `<div class="card mt">${section("Attached to pieces 🏺")}${attached.map((f) => fileRow(f, true)).join("")}</div>` : ""}`;
up.wire();
wireFileDeletes(files);
}
// ── Piece detail ─────────────────────────────────────────────────
async function pieceDetail(id) {
const p = await api(`/api/product?id=${id}`);
if (!p) return pieces();
const counted = p.kind === "core line" || p.kind === "tiles";
const STATUSES = ["in progress", "for sale", "sold"];
const up = uploadWidget("product", id, () => pieceDetail(id));
view.innerHTML = `<button class="back" id="back">← back to pieces</button>
<div class="card">
<h4>${esc(p.name)}</h4>
<div class="mt">${p.category ? `<span class="pill butter">${esc(p.category)}</span>` : ""}
<span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span>
${p.cost ? `<span class="pill">cost ~${money(p.cost)}</span>` : ""}</div>
<div class="loadrow mt">${counted
? `<span>on hand: <b>${p.qty}</b></span><span>
<button class="chip" data-q="-1"></button><button class="chip" data-q="1"></button>
<button class="btn mini" id="sell" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span>`
: `<span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${s}">${s}</span>`).join("")}</span>
${p.status !== "sold" ? `<button class="btn mini" id="sell">sold 💰</button>` : ""}`}</div>
${p.sales.length ? `<div class="result mt">💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}</div>` : ""}
<div class="field mt"><label>Notes</label>
<textarea id="p-notes" rows="3" style="width:100%" placeholder="dimensions, glaze combo, what makes it special…">${esc(p.notes || "")}</textarea>
<button class="btn mini soft mt" id="save-notes">save notes</button></div>
<div class="mt"><h3>Photos</h3><div class="photos">
${p.photos.map((ph) => `<img src="/photos/${ph.filename}" alt="">`).join("")}
<button class="ph-add" id="ph-add"></button><input type="file" id="ph-file" accept="image/*" hidden></div></div>
<div class="mt"><h3>Files — molds, templates, cut forms</h3>
${p.files.map((f) => fileRow(f, false)).join("") || '<div class="empty">nothing attached yet</div>'}
${up.html}</div>
</div>`;
$("#back").addEventListener("click", pieces);
view.querySelectorAll("[data-q]").forEach((b) =>
b.addEventListener("click", async () => { await api("/api/products/qty", { id, delta: Number(b.dataset.q) }); pieceDetail(id); }));
view.querySelectorAll("[data-st]").forEach((c) =>
c.addEventListener("click", async () => { await api("/api/products/status", { id, status: c.dataset.st }); pieceDetail(id); }));
$("#sell")?.addEventListener("click", async () => { await api("/api/sales", { product_id: id }); pieceDetail(id); });
$("#save-notes").addEventListener("click", async () => { await api("/api/products/notes", { id, notes: $("#p-notes").value }); });
$("#ph-add").addEventListener("click", () => $("#ph-file").click());
$("#ph-file").addEventListener("change", async (e) => {
const f = e.target.files[0];
if (f) { await fetch(`/api/photos?entity=product&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); pieceDetail(id); }
});
up.wire();
wireFileDeletes(() => pieceDetail(id));
}
// ── Sourcing ─────────────────────────────────────────────────────
async function sourcing() {
const { suppliers, entries } = await api("/api/sourcing");
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
const total = (e) => e.price + e.shipping;
// group price entries by item (case-insensitive)
const items = {};
for (const e of entries) (items[e.item.toLowerCase()] ??= { name: e.item, entries: [] }).entries.push(e);
const itemCard = (it) => {
// newest entry per supplier decides "current" prices; lowest total wins
const latestBySupplier = {};
for (const e of it.entries) latestBySupplier[e.supplier_id] ??= e; // entries are newest-first
const currents = Object.values(latestBySupplier).sort((a, b) => total(a) - total(b));
const bestId = currents[0]?.id;
const rows = it.entries.map((e) => {
// trend vs previous entry from the same supplier
const prev = it.entries.find((x) => x.supplier_id === e.supplier_id && x.id < e.id);
const trend = prev ? (total(e) > total(prev) ? ` <span title="was ${money(total(prev))}" style="color:#b3556b">▲ costs more now</span>`
: total(e) < total(prev) ? ` <span title="was ${money(total(prev))}" style="color:#5c7157">▼ cheaper</span>` : "") : "";
const drivable = e.supplier_kind === "local" ? ` <span class="pill sage">🚗 ${esc(e.distance || "drivable")}</span>` : "";
return `<div class="loadrow"><span>${esc(e.supplier)}${drivable} <span class="meta">${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""}</span></span>
<span>${money(e.price)}${e.shipping ? ` <span class="meta">+ ${money(e.shipping)} ship = <b>${money(total(e))}</b></span>` : ""}${trend}
${e.id === bestId && currents.length > 1 ? ' <span class="pill sage">best 🏆</span>' : ""}</span></div>`;
}).join("");
return `<div class="card" style="margin-bottom:14px"><h4>${esc(it.name)}</h4>${rows}</div>`;
};
const supplierCard = (s) => `
<div class="card"><h4>${esc(s.name)}</h4>
<div class="mt"><span class="pill ${s.kind === "local" ? "sage" : ""}">${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"}</span>
${s.url ? ` <a href="${esc(s.url)}" target="_blank" class="pill butter">visit site ↗</a>` : ""}</div>
${s.notes ? `<div class="meta mt">${esc(s.notes)}</div>` : ""}</div>`;
const searchable = suppliers.filter((s) => s.search_url);
view.innerHTML = `
${searchable.length ? `<div class="capture"><div class="row1">
<input type="text" id="pc-q" placeholder="🔎 price-check an item across your suppliers — e.g. custer feldspar">
<button class="go" id="pc-go">Search all</button></div>
<div class="chips" id="pc-links"><span class="hint">opens each supplier's live search — real prices, real stock, then log what you see</span></div></div>` : ""}
<div class="capture"><div class="row1">
<input type="text" id="pe-item" placeholder="item — e.g. Custer Feldspar 50 lb" style="flex:2">
<select id="pe-supplier">${suppliers.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join("") || "<option value=''>add a supplier first ↓</option>"}</select>
<input type="text" id="pe-price" inputmode="decimal" placeholder="$" style="width:70px">
<input type="text" id="pe-ship" inputmode="decimal" placeholder="ship $" style="width:80px">
<button class="go" id="pe-save">Log price</button></div>
<div class="chips"><span class="hint">log every buy or price-check — the comparisons build themselves 🌱</span></div></div>
${Object.values(items).map(itemCard).join("") || '<div class="empty">no prices logged yet — start with your next order 🧭</div>'}
${section("Suppliers")}
<div class="grid g2">${suppliers.map(supplierCard).join("") || '<div class="empty">MN Clay, the online places…</div>'}</div>
<div class="card mt"><h3> New supplier</h3>
<div class="grid g2">
<div class="field"><label>Name</label><input type="text" id="s-name" placeholder="e.g. Minnesota Clay" style="width:100%"></div>
<div class="field"><label>Type</label><div class="chips">
<span class="chip on" data-skind="online">📦 online</span><span class="chip" data-skind="local">🚗 can drive there</span></div></div>
<div class="field"><label>Distance (if drivable)</label><input type="text" id="s-dist" placeholder="e.g. 45 min" style="width:100%"></div>
<div class="field"><label>Website</label><input type="text" id="s-url" placeholder="https://…" style="width:100%"></div>
<div class="field"><label>Search link <span style="text-transform:none;font-weight:400">(their search page URL with {q} where the words go)</span></label>
<input type="text" id="s-search" placeholder="https://shop.example.com/search?q={q}" style="width:100%"></div>
</div>
<div class="field"><label>Notes</label><input type="text" id="s-notes" placeholder="free shipping over $150, closed Mondays…" style="width:100%"></div>
<button class="btn" id="s-save">Save supplier</button></div>`;
view.querySelectorAll("[data-skind]").forEach((c) =>
c.addEventListener("click", () => view.querySelectorAll("[data-skind]").forEach((x) => x.classList.toggle("on", x === c))));
$("#s-save").addEventListener("click", async () => {
const name = $("#s-name").value.trim();
if (!name) return alert("Name is all it needs 🙂");
await api("/api/suppliers", { name, kind: view.querySelector("[data-skind].on").dataset.skind,
distance: $("#s-dist").value.trim(), url: $("#s-url").value.trim(), notes: $("#s-notes").value.trim(),
search_url: $("#s-search").value.trim() });
sourcing();
});
const priceCheck = () => {
const q = $("#pc-q")?.value.trim();
if (!q) return;
$("#pc-links").innerHTML = searchable.map((s) =>
`<a class="chip" target="_blank" href="${esc(s.search_url.replace("{q}", encodeURIComponent(q)))}">🔎 ${esc(s.name)} ↗</a>`).join("");
};
$("#pc-go")?.addEventListener("click", priceCheck);
$("#pc-q")?.addEventListener("keydown", (e) => e.key === "Enter" && priceCheck());
const logPrice = async () => {
const item = $("#pe-item").value.trim(), sup = $("#pe-supplier").value;
if (!item || !sup) return;
await api("/api/price-entries", { item, supplier_id: Number(sup), price: $("#pe-price").value, shipping: $("#pe-ship").value });
sourcing();
};
$("#pe-save").addEventListener("click", logPrice);
["pe-item", "pe-price", "pe-ship"].forEach((id) =>
$("#" + id).addEventListener("keydown", (e) => e.key === "Enter" && logPrice()));
}
// ── Notes ────────────────────────────────────────────────────────
async function notes() {
const list = await api("/api/notes");
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"]]) +
`<div class="grid g2">${list.map((n) => `
<div class="card"><p style="font-size:14.5px">${esc(n.text)}</p>
<div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span>
${n.tags ? `<span class="pill">${esc(n.tags)}</span>` : ""}</div></div>`).join("") ||
'<div class="empty">no notes yet — the jot box awaits ✨</div>'}</div>`;
wireCapture(notes);
}
render();

109
prototype/public/index.html Normal file
View File

@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🌙 Studio Notebook</title>
<style>
:root {
--bg: #fdf7f2; --card: #ffffff; --ink: #4a3b47;
--plum: #8d6b94; --plum-deep: #6e4f78; --blush: #f3dbe4; --blush-deep: #c98da8;
--sage: #8fa68a; --sage-soft: #e4ecdf; --butter: #f7ecd8; --line: #eddfe3;
--moon: #f0e6f4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
.layout { display: flex; min-height: 100vh; }
aside { width: 212px; flex-shrink: 0; background: var(--card); border-right: 1.5px solid var(--line); padding: 26px 16px; position: sticky; top: 0; height: 100vh; }
.wrap { flex: 1; max-width: 900px; margin: 0 auto; padding: 0 20px 80px; min-width: 0; }
header { padding: 26px 0 0; }
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 22px; font-weight: 600; color: var(--plum-deep); }
.sub { color: #9c8698; font-size: 14.5px; margin-top: 2px; font-style: italic; font-family: Georgia, serif; }
nav { display: flex; flex-direction: column; gap: 6px; margin-top: 22px; }
nav button { border: none; background: none; color: #a08fa0; padding: 10px 14px; border-radius: 12px; font-size: 14.5px; cursor: pointer; font-weight: 600; font-family: inherit; text-align: left; }
nav button:hover { background: var(--moon); color: var(--plum-deep); }
nav button.on { background: var(--plum); color: #fff; }
@media (max-width: 700px) {
.layout { flex-direction: column; }
aside { width: 100%; height: auto; position: static; border-right: none; border-bottom: 1.5px solid var(--line); padding: 16px 20px; }
nav { flex-direction: row; flex-wrap: wrap; margin-top: 12px; }
nav button { padding: 8px 14px; border-radius: 999px; background: var(--bg); }
nav button.on { background: var(--plum); }
}
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; } .g3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 640px){ .g2, .g3 { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 18px 20px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.card h3 { font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--blush-deep); font-weight: 700; margin-bottom: 12px; }
h4 { font-family: Georgia, serif; font-size: 17px; font-weight: 600; color: var(--plum-deep); }
.meta { color: #9c8698; font-size: 13.5px; }
.capture { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 14px 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
.capture .row1 { display: flex; gap: 10px; }
input[type=text], textarea, select { border: 1.5px solid var(--line); border-radius: 12px; padding: 11px 14px; font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
.capture input[type=text] { flex: 1; }
button.go, .btn { border: none; border-radius: 12px; padding: 11px 20px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; background: var(--plum); color: #fff; }
.btn.soft { background: var(--blush); color: var(--plum-deep); }
.btn.mini { padding: 6px 14px; font-size: 13px; }
.chips { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.chips .hint { font-size: 12.5px; color: #b1a0ae; }
.chip { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); border: 1.5px solid transparent; cursor: pointer; }
.chip.on { border-color: var(--plum); background: var(--plum); color: #fff; }
.todo { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; }
.todo:last-child { border: none; }
.todo input { width: 17px; height: 17px; accent-color: var(--sage); flex-shrink: 0; }
.todo .done { text-decoration: line-through; color: #b1a0ae; }
.pill { display: inline-block; font-size: 12px; font-weight: 650; padding: 3px 11px; border-radius: 999px; background: var(--blush); color: var(--plum-deep); }
.pill.sage { background: var(--sage-soft); color: #5c7157; }
.pill.butter { background: var(--butter); color: #8d6a52; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #b1a0ae; font-weight: 700; padding: 4px; }
th:last-child, td:last-child { text-align: right; }
td { padding: 7px 4px; border-bottom: 1px solid var(--line); }
tr:last-child td { border: none; }
.swatch { height: 64px; border-radius: 12px; border: 1px solid rgba(0,0,0,0.05); margin-bottom: 10px; }
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; font-size: 14px; margin-top: 10px; }
.sparkle { background: var(--sage-soft); border-radius: 12px; padding: 10px 14px; font-size: 13.5px; color: #55624c; margin-top: 12px; }
.photos { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.photos img { width: 72px; height: 72px; border-radius: 10px; object-fit: cover; }
.ph-add { width: 72px; height: 72px; border-radius: 10px; border: 1.5px dashed var(--blush-deep); color: var(--blush-deep); font-size: 26px; background: none; cursor: pointer; }
.mt { margin-top: 14px; }
.clickable { cursor: pointer; }
.verdict { display: flex; gap: 5px; }
.verdict button { font-size: 16px; padding: 4px 9px; border-radius: 10px; background: var(--bg); border: 1.5px solid var(--line); cursor: pointer; }
.verdict button.on { background: var(--sage-soft); border-color: var(--sage); }
.loadrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; flex-wrap: wrap; }
.loadrow:last-child { border: none; }
.empty { color: #b1a0ae; font-style: italic; font-size: 14px; padding: 8px 0; }
.back { background: none; border: none; color: var(--plum); font-weight: 700; cursor: pointer; font-size: 14px; font-family: inherit; padding: 0 0 10px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px; }
.matrow { display: flex; gap: 8px; margin-bottom: 8px; }
.matrow input:first-child { flex: 1; }
.matrow input:last-of-type { width: 80px; }
footer { text-align: center; color: #b1a0ae; font-size: 13px; margin-top: 32px; font-style: italic; font-family: Georgia, serif; }
</style>
</head>
<body>
<div class="layout">
<aside>
<h1>🌙 Studio<br>Notebook</h1>
<nav id="nav">
<button data-v="today" class="on">☀️ Today</button>
<button data-v="glazes">🧪 Clay &amp; Glazes</button>
<button data-v="firings">🔥 Firing Log</button>
<button data-v="pieces">🏺 Pieces</button>
<button data-v="files">🗂️ Files</button>
<button data-v="sourcing">🧭 Sourcing</button>
<button data-v="notes">📒 Notes</button>
</nav>
</aside>
<div class="wrap">
<header style="padding-bottom:18px">
<div class="sub" id="greeting">welcome back</div>
</header>
<main id="view"></main>
<footer>🌙 Studio Notebook · Phase 1 prototype · your data lives safely on this Mac</footer>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

282
prototype/server.mjs Normal file
View File

@@ -0,0 +1,282 @@
// Studio Notebook — Phase 1 prototype server
// Zero dependencies: Node's built-in http + sqlite. Run: node prototype/server.mjs
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "node:fs";
import { join, dirname, extname } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA = join(ROOT, "data");
const PHOTOS = join(DATA, "photos");
mkdirSync(PHOTOS, { recursive: true });
const db = new DatabaseSync(join(DATA, "studio.db"));
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, tags TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS shopping (
id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glazes (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'glaze',
cone TEXT DEFAULT '', atmosphere TEXT DEFAULT '', surface TEXT DEFAULT '',
source TEXT DEFAULT 'mine', sg TEXT DEFAULT '', swatch TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS glaze_materials (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, name TEXT NOT NULL,
pct REAL NOT NULL, addition INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS glaze_journal (
id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, text TEXT NOT NULL,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS firings (
id INTEGER PRIMARY KEY, type TEXT DEFAULT 'glaze', cone TEXT DEFAULT '∆6',
schedule TEXT DEFAULT '', status TEXT DEFAULT 'firing',
result_notes TEXT DEFAULT '',
started TEXT DEFAULT (datetime('now','localtime')), unloaded TEXT);
CREATE TABLE IF NOT EXISTS firing_items (
id INTEGER PRIMARY KEY, firing_id INTEGER NOT NULL, piece TEXT NOT NULL,
clay TEXT DEFAULT '', glaze TEXT DEFAULT '', rating TEXT DEFAULT '', note TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'core line',
category TEXT DEFAULT '', price REAL DEFAULT 0, cost REAL DEFAULT 0,
qty INTEGER DEFAULT 0, status TEXT DEFAULT 'in progress',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS sales (
id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, qty INTEGER DEFAULT 1,
price REAL DEFAULT 0, cost REAL DEFAULT 0,
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY, entity TEXT NOT NULL, entity_id INTEGER NOT NULL,
filename TEXT NOT NULL, created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'online',
distance TEXT DEFAULT '', url TEXT DEFAULT '', notes TEXT DEFAULT '',
created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS price_entries (
id INTEGER PRIMARY KEY, item TEXT NOT NULL, supplier_id INTEGER NOT NULL,
price REAL NOT NULL, unit TEXT DEFAULT '', shipping REAL DEFAULT 0,
note TEXT DEFAULT '', created TEXT DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY, entity TEXT DEFAULT '', entity_id INTEGER DEFAULT 0,
stored TEXT NOT NULL, name TEXT NOT NULL, tag TEXT DEFAULT '',
size INTEGER DEFAULT 0, created TEXT DEFAULT (datetime('now','localtime')));
`);
const FILES = join(DATA, "files");
mkdirSync(FILES, { recursive: true });
try { db.exec("ALTER TABLE todos ADD COLUMN parent_id INTEGER"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE products ADD COLUMN notes TEXT DEFAULT ''"); } catch { /* column exists */ }
try { db.exec("ALTER TABLE suppliers ADD COLUMN search_url TEXT DEFAULT ''"); } catch { /* column exists */ }
const all = (sql, ...p) => db.prepare(sql).all(...p);
const get = (sql, ...p) => db.prepare(sql).get(...p);
const run = (sql, ...p) => db.prepare(sql).run(...p);
// ── the "plays well with" evidence table, built from unload ratings only ──
function compatibility(glazeName) {
return all(
`SELECT fi.clay, fi.rating, COUNT(*) n, GROUP_CONCAT(NULLIF(fi.note,''), ' · ') notes
FROM firing_items fi JOIN firings f ON f.id = fi.firing_id
WHERE fi.glaze = ? AND fi.rating != '' AND fi.clay != '' AND f.status = 'unloaded'
GROUP BY fi.clay, fi.rating ORDER BY fi.clay, n DESC`, glazeName);
}
const routes = {
"GET /api/summary": () => ({
todos: all("SELECT * FROM todos ORDER BY id DESC LIMIT 60"),
shopping: all("SELECT * FROM shopping ORDER BY done, id DESC LIMIT 12"),
notes: all("SELECT * FROM notes ORDER BY id DESC LIMIT 6"),
activeFiring: get("SELECT * FROM firings WHERE status='firing' ORDER BY id DESC LIMIT 1") ?? null,
lastUnload: get("SELECT * FROM firings WHERE status='unloaded' ORDER BY unloaded DESC LIMIT 1") ?? null,
}),
"POST /api/jot": (b) => {
const kind = b.kind || "note";
const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean);
if (kind === "todo") {
// first line = the task, following lines = its subtasks
const r = run("INSERT INTO todos (text) VALUES (?)", lines[0]);
for (const sub of lines.slice(1)) run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", sub, r.lastInsertRowid);
return { ok: true };
}
if (kind === "shopping") {
for (const l of lines) run("INSERT INTO shopping (text) VALUES (?)", l);
return { ok: true };
}
return run("INSERT INTO notes (text, tags) VALUES (?, ?)", b.text, b.tags || (kind !== "note" ? kind : ""));
},
"POST /api/todos/add": (b) => {
const r = run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", b.text, b.parent_id ?? null);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/todos/delete": (b) => {
run("DELETE FROM todos WHERE parent_id = ?", b.id);
return run("DELETE FROM todos WHERE id = ?", b.id);
},
"POST /api/todos/toggle": (b) => run("UPDATE todos SET done = 1 - done WHERE id = ?", b.id),
"POST /api/shopping/toggle": (b) => run("UPDATE shopping SET done = 1 - done WHERE id = ?", b.id),
"GET /api/notes": () => all("SELECT * FROM notes ORDER BY id DESC"),
"GET /api/glazes": () => all("SELECT * FROM glazes ORDER BY name").map(g => ({
...g, materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
})),
"GET /api/glaze": (_, q) => {
const g = get("SELECT * FROM glazes WHERE id = ?", Number(q.get("id")));
if (!g) return null;
return { ...g,
materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id),
journal: all("SELECT * FROM glaze_journal WHERE glaze_id = ? ORDER BY id DESC", g.id),
compatibility: compatibility(g.name),
photos: all("SELECT * FROM photos WHERE entity='glaze' AND entity_id = ?", g.id) };
},
"POST /api/glazes": (b) => {
const r = run(
"INSERT INTO glazes (name, kind, cone, atmosphere, surface, source, sg, swatch) VALUES (?,?,?,?,?,?,?,?)",
b.name, b.kind || "glaze", b.cone || "", b.atmosphere || "", b.surface || "", b.source || "mine", b.sg || "", b.swatch || "");
for (const m of b.materials || [])
run("INSERT INTO glaze_materials (glaze_id, name, pct, addition) VALUES (?,?,?,?)",
r.lastInsertRowid, m.name, m.pct, m.addition ? 1 : 0);
return { id: Number(r.lastInsertRowid) };
},
"POST /api/glaze/journal": (b) => run("INSERT INTO glaze_journal (glaze_id, text) VALUES (?,?)", b.glaze_id, b.text),
"GET /api/firings": () => all("SELECT * FROM firings ORDER BY id DESC").map(f => ({
...f, items: all("SELECT * FROM firing_items WHERE firing_id = ?", f.id) })),
"POST /api/firings": (b) => {
const r = run("INSERT INTO firings (type, cone, schedule) VALUES (?,?,?)", b.type || "glaze", b.cone || "∆6", b.schedule || "");
for (const it of b.items || [])
run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", r.lastInsertRowid, it.piece, it.clay || "", it.glaze || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/firing/items": (b) => run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", b.firing_id, b.piece, b.clay || "", b.glaze || ""),
"POST /api/firing/rate": (b) => run("UPDATE firing_items SET rating = ?, note = ? WHERE id = ?", b.rating, b.note || "", b.id),
"POST /api/firing/unload": (b) => run("UPDATE firings SET status='unloaded', unloaded=datetime('now','localtime'), result_notes=? WHERE id=?", b.result_notes || "", b.id),
"GET /api/products": () => ({
products: all("SELECT * FROM products ORDER BY kind, category, name"),
money: {
revenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales").v,
cogs: get("SELECT COALESCE(SUM(qty*cost),0) v FROM sales").v,
monthRevenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales WHERE created >= date('now','start of month')").v,
salesCount: get("SELECT COALESCE(SUM(qty),0) v FROM sales").v,
},
}),
"POST /api/products": (b) => {
const r = run("INSERT INTO products (name, kind, category, price, cost, qty) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "core line", b.category || "", Number(b.price) || 0, Number(b.cost) || 0, Number(b.qty) || 0);
return { id: Number(r.lastInsertRowid) };
},
"GET /api/product": (_, q) => {
const p = get("SELECT * FROM products WHERE id = ?", Number(q.get("id")));
if (!p) return null;
return { ...p,
photos: all("SELECT * FROM photos WHERE entity='product' AND entity_id = ?", p.id),
files: all("SELECT * FROM files WHERE entity='product' AND entity_id = ? ORDER BY id DESC", p.id),
sales: all("SELECT * FROM sales WHERE product_id = ? ORDER BY id DESC LIMIT 10", p.id) };
},
"POST /api/products/notes": (b) => run("UPDATE products SET notes = ? WHERE id = ?", b.notes, b.id),
"GET /api/files": () => all(`SELECT f.*, p.name AS product_name FROM files f
LEFT JOIN products p ON f.entity = 'product' AND p.id = f.entity_id ORDER BY f.id DESC`),
"POST /api/files/delete": (b) => {
const f = get("SELECT * FROM files WHERE id = ?", b.id);
if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } run("DELETE FROM files WHERE id = ?", b.id); }
return { ok: true };
},
"GET /api/sourcing": () => ({
suppliers: all("SELECT * FROM suppliers ORDER BY name"),
entries: all(`SELECT pe.*, s.name AS supplier, s.kind AS supplier_kind, s.distance
FROM price_entries pe JOIN suppliers s ON s.id = pe.supplier_id ORDER BY pe.item, pe.id DESC`),
}),
"POST /api/suppliers": (b) => {
const r = run("INSERT INTO suppliers (name, kind, distance, url, notes, search_url) VALUES (?,?,?,?,?,?)",
b.name, b.kind || "online", b.distance || "", b.url || "", b.notes || "", b.search_url || "");
return { id: Number(r.lastInsertRowid) };
},
"POST /api/price-entries": (b) => run(
"INSERT INTO price_entries (item, supplier_id, price, unit, shipping, note) VALUES (?,?,?,?,?,?)",
b.item, b.supplier_id, Number(b.price) || 0, b.unit || "", Number(b.shipping) || 0, b.note || ""),
"POST /api/products/qty": (b) => run("UPDATE products SET qty = MAX(0, qty + ?) WHERE id = ?", b.delta, b.id),
"POST /api/products/status": (b) => run("UPDATE products SET status = ? WHERE id = ?", b.status, b.id),
"POST /api/sales": (b) => {
const p = get("SELECT * FROM products WHERE id = ?", b.product_id);
if (!p) return { error: "no such product" };
run("INSERT INTO sales (product_id, qty, price, cost) VALUES (?,?,?,?)", p.id, b.qty || 1, b.price ?? p.price, p.cost);
if (p.kind === "core line" || p.kind === "tiles") run("UPDATE products SET qty = MAX(0, qty - ?) WHERE id = ?", b.qty || 1, p.id);
else run("UPDATE products SET status = 'sold' WHERE id = ?", p.id);
return { ok: true };
},
};
const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" };
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const send = (code, body, type = "application/json") => {
res.writeHead(code, { "content-type": type });
res.end(type === "application/json" ? JSON.stringify(body) : body);
};
try {
// photo upload: POST /api/photos?entity=note&id=3 (raw image body)
if (req.method === "POST" && url.pathname === "/api/photos") {
const chunks = [];
for await (const c of req) chunks.push(c);
const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase();
const filename = randomUUID() + ext;
writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks));
run("INSERT INTO photos (entity, entity_id, filename) VALUES (?,?,?)",
url.searchParams.get("entity") || "note", Number(url.searchParams.get("id") || 0), filename);
return send(200, { ok: true, filename });
}
// file upload: POST /api/files?name=mold.stl&tag=master+molds[&entity=product&id=3]
if (req.method === "POST" && url.pathname === "/api/files") {
const chunks = [];
for await (const c of req) chunks.push(c);
const buf = Buffer.concat(chunks);
const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_");
const stored = randomUUID() + extname(orig).toLowerCase();
writeFileSync(join(FILES, stored), buf);
run("INSERT INTO files (entity, entity_id, stored, name, tag, size) VALUES (?,?,?,?,?,?)",
url.searchParams.get("entity") || "", Number(url.searchParams.get("id") || 0),
stored, orig, url.searchParams.get("tag") || "", buf.length);
return send(200, { ok: true });
}
if (url.pathname.startsWith("/file/")) {
const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, "");
const f = get("SELECT * FROM files WHERE stored = ?", stored);
if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" });
res.writeHead(200, {
"content-type": MIME[extname(stored)] || "application/octet-stream",
"content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"`,
});
return res.end(readFileSync(join(FILES, stored)));
}
if (url.pathname.startsWith("/photos/")) {
const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, ""));
if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream");
return send(404, { error: "not found" });
}
const handler = routes[`${req.method} ${url.pathname}`];
if (handler) {
let body = {};
if (req.method === "POST") {
const chunks = [];
for await (const c of req) chunks.push(c);
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
}
return send(200, handler(body, url.searchParams) ?? { ok: true });
}
// static app
const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, "");
const path = join(ROOT, "public", file);
if (existsSync(path)) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain");
send(404, { error: "not found" });
} catch (e) {
send(500, { error: String(e.message || e) });
}
}).listen(6161, "127.0.0.1", () => {
console.log("🌙 Studio Notebook running at http://localhost:6161");
});