Composer UX polish — live preview, plate-aware lines, hex layout

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>
This commit is contained in:
mineracks 2026-05-28 20:07:06 +10:00
parent a4bcf364c7
commit 14486455c6
4 changed files with 408 additions and 120 deletions

View File

@ -9,14 +9,11 @@
// The static shell (HTML/CSS/JS) lives under web/composer/. Exported JS
// surface is documented in web/composer/app.js — every JS function listed
// there is bound here.
//
// This is the Phase 1 milestone build: a minimal composer that proves the
// Go-to-WASM pipeline works and produces canonical SH1E payloads. The
// preview/QR/SVG-mode features land in follow-up commits.
package main
import (
"fmt"
"strings"
"syscall/js"
"github.com/mineracks/seedhammer-v1-companion/engrave/wire/sh1e"
@ -28,47 +25,229 @@ func main() {
js.Global().Set("composerVersion", js.FuncOf(exportVersion))
js.Global().Set("composerPlateTypes", js.FuncOf(exportPlateTypes))
js.Global().Set("composerEncodeText", js.FuncOf(exportEncodeText))
js.Global().Set("composerPreviewText", js.FuncOf(exportPreviewText))
// Block forever so the Go runtime keeps the exported funcs alive.
select {}
}
// exportVersion returns the composer version string. Used by the JS shell
// to show what's loaded.
// ─── Plate geometry (v1 hardware constants) ──────────────────────────────
type plateDims struct {
Name string
W float64 // mm
H float64 // mm
}
// plateDimsByID lists v1 plate sizes from upstream backup/backup.go at
// v1.3.0. SmallPlate / SquarePlate / LargePlate enum order matches sh1e's.
var plateDimsByID = []plateDims{
{Name: "Small", W: 85, H: 55},
{Name: "Square", W: 85, H: 85},
{Name: "Large", W: 85, H: 134},
}
// outerMarginMM matches backup.go's outerMargin — the "no-engrave"
// boundary inset from the plate edge.
const outerMarginMM = 3.0
// innerMarginMM matches backup.go's innerMargin — the slightly larger
// inset where text typically starts. Used as a guide rectangle in the
// preview.
const innerMarginMM = 10.0
// ─── Layout constants ─────────────────────────────────────────────────────
const (
// Default text-block font size in points.
defaultFontSizePoints = 12
// X position of the first text block — sits inside the innerMargin
// guide so the engraver never touches the no-engrave zone.
textXMM = 11
// Y of the first text block — also inside innerMargin.
textYStartMM = 11
// Vertical stride between block tops.
textYStrideMM = 8
)
// maxLinesFor returns the number of text blocks that fit inside the
// innerMargin-safe area for a given plate. Used by the JS shell to size
// the line-input UI per plate.
func maxLinesFor(p plateDims) int {
// Last line's bottom must be ≤ p.H - innerMarginMM. With each line
// occupying ~fontMM of vertical space starting at textYStartMM + i*stride:
//
// textYStartMM + i*textYStrideMM + fontHeight ≤ p.H - innerMarginMM
//
// Approximate fontHeight as textYStrideMM-1 (leaves 1mm of breathing
// room between lines).
available := p.H - innerMarginMM - textYStartMM
if available <= 0 {
return 0
}
n := int(available/textYStrideMM) + 1
if n < 1 {
return 1
}
// Hard upper bound from sh1e spec.
if n > 32 {
n = 32
}
return n
}
// ─── Shared layout ───────────────────────────────────────────────────────
//
// JS signature: composerVersion() -> string
// Encode and Preview must agree on where text lands. layoutLines is the
// single source of truth so a change here propagates to both.
type lineLayout struct {
FontID sh1e.FontID
Size uint16 // points
XMM int16
YMM int16
Alignment sh1e.Alignment
Text string
}
func layoutLines(lines []string) []lineLayout {
out := make([]lineLayout, 0, len(lines))
for i, line := range lines {
out = append(out, lineLayout{
FontID: sh1e.FontComfortaa,
Size: defaultFontSizePoints,
XMM: textXMM,
YMM: int16(textYStartMM + i*textYStrideMM),
Alignment: sh1e.AlignLeft,
Text: line,
})
}
return out
}
// ─── JS exports ──────────────────────────────────────────────────────────
func exportVersion(this js.Value, args []js.Value) any {
return composerVersion
}
// exportPlateTypes returns the supported v1 plate types as a JS array of
// {id, name, w_mm, h_mm} objects. Used to populate the plate picker.
//
// JS signature: composerPlateTypes() -> Array<{id, name, w_mm, h_mm}>
//
// Values mirror upstream backup/backup.go at v1.3.0.
func exportPlateTypes(this js.Value, args []js.Value) any {
return js.ValueOf([]any{
map[string]any{"id": int(sh1e.SmallPlate), "name": "Small", "w_mm": 85, "h_mm": 55},
map[string]any{"id": int(sh1e.SquarePlate), "name": "Square", "w_mm": 85, "h_mm": 85},
map[string]any{"id": int(sh1e.LargePlate), "name": "Large", "w_mm": 85, "h_mm": 134},
})
out := make([]any, 0, len(plateDimsByID))
for i, p := range plateDimsByID {
out = append(out, map[string]any{
"id": i,
"name": p.Name,
"w_mm": p.W,
"h_mm": p.H,
"max_lines": maxLinesFor(p),
})
}
return js.ValueOf(out)
}
// exportEncodeText takes a plate type (number) and a JS array of line
// strings, produces an SH1E envelope.
//
// JS signature: composerEncodeText(plateType:number, lines:string[]) -> Uint8Array
//
// Throws on any validation failure (bad plate type, non-ASCII text, too
// many lines, …) — JS sees these as caught exceptions with a readable
// message.
// exportEncodeText: composerEncodeText(plateType:number, lines:string[]) -> Uint8Array
func exportEncodeText(this js.Value, args []js.Value) any {
plateType, lines, err := readArgs(args)
if err != nil {
return jsError(err)
}
layout := layoutLines(lines)
blocks := make([]sh1e.TextBlock, 0, len(layout))
for _, l := range layout {
blocks = append(blocks, sh1e.TextBlock{
FontID: l.FontID,
Size: l.Size,
XMM: l.XMM,
YMM: l.YMM,
Alignment: l.Alignment,
Text: l.Text,
})
}
bytes, err := sh1e.Encode(sh1e.Design{
PlateType: plateType,
TextBlocks: blocks,
})
if err != nil {
return jsError(err)
}
return uint8Array(bytes)
}
// exportPreviewText: composerPreviewText(plateType:number, lines:string[]) -> string (SVG)
//
// Returns a plate-anchored inline SVG showing the layout. Phase 1 milestone
// uses CSS-side font rendering (monospaced web font) rather than glyph-
// faithful Comfortaa rasterisation — pixel-perfect preview comes in a
// follow-up commit. Coordinates exactly match what exportEncodeText emits.
func exportPreviewText(this js.Value, args []js.Value) any {
plateType, lines, err := readArgs(args)
if err != nil {
return jsError(err)
}
if int(plateType) >= len(plateDimsByID) {
return jsError(fmt.Errorf("unknown plate type %d", plateType))
}
dims := plateDimsByID[plateType]
var sb strings.Builder
// Note: SVG y-axis grows downward (consistent with our XMM/YMM
// "from plate-origin top-left" convention).
fmt.Fprintf(&sb,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">`,
dims.W, dims.H,
)
// Plate outline (rounded-corner rect)
fmt.Fprintf(&sb,
`<rect x="0.5" y="0.5" width="%g" height="%g" rx="3" ry="3" fill="#ececec" stroke="#444" stroke-width="0.4"/>`,
dims.W-1, dims.H-1,
)
// outerMargin guide (dashed, light)
fmt.Fprintf(&sb,
`<rect x="%g" y="%g" width="%g" height="%g" fill="none" stroke="#999" stroke-width="0.15" stroke-dasharray="0.6,0.6"/>`,
outerMarginMM, outerMarginMM, dims.W-2*outerMarginMM, dims.H-2*outerMarginMM,
)
// innerMargin guide (dashed, slightly darker)
fmt.Fprintf(&sb,
`<rect x="%g" y="%g" width="%g" height="%g" fill="none" stroke="#666" stroke-width="0.15" stroke-dasharray="0.4,0.4"/>`,
innerMarginMM, innerMarginMM, dims.W-2*innerMarginMM, dims.H-2*innerMarginMM,
)
// Text blocks
for _, l := range layoutLines(lines) {
// font-size in SVG units = mm (because of viewBox). Roughly:
// 12 pt ≈ 4.23 mm; we use 0.33 mm/pt as the multiplier.
fontMM := float64(l.Size) * 0.33
anchor := "start"
switch l.Alignment {
case sh1e.AlignCenter:
anchor = "middle"
case sh1e.AlignRight:
anchor = "end"
}
// We treat (XMM, YMM) as the TOP-LEFT corner of the glyph cell
// (matching the spec). SVG <text> y is the baseline. Offset by
// the cap height (~0.78 of font size for monospaced faces) so
// the rendered text starts at YMM. Avoids dominant-baseline,
// which Safari renders inconsistently for "hanging".
baselineY := float64(l.YMM) + fontMM*0.78
fmt.Fprintf(&sb,
`<text x="%d" y="%g" font-size="%g" text-anchor="%s" fill="#111" font-weight="600">%s</text>`,
l.XMM, baselineY, fontMM, anchor, escapeXML(l.Text),
)
}
sb.WriteString(`</svg>`)
return sb.String()
}
// ─── Helpers ─────────────────────────────────────────────────────────────
func readArgs(args []js.Value) (sh1e.PlateType, []string, error) {
if len(args) != 2 {
return jsError(fmt.Errorf("composerEncodeText: expected 2 args, got %d", len(args)))
return 0, nil, fmt.Errorf("expected 2 args, got %d", len(args))
}
plateType := sh1e.PlateType(args[0].Int())
// Convert JS array of strings to []string.
jsLines := args[1]
n := jsLines.Length()
lines := make([]string, 0, n)
@ -79,54 +258,19 @@ func exportEncodeText(this js.Value, args []js.Value) any {
}
lines = append(lines, s)
}
// For Phase 1 milestone: each non-empty line becomes one TextBlock
// at FontComfortaa size 12, stacked at y = 5 + i*8 mm, x = 5 mm,
// left-aligned. Layout polish lands in a follow-up commit.
const (
fontSize = 12
xMM = 5
yStartMM = 5
yStrideMM = 8
alignment = sh1e.AlignLeft
fontFamily = sh1e.FontComfortaa
)
blocks := make([]sh1e.TextBlock, 0, len(lines))
for i, line := range lines {
blocks = append(blocks, sh1e.TextBlock{
FontID: fontFamily,
Size: fontSize,
XMM: xMM,
YMM: int16(yStartMM + i*yStrideMM),
Alignment: alignment,
Text: line,
})
}
design := sh1e.Design{
PlateType: plateType,
TextBlocks: blocks,
}
bytes, err := sh1e.Encode(design)
if err != nil {
return jsError(err)
}
return uint8Array(bytes)
return plateType, lines, nil
}
func escapeXML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// jsError wraps a Go error as a JS-side thrown Error. syscall/js doesn't
// expose Throw directly from FuncOf callbacks — returning a JS Error
// object causes the caller's try/catch to see it; alternative is to
// panic, but that abuses the runtime for control flow.
func jsError(err error) any {
jsErr := js.Global().Get("Error").New(err.Error())
js.Global().Get("console").Call("error", jsErr)
panic(jsErr) // caught as a Go panic across the JS boundary, surfaces as a JS exception
panic(jsErr)
}
// uint8Array copies a Go []byte into a fresh JS Uint8Array. Required
// because js.CopyBytesToJS is the only safe way to transfer a Go byte
// slice to JS — direct js.ValueOf on []byte doesn't produce a typed array.
func uint8Array(src []byte) js.Value {
dst := js.Global().Get("Uint8Array").New(len(src))
js.CopyBytesToJS(dst, src)

View File

@ -302,3 +302,68 @@ main {
}
.footer-card p { margin: 0; }
.footer-card code { font-size: 11px; }
/* v1-specific additions */
/* Override the lifted webnfc .preview rules: that container was sized
for SH-II's square 85x85 plate (aspect-ratio: 1/1). v1 has three
plate sizes Small 85x55 (wider), Square 85x85, Large 85x134
(taller). Let the SVG dictate its own aspect ratio and centre it. */
.preview {
aspect-ratio: unset;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
overflow: visible;
/* Cap visual size so a Large plate doesn't push the rest of the page. */
max-height: 70vh;
}
.preview svg {
display: block;
width: 100%;
height: auto;
max-height: 70vh;
margin: 0 auto;
}
.preview:empty::before { content: ""; }
.preview-note {
margin: 8px 0 0;
font-size: 0.8em;
color: var(--text-dim);
text-align: center;
}
.plate-choice { display: inline-flex; gap: 6px; align-items: center; margin-right: 16px; cursor: pointer; }
.plate-choice small { color: var(--text-dim); margin-left: 4px; }
.status.error { color: var(--error); }
/* Stop wide content (the hex dump) from inflating the grid column. Grid
items have `min-width: auto` by default which means content intrinsic
width sets the column min `min-width: 0` opts back into shrinking. */
html, body { overflow-x: hidden; }
main > * { min-width: 0; }
/* hex-dump pre: scroll internally inside its now-constrained parent. */
#output {
white-space: pre;
overflow-x: auto;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
background: var(--surface-strong);
border-radius: 8px;
padding: 12px;
margin: 8px 0 0;
color: var(--text);
max-height: 50vh;
max-width: 100%;
width: 100%;
box-sizing: border-box;
}
#output.error {
color: var(--error);
white-space: pre-wrap;
font-family: inherit;
}

View File

@ -1,26 +1,30 @@
// SeedHammer v1 composer — DOM shell + WASM bridge.
//
// The Go runtime (built from cmd/composer) exports a small surface:
// Go exports (built from cmd/composer):
//
// composerVersion() -> string semver-ish identifier
// composerPlateTypes() -> {id, name, w_mm, h_mm}[]
// composerEncodeText(plateType:number, lines:string[]) -> Uint8Array
//
// More exports (preview SVG, QR rendering, SVG mode) will follow.
// composerVersion() -> string
// composerPlateTypes() -> {id, name, w_mm, h_mm}[]
// composerEncodeText(plateType, lines) -> Uint8Array (SH1E)
// composerPreviewText(plateType, lines) -> string (inline SVG)
const els = {
status: document.getElementById("status"),
plateTypes: document.getElementById("plate-types"),
lines: document.getElementById("lines"),
output: document.getElementById("output"),
preview: document.getElementById("preview"),
size: document.getElementById("size-meter"),
btnGenerate: document.getElementById("btn-generate"),
output: document.getElementById("output"),
btnBytes: document.getElementById("btn-bytes"),
};
const NUM_LINES = 12;
// Build inputs for the largest possible plate; hide rows that don't fit
// the currently-selected plate. Keeps user data when switching back.
const MAX_LINES_ANY_PLATE = 20;
let wasmReady = false;
let plateType = 0; // default SmallPlate
let plateType = 0;
let plateInfo = []; // populated from Go: [{id, name, w_mm, h_mm, max_lines}]
let visibleLines = MAX_LINES_ANY_PLATE;
function setStatus(text, error = false) {
els.status.textContent = text;
@ -28,6 +32,7 @@ function setStatus(text, error = false) {
}
function buildPlateChoices(types) {
plateInfo = types;
els.plateTypes.innerHTML = "";
for (const t of types) {
const id = `plate-${t.id}`;
@ -35,62 +40,132 @@ function buildPlateChoices(types) {
wrap.className = "plate-choice";
wrap.innerHTML = `
<input type="radio" name="plate-type" id="${id}" value="${t.id}" ${t.id === plateType ? "checked" : ""}>
<span><strong>${t.name}</strong> <small>${t.w_mm} × ${t.h_mm} mm</small></span>
<span><strong>${t.name}</strong> <small>${t.w_mm} × ${t.h_mm} mm ${t.max_lines} lines</small></span>
`;
wrap.querySelector("input").addEventListener("change", (e) => {
plateType = Number(e.target.value);
applyPlate();
refresh();
});
els.plateTypes.appendChild(wrap);
}
}
function applyPlate() {
const info = plateInfo[plateType];
visibleLines = Math.min(info?.max_lines ?? MAX_LINES_ANY_PLATE, MAX_LINES_ANY_PLATE);
const items = els.lines.querySelectorAll("li");
items.forEach((li, i) => {
li.style.display = i < visibleLines ? "" : "none";
});
}
function buildLineInputs() {
els.lines.innerHTML = "";
for (let i = 0; i < NUM_LINES; i++) {
for (let i = 0; i < MAX_LINES_ANY_PLATE; i++) {
const li = document.createElement("li");
const input = document.createElement("input");
input.type = "text";
input.maxLength = 26;
input.placeholder = i === 0 ? "First line, e.g. word1 word2 ..." : "";
input.placeholder = i === 0 ? "First line" : "";
input.autocomplete = "off";
input.spellcheck = false;
input.addEventListener("input", scheduleRefresh);
li.appendChild(input);
els.lines.appendChild(li);
}
applyPlate();
}
function readLines() {
return [...els.lines.querySelectorAll("input")]
.slice(0, visibleLines) // only what fits the current plate
.map((el) => el.value.toUpperCase().trim())
.filter((s) => s.length > 0);
}
function showBytes(bytes) {
els.size.textContent = `${bytes.length.toLocaleString("en-US")} B`;
els.output.hidden = false;
els.output.classList.remove("error");
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" ");
els.output.textContent = `SH1E envelope (${bytes.length} bytes):\n\n${hex}`;
let refreshTimer = null;
function scheduleRefresh() {
// Debounce keystrokes; 80ms feels live but doesn't thrash the WASM.
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(refresh, 80);
}
function showError(msg) {
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = msg;
function refresh() {
refreshTimer = null;
if (!wasmReady) return;
const lines = readLines();
// Empty: render an empty plate so the geometry still shows.
let bytes = null;
if (lines.length > 0) {
try {
bytes = globalThis.composerEncodeText(plateType, lines);
} catch (e) {
// Show the error in the size meter; preview still renders structurally.
els.size.textContent = `error: ${e?.message ?? e}`;
els.size.classList.add("error");
els.preview.innerHTML = globalThis.composerPreviewText(plateType, lines);
return;
}
}
let svg;
try {
svg = globalThis.composerPreviewText(plateType, lines);
} catch (e) {
els.preview.textContent = `preview error: ${e?.message ?? e}`;
return;
}
els.preview.innerHTML = svg;
els.size.classList.remove("error");
if (bytes) {
els.size.textContent = `${bytes.length.toLocaleString("en-US")} B`;
} else {
els.size.textContent = "— B";
}
}
function onGenerate() {
// hexDump formats bytes in a compact 8-bytes-per-row layout. Total ~33
// chars wide, fits comfortably inside the actions card on every viewport.
// 00: 53 48 31 45 01 4f 00 56 |SH1E.O.V|
// 08: 14 48 32 a3 01 02 02 82 |.H2.....|
function hexDump(bytes) {
const COLS = 8;
const out = [];
const offWidth = Math.max(2, bytes.length.toString(16).length);
for (let i = 0; i < bytes.length; i += COLS) {
const slice = Array.from(bytes.slice(i, i + COLS));
const offset = i.toString(16).padStart(offWidth, "0");
const hex = slice
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ")
.padEnd(COLS * 3 - 1, " ");
const ascii = slice
.map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
.join("")
.padEnd(COLS, " ");
out.push(`${offset}: ${hex} |${ascii}|`);
}
return out.join("\n");
}
function showBytes() {
if (!wasmReady) return;
const lines = readLines();
if (lines.length === 0) {
showError("Enter at least one line of text.");
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = "Enter at least one line of text first.";
return;
}
try {
const bytes = globalThis.composerEncodeText(plateType, lines);
showBytes(bytes);
els.output.hidden = false;
els.output.classList.remove("error");
els.output.textContent = `SH1E envelope — ${bytes.length} bytes\n\n${hexDump(bytes)}`;
} catch (e) {
showError(`Encode failed: ${e?.message ?? e}`);
els.output.hidden = false;
els.output.classList.add("error");
els.output.textContent = `Encode failed: ${e?.message ?? e}`;
}
}
@ -103,9 +178,7 @@ async function loadWasm() {
return;
}
const result = await WebAssembly.instantiateStreaming(resp, go.importObject);
go.run(result.instance); // doesn't block — fires off the runtime goroutine
// The Go main() blocks on a `select {}`, so the runtime keeps exports alive.
// Spin until at least composerVersion is defined.
go.run(result.instance);
for (let i = 0; i < 100; i++) {
if (typeof globalThis.composerVersion === "function") break;
await new Promise((r) => setTimeout(r, 20));
@ -115,15 +188,15 @@ async function loadWasm() {
return;
}
const v = globalThis.composerVersion();
const types = globalThis.composerPlateTypes();
buildPlateChoices(types);
buildPlateChoices(globalThis.composerPlateTypes());
buildLineInputs();
wasmReady = true;
els.btnGenerate.disabled = false;
els.btnBytes.disabled = false;
setStatus(`Ready — ${v}`);
refresh(); // initial empty-plate render
}
els.btnGenerate.addEventListener("click", onGenerate);
els.btnBytes.addEventListener("click", showBytes);
loadWasm().catch((e) => {
setStatus(`Boot failed: ${e?.message ?? e}`, true);

View File

@ -29,6 +29,21 @@
<div role="radiogroup" aria-label="Plate type" id="plate-types"></div>
</section>
<section class="card glass preview-card" aria-label="Plate preview">
<div class="preview-wrap">
<div id="preview" class="preview" aria-label="Plate preview"></div>
<div class="preview-meta">
<span id="size-meter">— B</span>
<span id="size-cap">/ 65535 B</span>
</div>
</div>
<p class="preview-note">
Visual preview only — text shown in your system monospace, not the
Comfortaa glyphs the engraver will actually punch. Pixel-faithful
preview lands in a follow-up commit.
</p>
</section>
<section class="card glass editor-card" aria-label="Block text editor">
<div class="editor-head">
<h2>Block text</h2>
@ -37,25 +52,16 @@
<ol id="lines" class="lines"></ol>
</section>
<section class="card glass preview-card" aria-label="SH1E output">
<div class="preview-wrap">
<div class="preview-meta">
<span id="size-meter">0 B</span>
<span id="size-cap">/ 65535 B</span>
</div>
<pre id="output" class="error" hidden></pre>
</div>
</section>
<section class="card glass actions-card" aria-label="Actions">
<button id="btn-generate" class="btn primary" disabled>
<span class="btn-label">Generate SH1E</span>
<span class="btn-sub">test build — bytes shown above</span>
<section class="card glass actions-card" aria-label="Output">
<button id="btn-bytes" class="btn" disabled>
<span class="btn-label">Show SH1E bytes</span>
<span class="btn-sub">debug — hex dump below</span>
</button>
<pre id="output" class="error" hidden></pre>
</section>
<footer class="card glass footer-card">
<p>This is an early Phase&nbsp;1 build. Today it proves the Go-to-WASM pipeline works and the SH1E encoder produces canonical bytes. Live preview, QR display, and the SeedSigner sim land in following commits. See <a href="https://github.com/mineracks/seedhammer-v1-companion">the repo</a> for the roadmap.</p>
<p>This is an early Phase&nbsp;1 build. SH1E encoder + live layout preview both wired in. Next: pixel-faithful Comfortaa rasterisation, QR display, SVG path mode, the SeedSigner sim. See <a href="https://github.com/mineracks/seedhammer-v1-companion">the repo</a>.</p>
</footer>
</main>