Design Library

Scanner HUD - lock ring and insight card

coin-scanner-hud/ ยท 4 files

componentskill: design-taste-frontendcanvascameradarkinteractiveevidence

Brief

The prompt that produced this. Re-run it to generate more in the same taste.

Aesthetic
instrument overlay - dark viewfinder, thin luminous ring, monospaced readout, state-led, never occluding the subject
Reference
coin-detect live capture HUD
Intent
narrate a recognition attempt while it is happening, so the person holding the camera knows whether to hold still, move closer, or that it already has what it needs
Guardrails
always keep the subject visible - the card yields to the ring, never covers it, name the phase in words, not only by ring colour, show confidence beside any identification, hold the last good reading rather than flickering to empty
never claim an identification while still acquiring, cover the centre of the frame, animate the ring faster than the detector actually updates, report confidence without a subject

Install

pnpm dlx shadcn@latest add http://localhost:3040/r/coin-scanner-hud.json

Source

Meta block and library type import stripped - this is what pastes cleanly elsewhere.

'use client'

import { useEffect, useState } from 'react'
import { CoinInsightCard } from './insight-card'
import type { CircleOverlayPx, PreviewInsight, TrackingPhase } from './lib'

const STAGE_W = 720
const STAGE_H = 480

/** Where the coin sits in the frame. Fixed: there is no detector here to move it. */
const ANCHOR: CircleOverlayPx = { cx: STAGE_W * 0.42, cy: STAGE_H * 0.5, r: 96 }

const INSIGHT: PreviewInsight = {
  title: '20 Francs Marianne Coq',
  coin_name: '20 Francs Marianne Coq',
  year: '1907',
  metal: 'Gold, .900',
  catalog_ref: 'KM# 857',
  one_liner: 'Third Republic gold franc, Chaplain design, common date in this grade.',
  confidence: 0.87,
}

/** The phases a real scan walks through, in order, on a loop. */
const CYCLE: TrackingPhase[] = ['searching', 'acquiring', 'locked', 'identifying', 'identified']

const RING: Record<TrackingPhase, string> = {
  idle: 'rgba(161,161,170,0.35)',
  searching: 'rgba(161,161,170,0.6)',
  acquiring: 'rgba(250,204,21,0.85)',
  locked: 'rgba(56,189,248,0.95)',
  identifying: 'rgba(56,189,248,0.95)',
  identified: 'rgba(74,222,128,0.95)',
}

export default function CoinScannerHud() {
  const [step, setStep] = useState(0)
  const phase = CYCLE[step % CYCLE.length]

  useEffect(() => {
    const id = setInterval(() => setStep((s) => s + 1), 1800)
    return () => clearInterval(id)
  }, [])

  return (
    <main className="grid min-h-[100dvh] place-items-center bg-zinc-950 p-6 text-zinc-100">
      <div className="w-full max-w-3xl">
        <div className="mb-3 flex items-center justify-between">
          <p className="font-mono text-[11px] text-zinc-500 uppercase tracking-widest">
            Live capture
          </p>
          <p
            aria-live="polite"
            className="font-mono text-[11px] text-zinc-300 uppercase tracking-widest"
          >
            {phase}
          </p>
        </div>

        <div
          className="relative overflow-hidden rounded-lg bg-[radial-gradient(120%_100%_at_40%_45%,#2b2b31_0%,#1a1a1e_55%,#101013_100%)]"
          style={{ width: '100%', aspectRatio: `${STAGE_W} / ${STAGE_H}` }}
        >
          {/* Stand-in for the camera feed: the design is the HUD, and a real
              viewfinder would need a stream this preview cannot ask for. */}
          <div
            aria-hidden
            className="absolute rounded-full bg-[radial-gradient(circle_at_38%_32%,#d9c98f_0%,#a8912f_45%,#6b5a18_100%)]"
            style={{
              left: `${(ANCHOR.cx / STAGE_W) * 100}%`,
              top: `${(ANCHOR.cy / STAGE_H) * 100}%`,
              width: `${((ANCHOR.r * 2) / STAGE_W) * 100}%`,
              aspectRatio: '1',
              transform: 'translate(-50%, -50%)',
            }}
          />

          <div
            aria-hidden
            className="absolute rounded-full transition-[border-color,transform] duration-300"
            style={{
              left: `${(ANCHOR.cx / STAGE_W) * 100}%`,
              top: `${(ANCHOR.cy / STAGE_H) * 100}%`,
              width: `${((ANCHOR.r * 2.25) / STAGE_W) * 100}%`,
              aspectRatio: '1',
              transform: `translate(-50%, -50%) scale(${phase === 'acquiring' ? 1.04 : 1})`,
              border: `2px solid ${RING[phase]}`,
            }}
          />

          <CoinInsightCard
            anchor={ANCHOR}
            stageW={STAGE_W}
            stageH={STAGE_H}
            phase={phase}
            insight={phase === 'identified' ? INSIGHT : null}
            disabled={false}
          />
        </div>
      </div>
    </main>
  )
}