fix(capture): align masked tape calibration with #99

This commit is contained in:
Ty
2026-09-24 16:49:17 -07:00
parent 4ca2ef57a7
commit d49ff2fab5
18 changed files with 757 additions and 181 deletions
+315 -143
View File
@@ -1,13 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Capture, TapeLine } from './capture.types';
import type { Capture, PhotoTransform, TapeLine } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
import {
applyApprovedMask,
CALIBRATION_FRAME,
cropTickPositions,
DEFAULT_PHOTO_TRANSFORM,
pinchPhotoTransform,
photoFitScale,
photoToStage,
stageToPhoto,
tapeLineFromStageTicks,
} from './tapeCalibration';
export let state: Extract<Capture, { k: 'axis' }>;
export let setTapeLine: (tapeLine: TapeLine) => void;
export let setTapeLine: (tapeLine: TapeLine, transform: PhotoTransform) => void;
export let approveAxis: () => void;
export let back: () => void;
export let cropFill = 90;
export let setCropFill: (pct: number) => void = () => {};
let stage: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
@@ -16,157 +28,292 @@
let sourceH = state.source.heightPx;
let stageW = 0;
let stageH = 0;
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
let angleDeg = 0;
let zoom = 1;
let panX = 0;
let panY = 0;
let dragging: 'start' | 'end' | 'pan' | null = null;
let selected: 'start' | 'end' = 'start';
let lastPointer: PhotoPoint = [0, 0];
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? DEFAULT_PHOTO_TRANSFORM) };
let tickA = photoToStage(state.axis.tapeLine.start, sourceW, sourceH, transform)[1];
let tickB = photoToStage(state.axis.tapeLine.end, sourceW, sourceH, transform)[1];
let focusedTick: 'A' | 'B' = 'A';
let drag: 'tickA' | 'tickB' | 'pan' | 'tilt' | 'pinch' | null = null;
let lastPointer: [number, number] = [0, 0];
let pointerStart: [number, number] = [0, 0];
let transformStart = 0;
let pointers = new Map<number, [number, number]>();
let pinchStart: { distance: number; anchor: [number, number] } | null = null;
let panTapCandidate = false;
let previousTap: { time: number; point: [number, number] } | null = null;
let lastCropFill = cropFill;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
const frame = CALIBRATION_FRAME;
const lineX = frame.width / 2;
const tiltX = frame.width / 2;
const tiltY = frame.height - 26;
const tickHitRadius = 38; // 76 logical px keeps the drag target above 44px on a 320px viewport.
function fittedScale(): number {
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
return Math.min(stageW / sourceW, stageH / sourceH);
function stagePoint(event: PointerEvent | WheelEvent): [number, number] {
const rect = canvas!.getBoundingClientRect();
return [
((event.clientX - rect.left) / rect.width) * frame.width,
((event.clientY - rect.top) / rect.height) * frame.height,
];
}
function view(): PhotoViewTransform {
return {
width: sourceW,
height: sourceH,
centerX: stageW / 2,
centerY: stageH / 2,
scale: fittedScale(),
zoom,
angleDeg,
panX,
panY,
};
}
function draw(): void {
function redraw(): void {
if (!canvas || !stageW || !stageH) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(stageW * dpr);
canvas.height = Math.round(stageH * dpr);
canvas.style.width = `${stageW}px`;
canvas.style.height = `${stageH}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.setTransform(dpr * stageW / frame.width, 0, 0, dpr * stageH / frame.height, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, stageW, stageH);
const transform = view();
if (bitmap && transform.scale > 0) {
ctx.fillRect(0, 0, frame.width, frame.height);
if (bitmap && sourceW > 0 && sourceH > 0) {
const scale = photoFitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
ctx.save();
ctx.translate(transform.centerX + panX, transform.centerY + panY);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.scale(transform.scale * zoom, transform.scale * zoom);
ctx.translate(lineX + transform.panX, frame.height / 2 + transform.panY);
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
ctx.scale(scale, scale);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
ctx.restore();
}
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.strokeStyle = 'rgba(255,255,255,.92)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
if (which === selected) {
ctx.strokeStyle = '#ffd600';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
for (const [y, id] of [[tickA, 'A'], [tickB, 'B']] as const) {
ctx.strokeStyle = 'rgba(255,255,255,.95)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
ctx.beginPath(); ctx.arc(lineX, y, 4, 0, Math.PI * 2);
ctx.fillStyle = focusedTick === id ? '#ff3d71' : '#000';
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
}
ctx.beginPath(); ctx.arc(tiltX, tiltY, 17, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fill();
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = '#00e5ff'; ctx.font = '15px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↻', tiltX, tiltY + 2);
ctx.fillStyle = '#aaa'; ctx.font = '10.5px system-ui'; ctx.textAlign = 'left';
ctx.fillText('drag to tilt the slab', tiltX + 24, tiltY + 4);
ctx.restore();
}
function pointerPosition(event: PointerEvent): PhotoPoint {
const rect = canvas!.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top];
function photoPoint(point: [number, number]): [number, number] {
return stageToPhoto(point, sourceW, sourceH, transform);
}
function syncCalibration(): void {
const tapeLine = tapeLineFromStageTicks(tickA, tickB, sourceW, sourceH, transform);
setTapeLine(tapeLine, { ...transform });
}
function moveTickA(y: number): void {
tickA = Math.max(20, Math.min(tickB - 40, y));
}
function moveTickB(y: number): void {
tickB = Math.max(tickA + 40, Math.min(frame.height - 20, y));
}
function queuePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
syncCalibration();
}, 250);
}
function flushCalibration(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = null;
syncCalibration();
}
function approveCalibration(): void {
flushCalibration();
approveAxis();
}
function backToMask(): void {
flushCalibration();
back();
}
function updatePointer(event: PointerEvent): [number, number] {
const point = stagePoint(event);
pointers.set(event.pointerId, point);
return point;
}
function onPointerDown(event: PointerEvent): void {
const point = pointerPosition(event);
const transform = view();
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
if (dragging !== 'pan') selected = dragging;
lastPointer = point;
const point = updatePointer(event);
if (pointers.size > 1) {
const [a, b] = [...pointers.values()];
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
pinchStart = {
distance: Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1])),
anchor: photoPoint(center),
};
drag = 'pinch';
panTapCandidate = false;
canvas?.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
const distA = Math.hypot(point[0] - lineX, point[1] - tickA);
const distB = Math.hypot(point[0] - lineX, point[1] - tickB);
const coreA = distA <= 12;
const coreB = distB <= 12;
if (coreA || coreB) {
focusedTick = distA <= distB ? 'A' : 'B';
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
panTapCandidate = false;
} else if (Math.hypot(point[0] - tiltX, point[1] - tiltY) < 18) {
drag = 'tilt';
pointerStart = point;
transformStart = transform.tiltDeg;
panTapCandidate = false;
} else if (distA <= tickHitRadius || distB <= tickHitRadius) {
focusedTick = distA <= distB ? 'A' : 'B';
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
panTapCandidate = false;
} else {
drag = 'pan';
lastPointer = point;
pointerStart = point;
panTapCandidate = true;
}
canvas?.setPointerCapture(event.pointerId);
redraw();
event.preventDefault();
}
function onPointerMove(event: PointerEvent): void {
if (!dragging) return;
const point = pointerPosition(event);
if (dragging === 'pan') {
panX += point[0] - lastPointer[0];
panY += point[1] - lastPointer[1];
} else {
const sourcePoint = screenToPhoto(point, view());
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
if (dragging === 'start') tickA = sourcePoint;
else tickB = sourcePoint;
if (!drag) return;
const point = updatePointer(event);
if (drag === 'pinch' && pinchStart && pointers.size > 1) {
const [a, b] = [...pointers.values()];
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const distance = Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1]));
transform = pinchPhotoTransform(
transform, pinchStart.anchor, center, distance / pinchStart.distance, sourceW, sourceH,
);
pinchStart.distance = distance;
} else if (drag === 'tickA') moveTickA(point[1]);
else if (drag === 'tickB') moveTickB(point[1]);
else if (drag === 'tilt') transform.tiltDeg = transformStart + (point[0] - pointerStart[0]) * 0.6;
else {
if (drag === 'pan' && Math.hypot(point[0] - pointerStart[0], point[1] - pointerStart[1]) > 4) panTapCandidate = false;
transform.panX += point[0] - lastPointer[0];
transform.panY += point[1] - lastPointer[1];
lastPointer = point;
}
lastPointer = point;
draw();
redraw();
queuePersist();
}
function onPointerUp(event: PointerEvent): void {
if (dragging === 'start' || dragging === 'end') {
setTapeLine({ start: [...tickA], end: [...tickB] });
pointers.delete(event.pointerId);
if (!drag) return;
try { canvas?.releasePointerCapture(event.pointerId); } catch { /* already released */ }
if (drag === 'pinch' && pointers.size > 0) {
drag = 'pan';
pinchStart = null;
panTapCandidate = false;
lastPointer = [...pointers.values()][0];
queuePersist();
redraw();
return;
}
dragging = null;
canvas?.releasePointerCapture(event.pointerId);
const wasSlabTap = drag === 'pan' && panTapCandidate;
const tapPoint = stagePoint(event);
drag = null;
pinchStart = null;
panTapCandidate = false;
flushCalibration();
if (wasSlabTap) {
const now = performance.now();
if (previousTap && now - previousTap.time < 350
&& Math.hypot(tapPoint[0] - previousTap.point[0], tapPoint[1] - previousTap.point[1]) < 12) {
previousTap = null;
fitSlab();
} else previousTap = { time: now, point: tapPoint };
}
redraw();
}
function zoomAt(x: number, y: number, factor: number): void {
const before = screenToPhoto([x, y], view());
zoom = Math.max(0.5, Math.min(5, zoom * factor));
const after = photoToScreen(before, view());
panX += x - after[0];
panY += y - after[1];
draw();
function zoomAt(point: [number, number], factor: number): void {
const anchor = photoPoint(point);
transform.zoom = Math.max(0.5, Math.min(3, transform.zoom * factor));
const after = photoToStage(anchor, sourceW, sourceH, transform);
transform.panX += point[0] - after[0];
transform.panY += point[1] - after[1];
redraw();
queuePersist();
}
function onWheel(event: WheelEvent): void {
event.preventDefault();
const rect = canvas!.getBoundingClientRect();
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
zoomAt(stagePoint(event), Math.exp(-event.deltaY * 0.0015));
}
function resetView(): void {
angleDeg = 0;
zoom = 1;
panX = 0;
panY = 0;
draw();
function fitSlab(): void {
transform = { ...transform, zoom: 1, panX: 0, panY: 0 };
redraw();
syncCalibration();
}
function changeTilt(degrees: number): void {
transform = { ...transform, tiltDeg: transform.tiltDeg + degrees };
redraw();
syncCalibration();
}
function changeZoom(factor: number): void {
zoomAt([frame.width / 2, frame.height / 2], factor);
}
function onKeyDown(event: KeyboardEvent): void {
const fine = event.shiftKey ? 0.1 : 1;
if (event.key === 'ArrowLeft') changeTilt(-fine);
else if (event.key === 'ArrowRight') changeTilt(fine);
else if (event.key === 'ArrowUp') {
if (focusedTick === 'A') moveTickA(tickA - fine);
else moveTickB(tickB - fine);
syncCalibration(); redraw();
} else if (event.key === 'ArrowDown') {
if (focusedTick === 'A') moveTickA(tickA + fine);
else moveTickB(tickB + fine);
syncCalibration(); redraw();
} else if (event.key === '+' || event.key === '=') changeZoom(1.1);
else if (event.key === '-' || event.key === '_') changeZoom(1 / 1.1);
else return;
event.preventDefault();
}
function changeCropFill(event: Event): void {
const pct = Number((event.currentTarget as HTMLInputElement).value);
setCropFill(pct);
[tickA, tickB] = cropTickPositions(pct);
syncCalibration();
redraw();
}
$: if (cropFill !== lastCropFill) {
lastCropFill = cropFill;
[tickA, tickB] = cropTickPositions(cropFill);
syncCalibration();
redraw();
}
onMount(() => {
@@ -176,76 +323,101 @@
if (!entry) return;
stageW = entry.contentRect.width;
stageH = entry.contentRect.height;
draw();
redraw();
});
if (stage) resize.observe(stage);
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
if (!alive) {
void decodeOrientedBitmap(state.source.blob).then(async (decoded) => {
if (!alive) { decoded.close(); return; }
sourceW = decoded.width;
sourceH = decoded.height;
if (state.approvedMask.bitset.length !== sourceW * sourceH) {
decoded.close();
return;
}
bitmap = decoded;
sourceW = decoded.width;
sourceH = decoded.height;
draw();
});
const maskedCanvas = document.createElement('canvas');
maskedCanvas.width = sourceW; maskedCanvas.height = sourceH;
const maskedCtx = maskedCanvas.getContext('2d');
if (!maskedCtx) { decoded.close(); return; }
maskedCtx.drawImage(decoded, 0, 0);
const pixels = maskedCtx.getImageData(0, 0, sourceW, sourceH);
pixels.data.set(applyApprovedMask(pixels.data, state.approvedMask.bitset));
maskedCtx.putImageData(pixels, 0, 0);
const maskedBitmap = await createImageBitmap(maskedCanvas);
decoded.close();
if (!alive) { maskedBitmap.close(); return; }
bitmap = maskedBitmap;
redraw();
}).catch(() => {});
return () => {
alive = false;
resize.disconnect();
bitmap?.close();
bitmap = null;
if (persistTimer) clearTimeout(persistTimer);
bitmap?.close(); bitmap = null;
};
});
</script>
<section class="panel">
<h1>Tape-line calibration</h1>
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<div class="stage" bind:this={stage}>
<canvas
bind:this={canvas}
aria-label="Calibration photo with draggable tape endpoint ticks"
tabindex="0"
aria-label="Fixed tape line over the masked slab. Drag the slab to pan, use the tilt control, and drag ticks along the line."
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel|nonpassive={onWheel}
on:keydown={onKeyDown}
></canvas>
</div>
<p class="hint">The black line is fixed in the middle of the frame. Move the slab instead: drag to pan, wheel or pinch to zoom, and use the rotate handle or arrow keys to tilt until the slab's long axis lies on the line.</p>
<div class="control">
<label for="photo-angle">Rotate photo</label>
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
<span class="value">{angleDeg.toFixed(1)}°</span>
<label for="photo-angle">Tilt the slab</label>
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => changeTilt(-1)}>−</button>
<input id="photo-angle" type="range" min="0" max="359" step="1" value={((transform.tiltDeg % 360) + 360) % 360}
on:input={(event) => { transform.tiltDeg = Number(event.currentTarget.value); redraw(); syncCalibration(); }} />
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => changeTilt(1)}>+</button>
<span class="value">{((transform.tiltDeg % 360) + 360) % 360}°</span>
</div>
<div class="control">
<label for="photo-zoom">Zoom</label>
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
<span class="value">{Math.round(zoom * 100)}%</span>
<button type="button" class="minor" on:click={resetView}>Reset view</button>
<label for="photo-zoom">Zoom the slab</label>
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => changeZoom(1 / 1.1)}>−</button>
<input id="photo-zoom" type="range" min="50" max="300" step="1" value={Math.round(transform.zoom * 100)}
on:input={(event) => { changeZoom(Number(event.currentTarget.value) / (transform.zoom * 100)); }} />
<span class="value">{Math.round(transform.zoom * 100)}%</span>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => changeZoom(1.1)}>+</button>
<button type="button" class="minor" on:click={fitSlab}>Fit</button>
</div>
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
<div class="control">
<label for="crop-fill">Crop fill %</label>
<input id="crop-fill" type="range" min="1" max="100" step="1" value={cropFill} on:input={changeCropFill} />
<span class="value">{cropFill}%</span>
</div>
<p class="hint">Arrow keys tilt the slab; Shift + arrow adjusts by 0.1°. The last tick you grabbed takes the up/down keys (1px, or 0.1px with Shift). Use +/− to zoom; double-click the slab to fit.</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
<button type="button" class="ghost" on:click={backToMask}>Back</button>
<button type="button" class="primary" on:click={approveCalibration}>Approve tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
canvas { display: block; touch-action: none; cursor: grab; }
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
.hint { color: #aaa; font-size: 0.85rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: min(100%, 420px); aspect-ratio: 3 / 4; margin: 0 auto; overflow: hidden; background: #161616; border: 1px solid #333; border-radius: 3px; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; outline-offset: 2px; }
.control { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
.control label { min-width: 86px; color: #bbb; font-size: 0.82rem; }
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.value { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.thumb, .minor { min-width: 44px; min-height: 44px; border: 1px solid #444; background: #1d1d1d; color: #eee; padding: 6px 9px; }
.thumb { border-radius: 50%; font-size: 1.2rem; flex: none; }
.minor { border-radius: 3px; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
@media (max-width: 360px) { .control { gap: 6px; } .control label { min-width: 64px; } }
</style>