#1 QR display — composer becomes end-to-end useful

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>
This commit is contained in:
mineracks 2026-05-28 20:10:47 +10:00
parent 14486455c6
commit 2755a685d1
4 changed files with 203 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import (
"strings"
"syscall/js"
"github.com/kortschak/qr"
"github.com/mineracks/seedhammer-v1-companion/engrave/wire/sh1e"
)
@ -26,6 +27,7 @@ func main() {
js.Global().Set("composerPlateTypes", js.FuncOf(exportPlateTypes))
js.Global().Set("composerEncodeText", js.FuncOf(exportEncodeText))
js.Global().Set("composerPreviewText", js.FuncOf(exportPreviewText))
js.Global().Set("composerQR", js.FuncOf(exportQR))
// Block forever so the Go runtime keeps the exported funcs alive.
select {}
}
@ -241,6 +243,92 @@ func exportPreviewText(this js.Value, args []js.Value) any {
return sb.String()
}
// exportQR: composerQR(plateType:number, lines:string[]) -> {svg:string, modules:number, bytes:number}
//
// Encodes the design as SH1E, then encodes those bytes as a QR code at
// medium error-correction level (15% damage tolerance — a sensible balance
// for a phone-screen-to-camera handoff). The returned SVG is a full QR
// matrix, drawn as one rect per dark module against a white background.
//
// JS uses this to show a scannable QR to the SeedHammer v1 camera.
func exportQR(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,
})
}
payload, err := sh1e.Encode(sh1e.Design{
PlateType: plateType,
TextBlocks: blocks,
})
if err != nil {
return jsError(err)
}
code, err := qr.Encode(string(payload), qr.M)
if err != nil {
return jsError(fmt.Errorf("qr encode: %w", err))
}
return js.ValueOf(map[string]any{
"svg": qrSVG(code),
"modules": code.Size,
"bytes": len(payload),
})
}
// qrSVG renders a kortschak/qr Code as an inline SVG string. Uses
// viewBox = module-count so callers can size by CSS without distortion.
// One <rect> per dark module, against a white background.
func qrSVG(code *qr.Code) string {
dim := code.Size
// Quiet zone: per spec, 4 modules of white margin around the QR. We
// embed it into the viewBox so the consumer doesn't have to add padding.
const quiet = 4
total := dim + 2*quiet
var sb strings.Builder
fmt.Fprintf(&sb,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`,
total, total,
)
// White background covers the whole canvas including the quiet zone.
fmt.Fprintf(&sb, `<rect width="%d" height="%d" fill="#fff"/>`, total, total)
// Black modules. Coalesce consecutive horizontal runs into a single rect
// to shrink the SVG payload — typical QR has ~50% dark modules; per-cell
// rects would be ~half the matrix count.
for y := 0; y < dim; y++ {
x := 0
for x < dim {
if !code.Black(x, y) {
x++
continue
}
runStart := x
for x < dim && code.Black(x, y) {
x++
}
runLen := x - runStart
fmt.Fprintf(&sb,
`<rect x="%d" y="%d" width="%d" height="1" fill="#000"/>`,
runStart+quiet, y+quiet, runLen,
)
}
}
sb.WriteString(`</svg>`)
return sb.String()
}
// ─── Helpers ─────────────────────────────────────────────────────────────
func readArgs(args []js.Value) (sh1e.PlateType, []string, error) {

View File

@ -367,3 +367,57 @@ main > * { min-width: 0; }
white-space: pre-wrap;
font-family: inherit;
}
/* QR fullscreen overlay */
#qr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 24px;
backdrop-filter: blur(8px);
}
#qr-overlay[hidden] { display: none; }
.qr-overlay-card {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: min(92vmin, 600px);
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
align-items: stretch;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
}
#qr-canvas {
width: 100%;
aspect-ratio: 1 / 1;
}
#qr-canvas svg {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.qr-overlay-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: #333;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.btn.small {
padding: 8px 14px;
font-size: 13px;
background: #1a1a1a;
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
}

View File

@ -15,6 +15,11 @@ const els = {
size: document.getElementById("size-meter"),
output: document.getElementById("output"),
btnBytes: document.getElementById("btn-bytes"),
btnQR: document.getElementById("btn-qr"),
qrOverlay: document.getElementById("qr-overlay"),
qrCanvas: document.getElementById("qr-canvas"),
qrInfo: document.getElementById("qr-info"),
qrClose: document.getElementById("qr-close"),
};
// Build inputs for the largest possible plate; hide rows that don't fit
@ -192,11 +197,53 @@ async function loadWasm() {
buildLineInputs();
wasmReady = true;
els.btnBytes.disabled = false;
els.btnQR.disabled = false;
setStatus(`Ready — ${v}`);
refresh(); // initial empty-plate render
}
function showQR() {
if (!wasmReady) return;
const lines = readLines();
if (lines.length === 0) {
setStatus("Enter at least one line first", true);
return;
}
try {
const result = globalThis.composerQR(plateType, lines);
els.qrCanvas.innerHTML = result.svg;
els.qrInfo.textContent = `${result.bytes} B SH1E → QR ${result.modules}×${result.modules}`;
els.qrOverlay.hidden = false;
els.qrOverlay.setAttribute("aria-hidden", "false");
setStatus(`Ready — v${composerVersionString()}`);
} catch (e) {
setStatus(`QR encode failed: ${e?.message ?? e}`, true);
}
}
function hideQR() {
els.qrOverlay.hidden = true;
els.qrOverlay.setAttribute("aria-hidden", "true");
els.qrCanvas.innerHTML = "";
}
let _ver;
function composerVersionString() {
if (!_ver) _ver = globalThis.composerVersion().replace(/^v/, "");
return _ver;
}
els.btnBytes.addEventListener("click", showBytes);
els.btnQR.addEventListener("click", showQR);
els.qrClose.addEventListener("click", hideQR);
els.qrOverlay.addEventListener("click", (e) => {
// Click outside the inner card dismisses; click inside (e.g. on the QR
// itself) does nothing.
if (e.target === els.qrOverlay) hideQR();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !els.qrOverlay.hidden) hideQR();
});
loadWasm().catch((e) => {
setStatus(`Boot failed: ${e?.message ?? e}`, true);

View File

@ -53,6 +53,10 @@
</section>
<section class="card glass actions-card" aria-label="Output">
<button id="btn-qr" class="btn primary" disabled>
<span class="btn-label">Show QR</span>
<span class="btn-sub">hold up to the SeedHammer v1 camera</span>
</button>
<button id="btn-bytes" class="btn" disabled>
<span class="btn-label">Show SH1E bytes</span>
<span class="btn-sub">debug — hex dump below</span>
@ -60,6 +64,16 @@
<pre id="output" class="error" hidden></pre>
</section>
<div id="qr-overlay" hidden aria-hidden="true" role="dialog">
<div class="qr-overlay-card">
<div id="qr-canvas"></div>
<div class="qr-overlay-meta">
<span id="qr-info"></span>
<button id="qr-close" class="btn small">Close</button>
</div>
</div>
</div>
<footer class="card glass footer-card">
<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>