.emu-device-card was display:flex justify-content:center, so adding
the SD card div as a sibling of .emu-chassis put them side-by-side
in a row. The chassis's internal grid then competed with the SD card
for horizontal space and the LCD column collapsed.
Fix: flex-direction:column on .emu-device-card so the chassis sits
on top at full width, with the SD card affordance stacked below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real device's REMOVE SD CARD / inserted-OK screens are driven by
gui.SDCardEvent{Inserted: bool}. On real hardware that's a kernel
hotplug event; in the browser we have to synthesise it.
Added:
- composer-style "Eject SD card" / "Insert SD card" button below
the chassis, plus a tiny visual slot affordance with a card
that animates in/out
- exportSetSDCard JS bridge in cmd/emulator/main.go that pushes
gui.SDCardEvent{Inserted: bool}.Event() onto the pending queue
- JS state tracking + label flip + Go side called on every click
Default state: inserted (matches real device boot). Toggle is a
simple binary — no "ignore warning" simulation since the firmware
already handles that on the Hold-button-to-ignore path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chassis redesign renamed .emu-btn → .emu-disc-zone (joystick zones)
and .emu-key (side keys), but app.js was still querying .emu-btn.
Result: no click listeners attached after the chassis swap — only
the keyboard worked.
Fix:
- els.buttons selector covers both new class names
- keyboard handler's visual-feedback selector dropped the .emu-btn
prefix; matching on data-btn alone finds the right element no
matter which class it carries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layout was a generic dev-tool affordance (small canvas on top, square
button grid below). Real device is landscape orientation with the
LCD recessed in the middle, ONE disc joystick on the left side, and
THREE round side keys stacked on the right.
before: after:
+----------------+ +---------------------------+
| [canvas] | | (\) +-------+ o |
| | | | | LCD | o |
| [ U ] | | (/) | | o |
| [L][C][R] | | +-------+ |
| [ D ] | +---------------------------+
| | orange anodised chassis
| [K1][K2][K3] |
+----------------+
The chassis is a CSS-only render — orange gradient with edge highlight
+ inset shadow stack so it reads as machined aluminium. The disc
joystick is a single circular metal-look element with 5 overlapping
square-ish hit zones (top/bottom/left/right/centre); arrow glyphs
hint at the directions but the look is one cohesive control rather
than five separate buttons. The three side keys are round metal-look
domes with press states.
Footer copy refreshed: no more "Phase 2 scaffolding" caveat — the
real upstream GUI is running. Notes the camera and engraver are
stubbed.
Responsive breakpoint at 600px shrinks chassis + controls so the
emulator stays usable on phones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 kickoff. The cmd/emulator binary now builds to WebAssembly,
loads a 240×240 LCD-equivalent canvas in the browser, and accepts
8-button input from either on-screen buttons or mapped keyboard
keys. The actual v1 gui/ package isn't lifted yet — this is the
scaffolding that proves the platform/v1.Platform interface contract
and the build/serve pipeline so the GUI lift can land cleanly later.
platform/v1/platform.go (interface)
Button enum: Up Down Left Right Center Button1 Button2 Button3
Event{Button, Pressed}
Platform interface: Events() <-chan Event, Display(image.Image)
The interface is intentionally minimal — engrave/font/QR access
happens via direct package imports, not through Platform.
Future additions (camera, persistent storage) widen the
interface, not the data model.
cmd/emulator/main.go (//go:build js && wasm)
browserPlatform implements Platform against the browser:
- Display() copies the RGBA buffer into a JS Uint8ClampedArray
and calls back into emulatorPaint(pixels, w, h)
- Events() returns a 32-slot buffered channel that
exportPushEvent feeds from JS
Boot screen draws a stub frame (orange border, centre cross-hair
tick) so the canvas isn't blank during the GUI-less stage.
Future: replace drawBootScreen with gui.Loop() once gui/ is
lifted.
cmd/emulator/emulator_host.go (//go:build !js || !wasm)
Host placeholder so `go build ./...` succeeds on non-WASM
targets. Emits a helpful "build with GOOS=js GOARCH=wasm".
web/emulator/{index.html, app.css, app.js, build.sh}
Static PWA shell. Lays out an LCD frame (canvas, 320×320
rendered with image-rendering:pixelated for crisp 240×240
upscale), an on-screen joystick grid, three side-key buttons,
and a keyboard-shortcut reference table. Keyboard mapping
matches docs/architecture/v1-buttons-and-ui.md proposal.
Reuses ../composer/{wasm_exec.js, app.css} — DRY base styling,
no duplication.
The static server now needs to run one directory level higher
(`-d ./web` instead of `-d ./web/composer`) so both /composer/
and /emulator/ are reachable.
Build with: ./web/emulator/build.sh
Serve with: python3 -m http.server -d ./web 38080
Open: http://localhost:38080/emulator/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(1) Mounting holes were rendering at 6.5mm inset — a guess. The actual
Mineracks production DXF (Name-Plate-85x85-316L 2B.DXF, the SH-02
plate) places 4 circles at (3, 3), (3, 82), (82, 3), (82, 82) with
radius 1.5mm. Centres sit on the outerMargin line; the hole edge
falls 1.5mm shy of the plate edge.
holeInsetMM: 6.5 → 3.0
holeDiameterMM: 3.5 → 3.0 (true M3 clearance from CAD)
holeDangerDiameterMM: unchanged at 7mm (covers the M3 nut/head
footprint per the spec)
Hole fill now #fff so the eye reads "metal removed here" instead
of a printed dot. Same 3mm inset is applied to SH-01 and SH-03 —
same physical drill jig, just different plate aspect.
(2) SVG-mode error message when no drawable shapes are found now
enumerates what was actually in the file (e.g. "found: 12 <g>,
4 <text>, 2 <tspan>") and gives a targeted hint:
- if <text>/<tspan>: "text logos need 'Path → Object to Path'
in Inkscape first"
- if <image>: "raster images can't be engraved; re-trace as
vector outlines"
- if <use>/<symbol>: "unresolved references; expand in your
editor first"
Helps users figure out what to fix in their .svg without having
to guess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two user-facing polish items spotted at the preview stage:
(1) Plate previews now render the physical M3 mounting holes. SH-01
and SH-02 get 4 corner holes; SH-03 (Large, 85×134mm) gets 6 —
4 corners plus 2 mid-edge holes on the long sides, matching the
physical clamping pattern. Each hole shows as:
- a grey filled circle (3.5mm visual diameter, slightly larger
than the actual M3 clearance for preview contrast)
- a red dashed "no-engrave" exclusion ring (7mm diameter)
The user can see at a glance where the engraver must not punch.
Hole positions:
corners at (holeInset, holeInset) with mirrors for the other
three quadrants, where holeInset = 6.5mm — midway between
outerMargin (3mm, the no-engrave boundary) and innerMargin
(10mm, the safe text area). The 3-10mm band is where SeedHammer
drills the holes on real plates.
holePositions(plateDims) is a small helper exposed for the future
when an actual gui/ port needs the same numbers.
(2) SVG file upload now auto-converts <rect>, <circle>, <ellipse>,
<line>, <polyline>, <polygon> to <path d="..."/> in the browser
before extracting d-strings. Lifted Gangleri42's rewriteShapes
algorithm verbatim (same Bezier control-point ratios for the
circle/ellipse approximations).
Previously the composer rejected files with no <path> elements
and told the user to "flatten shapes in your editor first" —
which was a hard-fail UX for a fixable problem. Now any SVG
with drawable shapes Just Works.
Transforms still need manual flattening (getCTM-based bake is a
bigger chunk); the help text reflects this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Editor card now has two tabs: "Block text" and "SVG paths". Picking
an .svg file extracts every <path d="..."/> via DOMParser, hands the
d-strings to WASM, which builds sh1e.SvgPath entries (one per path)
anchored at (innerMargin, innerMargin) at 100% scale. Live preview
renders them on the plate via native browser <path> support — same
stroke styling as the engraving font so the visual is consistent.
JS side (web/composer/app.js):
- onSVGFile(): reads file, runs DOMParser on the text, harvests
every <path>'s `d` attribute, errors out clearly if no paths
found ("flatten shapes to paths in your editor first")
- mode state ("text" | "svg") switches which encoder/preview is
called from refresh(), showQR(), showBytes()
- setMode() flips the active tab + the visible editor pane
- a summary line shows filename, path count, total chars
Go side stays as-is (composerEncodeSVG / composerPreviewSVG /
composerQRSVG were wired in the previous commit). Plate chrome
rendering shared between text- and svg-preview via writePlateChrome
so the two modes draw the same plate outline + margin guides.
Limitations (acceptable for MVP, documented in the tab help text):
- Only flat <path d="..."> elements are recognised; <rect>,
<circle>, <use>, transforms, and nested <svg> are ignored. Users
should flatten in their editor (Inkscape: Object → Path, then
Flatten Transforms).
- Strict-subset path syntax check (M/L/H/V/Q/C/Z only — no arcs
per the SH1E spec) is not enforced composer-side yet. The Pi
parser will reject non-subset paths. Composer-side validation
is a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tightly-related polish changes:
1. Preview now uses font/constant.Font — the v1 firmware's *vector*
engraving face — instead of bitmap Comfortaa. Every <path> stroke
in the preview is the actual stroke the engraver will follow on
the plate. Reads the same MoveTo/LineTo segment data the upstream
stepper consumes. vector-effect=non-scaling-stroke keeps the
stroke width visually constant regardless of the SVG scale.
The bitmap-faithful renderer is gone — engrave fidelity is more
useful than LCD fidelity for the composer use case. The
bitmap.Face import path is dropped from main.go.
2. Layout columns swapped. Editor was on the right, preview on the
left. Now editor (taller, wider) is on the LEFT and plate picker +
preview + actions stack on the RIGHT. Matches the natural
"pick a plate → type → see result" reading order. Plate section
gains a .plate-card class so it can be placed in the grid area.
3. Stale-error fix: typing in any input now clears the Show-SH1E-
bytes "enter at least one line first" warning. Previously stuck
around until the bytes button was clicked again with valid input.
Also extends the composer's JS export surface (composerPreviewSVG,
composerEncodeSVG, composerQRSVG) and adds the SVG-mode helpers in
Go (readSVGArgs, makeSVGDesign, writePlateChrome). JS shell doesn't
call these yet — wiring lands in the next commit (stage #3 of the
five-stage push).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the system-monospace SVG <text> placeholder with actual
bitmap-glyph rendering from font/comfortaa.Regular16 — the same
16-pixel Comfortaa face the v1 firmware ships on the LCD. Every
"on" pixel in the bitmap becomes part of a horizontal-run <rect>
inside a transform="translate scale" group, so the preview is
pixel-identical to what the device's screen would show.
Implementation (cmd/composer/main.go):
- faceForFont(FontID, sizePt) -> *bitmap.Face
Maps SH1E font IDs to bitmap faces. Today everything resolves
to comfortaa.Regular16 — Poppins/Constant fall back until we
wire their faces in.
- renderTextRowBitmap(sb, lineLayout)
Two-pass over the text:
1. Walk to compute total pixel width (advance + kerning) for
horizontal alignment math.
2. Emit one <g> with translate to (l.XMM, l.YMM + ascent*scale)
and scale = fontMM / 16. For each glyph: AlphaAt threshold
at 0x80, coalesce horizontal runs into single rects.
Threshold-binary (not grayscale) because the engraver itself
is binary — a pixel either gets punched or it doesn't.
Updated preview-note to reflect the new fidelity: "Each pixel in
the preview corresponds to a pixel the engraver will punch."
WASM size 3.6 → 3.5 MB (the simple <text> SVG → bitmap rect SVG
swap is roughly even; font binary data was already linked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New JS export from cmd/composer:
composerQR(plateType, lines) -> {svg, modules, bytes}
Encodes the design as SH1E (same path as Encode), then runs the
bytes through kortschak/qr at medium error-correction level (15%
damage tolerance — sensible for a phone-screen-to-camera handoff).
Returns an inline SVG of the QR matrix with a 4-module quiet zone
baked in, drawn as horizontal-run rects (one per dark run, NOT one
per cell — ~half the markup).
UI: a new orange primary button "Show QR" beside the debug bytes
button. Click → fullscreen overlay with the QR centred on a white
card, byte/module count below, and a close button. Click outside
the card or hit Escape to dismiss.
This is the Phase 1 unlock that turns the composer from "interesting
demo" into "I can hold my phone up to a SeedHammer v1 and engrave
this plate". Single-frame QR for SH1E payloads up to ~200 bytes
(well above the typical 90B for a 12-word seed plate). BBQr for
multi-plate manifests comes later.
WASM grew 3.4MB → 3.6MB from pulling in the QR encoder. Acceptable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live SVG preview of the plate, rendered from the same lineLayout
function as Encode so the visual always matches what gets engraved.
The preview shows the plate outline, both margin guides (outer 3mm /
inner 10mm), and each non-empty text block at its (XMM, YMM) anchor.
Plate-aware line counts: composerPlateTypes now reports `max_lines`
per plate (computed from H minus innerMargin minus textYStartMM,
divided by stride). UI hides input rows above that count and
restores them when a bigger plate is picked again — no data loss
on plate switch.
Small 85×55 → 5 lines
Square 85×85 → 9 lines
Large 85×134 → 15 lines
textYStartMM bumped 5 → 11mm so the first line sits inside the
innerMargin safe-engrave guide (it was visibly outside before).
textXMM bumped 5 → 11mm for the same reason.
Hex dump:
- 8 bytes per row instead of 16 (~33 chars wide, fits any column)
- shorter offset prefix (auto-width by total size)
- now a debug-only collapsible alongside the live size meter
Layout fixes:
- .preview no longer hard-pinned to 1:1 aspect (lifted webnfc CSS
was sized for SH-II's square plate)
- main > * gets min-width: 0 so wide hex content can't inflate
the grid column past its fr unit
- html, body get overflow-x: hidden as a root-level safety net
- text baseline now calculated as YMM + fontMM*0.78 instead of
relying on dominant-baseline="hanging" — Safari renders the
latter inconsistently which made Large-plate text invisible
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end proof that the Go-to-WASM pipeline works and the SH1E
encoder produces canonical bytes loadable by a browser.
cmd/composer/main.go (135 lines)
//go:build js && wasm
Three JS exports:
- composerVersion() string
- composerPlateTypes() Array<{id, name, w_mm, h_mm}>
- composerEncodeText(plateType:number, lines:string[]) Uint8Array
Encoder maps each non-empty line to a sh1e.TextBlock with v1
plate constants (Comfortaa 12pt, 5mm x-offset, 8mm y-stride,
left-aligned). Layout polish in follow-up commits; today the
point is that the wire format is real SH1E.
cmd/composer/composer_host.go (13 lines)
//go:build !js || !wasm placeholder so `go build ./...` is clean
on non-WASM targets; emits a helpful "use GOOS=js GOARCH=wasm"
message if invoked.
web/composer/index.html (61 lines)
Fresh shell — no NFC/bridge UI (v1 uses QR handoff). Plate
radio group, 12-line text editor, size meter, output pane.
web/composer/app.js (101 lines)
Vanilla JS, no framework, no bundler. Loads wasm_exec.js +
composer.wasm, builds UI from composerPlateTypes(), wires
Generate button to composerEncodeText() + hex-dump output.
web/composer/app.css
Lifted verbatim from Gangleri42/seedhammer cmd/webnfc/app.css
(liquid-glass surfaces, system-theme-aware). 304 lines, MIT/
Unlicense.
web/composer/manifest.webmanifest
PWA manifest with v1 branding.
web/composer/build.sh
One-command rebuild: stages wasm_exec.js from GOROOT, builds
composer.wasm with -trimpath -ldflags='-s -w'. Prints the
serve command + URL.
Verified end-to-end:
- WASM builds at 3.4MB
- HTTP server returns 200 for /, /composer.wasm (application/
wasm), /wasm_exec.js, /app.js, /app.css
- sh1e tests still green
- go build ./... clean on host (placeholder file)
Deferred to follow-up commits:
- Live SVG preview rendering (needs engrave/font integration)
- QR display (probably via a small Go QR library compiled in)
- SVG path mode (the second composer tab)
- SeedSigner sim handoff bus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>