mirror of
https://github.com/mineracks/seedhammer-v1-companion.git
synced 2026-06-26 22:01:05 +10:00
#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:
parent
14486455c6
commit
2755a685d1
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user