Image classifier built with tensorgrad, running in your browser on WebGPU. Drop in photos, fix wrong guesses, and add new classes, live.
---
format: typebulb/v1
name: Image Classifier
---
**code.tsx**
```tsx
import {
Module, Conv2d, Linear, LayerNorm, compileForward, compile, loadSafetensors,
add, mul, matmul, swapAxes, reshape, concat, narrow, split,
splitHeads, mergeHeads, softmax, gelu, crossEntropy, checkWebGPU, type Tensor,
} from 'tensorgrad'
import {
App, Component, div, h1, h3, p, span, button, img, label, input, inputText, svg, polyline, polygon,
} from 'domeleon'
// ============================================================================
// Model & feature extraction (pure logic)
// ============================================================================
// ViT-tiny dimensions (timm vit_tiny_patch16_224).
const D = 192
const H = 3
const HEADDIM = D / H
const DEPTH = 12
const PATCH = 16
const GRID = 14
const NPATCH = GRID * GRID
const NPOS = NPATCH + 1
const FFN = 4 * D
const IMG = GRID * PATCH
// timm augreg ViT preprocessing.
const MEAN = [0.5, 0.5, 0.5]
const STD = [0.5, 0.5, 0.5]
// Assets: weights on a Cloudflare R2 custom domain (cached in-browser); images
// from a public ImageNet sample set.
const R2 = 'https://assets.typebulb.com'
const WEIGHTS = `${R2}/weights/vit_tiny_patch16_224.augreg_in21k_ft_in1k.safetensors`
const IMG_BASE = 'https://raw.githubusercontent.com/EliSchwartz/imagenet-sample-images/master/'
const HELDOUT = `${IMG_BASE}n02123597_Siamese_cat.JPEG` // not in the seed set — the launch image, to show generalization
const PALETTE = ['#e8833a', '#3a7de8', '#34a853', '#a142f4', '#e0457b', '#00897b', '#f4b400', '#5f6368']
const SEED = [
['cat', 'n02123045_tabby.JPEG'], ['cat', 'n02123159_tiger_cat.JPEG'], ['cat', 'n02123394_Persian_cat.JPEG'], ['cat', 'n02124075_Egyptian_cat.JPEG'],
['dog', 'n02110185_Siberian_husky.JPEG'], ['dog', 'n02109961_Eskimo_dog.JPEG'], ['dog', 'n02099601_golden_retriever.JPEG'], ['dog', 'n02099712_Labrador_retriever.JPEG'],
['car', 'n02814533_beach_wagon.JPEG'], ['car', 'n03100240_convertible.JPEG'], ['car', 'n04285008_sports_car.JPEG'], ['car', 'n03594945_jeep.JPEG'],
].map(([label, file]) => ({ label: label!, file: file!, url: IMG_BASE + file }))
interface Example { feat: Float32Array; label: number; url: string }
interface Shot { url: string; feat: Float32Array | null }
// First load fetches from R2; reloads/revisits hit CacheStorage.
async function cachedFetch(url: string): Promise<ArrayBuffer> {
const cache = await caches.open('tensorgrad-assets')
let res = await cache.match(url)
if (!res) {
res = await fetch(url)
// Best-effort: a failed/partial cache write shouldn't break the load.
if (res.ok) {
try { await cache.put(url, res.clone()) } catch { /* caching is an optimization */ }
}
}
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return res.arrayBuffer()
}
function loadImage(src: string, cors = false): Promise<HTMLImageElement> {
const image = new Image()
if (cors) image.crossOrigin = 'anonymous' // cross-origin sources must be CORS-loaded or the canvas taints
return new Promise((res, rej) => {
image.onload = () => res(image)
image.onerror = () => rej(new Error('image load failed'))
image.src = src
})
}
// Center-crop to IMG×IMG, normalize, and lay out as CHW for the backbone.
function imageTensor(image: HTMLImageElement): Float32Array {
const canvas = document.createElement('canvas')
canvas.width = IMG
canvas.height = IMG
const ctx = canvas.getContext('2d')!
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
const side = Math.min(w, h)
ctx.drawImage(image, (w - side) / 2, (h - side) / 2, side, side, 0, 0, IMG, IMG)
const px = ctx.getImageData(0, 0, IMG, IMG).data
const x = new Float32Array(3 * IMG * IMG)
for (let i = 0; i < IMG * IMG; i++) {
for (let ch = 0; ch < 3; ch++) {
x[ch * IMG * IMG + i] = (px[i * 4 + ch]! / 255 - MEAN[ch]!) / STD[ch]!
}
}
return x
}
function l2normalize(v: Float32Array): Float32Array {
let norm = 0
for (const x of v) norm += x * x
norm = Math.sqrt(norm) || 1
const out = new Float32Array(v.length)
for (let i = 0; i < v.length; i++) out[i] = v[i]! / norm
return out
}
// ---- ViT-tiny, features only (no classifier head) ----
class Block extends Module {
norm1 = new LayerNorm(D)
qkv = new Linear(D, 3 * D)
proj = new Linear(D, D)
norm2 = new LayerNorm(D)
fc1 = new Linear(D, FFN)
fc2 = new Linear(FFN, D)
}
class ViT extends Module {
patch = new Conv2d(3, D, PATCH, { stride: PATCH })
cls = this.param([1, 1, D])
pos = this.param([1, NPOS, D])
norm = new LayerNorm(D)
blocks: Block[]
constructor() {
super()
this.blocks = Array.from({ length: DEPTH }, () => new Block())
}
}
function block(b: Block, x: Tensor): Tensor {
const [q0, k0, v0] = split(b.qkv.fwd(b.norm1.fwd(x)), [D, D, D], -1)
const q = splitHeads(q0, H)
const k = splitHeads(k0, H)
const v = splitHeads(v0, H)
const scores = mul(matmul(q, swapAxes(k, -1, -2)), 1 / Math.sqrt(HEADDIM))
const attended = mergeHeads(matmul(softmax(scores, -1), v))
const x1 = add(x, b.proj.fwd(attended))
return add(x1, b.fc2.fwd(gelu(b.fc1.fwd(b.norm2.fwd(x1)))))
}
// Patchify -> prepend cls token -> add positions -> blocks -> the cls feature [1, D].
function embed(m: ViT, { x }: { x: Tensor }): Tensor {
let h = swapAxes(reshape(m.patch.fwd(x), [1, D, NPATCH]), 1, 2)
h = add(concat([m.cls, h], 1), m.pos)
for (const b of m.blocks) h = block(b, h)
return reshape(narrow(m.norm.fwd(h), 1, 0, 1), [1, D])
}
function transpose2d(s: Float32Array, rows: number, cols: number): Float32Array {
const out = new Float32Array(rows * cols)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) out[c * rows + r] = s[r * cols + c]!
}
return out
}
// Map the timm checkpoint keys onto this Module's param names, transposing every
// Linear ([out, in] -> [in, out]). uploadParams is strict, so this must cover
// every backbone param; head.* is intentionally omitted (features only).
function importViT(t: Record<string, Float32Array>, shapes: Record<string, number[]>): Record<string, Float32Array> {
const out: Record<string, Float32Array> = {}
const T = (n: string) => transpose2d(t[n]!, shapes[n]![0]!, shapes[n]![1]!)
out['patch.W'] = t['patch_embed.proj.weight']!
out['patch.b'] = t['patch_embed.proj.bias']!
out['cls'] = t['cls_token']!
out['pos'] = t['pos_embed']!
out['norm.g'] = t['norm.weight']!
out['norm.b'] = t['norm.bias']!
for (let i = 0; i < DEPTH; i++) {
const s = `blocks.${i}`
out[`${s}.norm1.g`] = t[`${s}.norm1.weight`]!
out[`${s}.norm1.b`] = t[`${s}.norm1.bias`]!
out[`${s}.qkv.W`] = T(`${s}.attn.qkv.weight`)
out[`${s}.qkv.b`] = t[`${s}.attn.qkv.bias`]!
out[`${s}.proj.W`] = T(`${s}.attn.proj.weight`)
out[`${s}.proj.b`] = t[`${s}.attn.proj.bias`]!
out[`${s}.norm2.g`] = t[`${s}.norm2.weight`]!
out[`${s}.norm2.b`] = t[`${s}.norm2.bias`]!
out[`${s}.fc1.W`] = T(`${s}.mlp.fc1.weight`)
out[`${s}.fc1.b`] = t[`${s}.mlp.fc1.bias`]!
out[`${s}.fc2.W`] = T(`${s}.mlp.fc2.weight`)
out[`${s}.fc2.b`] = t[`${s}.mlp.fc2.bias`]!
}
return out
}
// A one-layer head over the frozen 192-d features, trained with crossEntropy + Adam.
class Head extends Module {
fc: Linear
constructor(classes: number) {
super()
this.fc = new Linear(D, classes)
}
}
async function trainHead(train: Example[], classes: number) {
const N = train.length
const feats = new Float32Array(N * D)
train.forEach((e, i) => feats.set(e.feat, i * D))
const labels = Int32Array.from(train.map(e => e.label))
const head = await compile({
model: new Head(classes),
loss: (m: Head, { f, y }: { f: Tensor; y: Tensor }) => crossEntropy(m.fc.fwd(f), y),
inputs: { f: [N, D], y: { shape: [N], dtype: 'i32' } },
optimizer: { kind: 'adam', lr: 0.05 },
})
const losses: number[] = []
for (let step = 0; step < 200; step++) {
const r = await head.step({ f: feats.slice(), y: labels.slice() })
if (r.kind !== 'completed') break
losses.push(r.loss)
}
const infer = await head.attach({
forward: (m: Head, { f }: { f: Tensor }) => softmax(m.fc.fwd(f), -1),
inputs: { f: [1, D] },
})
return { head, infer, losses }
}
// ============================================================================
// Model — app state + the ML pipeline (a non-visual domeleon component)
// ============================================================================
class Model extends Component {
status = 'starting…'
ready = false // backbone loaded + head trained at least once
retraining = false
classes: string[] = ['cat', 'dog', 'car']
train: Example[] = []
losses: number[] = []
shot: Shot | null = null // the image currently classified in the stage
probs: Float32Array | null = null
#backbone: Awaited<ReturnType<typeof compileForward<ViT, { x: number[] }>>> | null = null
#trained: Awaited<ReturnType<typeof trainHead>> | null = null
// Snapshots of the initial state, so reset() starts over from cached features.
#seedClasses: string[] = []
#seedTrain: Example[] = []
#initialShot: { url: string; feat: Float32Array } | null = null
#started = false
override onAttached() {
if (this.#started) return
this.#started = true
this.start()
}
setStatus(s: string) {
this.status = s
tb.log(s)
this.update()
}
async start() {
const gpu = await checkWebGPU()
if (!gpu.ok) {
this.setStatus(gpu.message)
return
}
this.setStatus('Loading the model…')
const { tensors, shapes } = loadSafetensors(await cachedFetch(WEIGHTS))
this.#backbone = await compileForward({ model: new ViT(), forward: embed, inputs: { x: [1, 3, IMG, IMG] } })
await this.#backbone.uploadParams(importViT(tensors, shapes))
// Extract the seed features once, then snapshot them for reset().
for (let i = 0; i < SEED.length; i++) {
this.setStatus(`Extracting features ${i + 1}/${SEED.length}…`)
const feat = await this.embOf(await loadImage(SEED[i]!.url, true))
this.train.push({ feat, label: this.classes.indexOf(SEED[i]!.label), url: SEED[i]!.url })
this.update()
}
this.#seedClasses = [...this.classes]
this.#seedTrain = this.train.map(e => ({ ...e }))
this.setStatus('Training the head…')
await this.retrain()
await this.preselect()
tb.log('Ready.')
}
async embOf(image: HTMLImageElement): Promise<Float32Array> {
const r = await this.#backbone!.run({ x: imageTensor(image) })
if (r.kind !== 'completed') throw new Error(`embed run ${r.kind}`)
return l2normalize(r.output as Float32Array)
}
async retrain() {
this.retraining = true
this.update()
this.#trained?.head.destroy()
this.#trained = await trainHead(this.train, this.classes.length)
this.losses = this.#trained.losses
this.ready = true
this.retraining = false
this.status = ''
if (this.shot?.feat) await this.classify()
else this.update()
}
// Start over from the cached seed set — no refresh, no re-embedding.
async reset() {
this.classes = [...this.#seedClasses]
this.train = this.#seedTrain.map(e => ({ ...e }))
this.shot = this.#initialShot ? { ...this.#initialShot } : null
this.probs = null
await this.retrain()
}
async classify() {
if (!this.#trained || !this.shot?.feat) return
const r = await this.#trained.infer.run({ f: this.shot.feat })
if (r.kind !== 'completed') {
tb.log(`classify ${r.kind}`)
return
}
this.probs = r.output as Float32Array
this.update()
}
// Click a training thumbnail. Its feature is cached, so classify is instant —
// keep the previous bars on screen (they animate to the new values) rather than
// collapsing to the "reading" state, which would cause a height blink.
async selectShot(url: string, feat: Float32Array) {
this.shot = { url, feat }
this.update()
await this.classify()
}
// Drop / choose a file: show it at once, embed with the frozen backbone, classify.
async onPick(file: File) {
if (!this.ready) return
try {
const url = URL.createObjectURL(file)
const image = await loadImage(url)
this.shot = { url, feat: null }
this.probs = null
this.update()
const feat = await this.embOf(image)
if (this.shot) this.shot.feat = feat
await this.classify()
} catch (err) {
tb.log(`couldn't read image: ${(err as Error).message}`)
}
}
async teachInto(ci: number) {
if (!this.shot?.feat) return
this.train.push({ feat: this.shot.feat, label: ci, url: this.shot.url })
await this.retrain()
}
async teachNewClass(name: string) {
if (!this.shot?.feat) return
this.classes.push(name)
this.train.push({ feat: this.shot.feat, label: this.classes.length - 1, url: this.shot.url })
await this.retrain()
}
// Classify a held-out image (not in the seed set) on launch, to show real
// generalization; fall back to the first seed if it fails to load.
async preselect() {
try {
this.shot = { url: HELDOUT, feat: null }
this.update()
const feat = await this.embOf(await loadImage(HELDOUT, true))
if (this.shot) this.shot.feat = feat
this.#initialShot = { url: HELDOUT, feat }
await this.classify()
} catch {
const e = this.train[0]
if (e) {
this.#initialShot = { url: e.url, feat: e.feat }
await this.selectShot(e.url, e.feat)
}
}
}
}
// ============================================================================
// UI
// ============================================================================
const classColor = (i: number) => PALETTE[i % PALETTE.length]!
class Root extends Component {
model = new Model()
// View-local state for the inline "new class" editor.
dragOver = false
addingClass = false
newClassName = ''
#outsideHandler: ((e: MouseEvent) => void) | null = null
// --- delegating handlers (close the editor, then act on the model) ---
pick(file: File) {
this.cancelNewClass()
this.model.onPick(file)
}
select(url: string, feat: Float32Array) {
this.cancelNewClass()
this.model.selectShot(url, feat)
}
reset() {
this.cancelNewClass()
this.model.reset()
}
// --- inline "new class" editor ---
startNewClass() {
this.addingClass = true
this.newClassName = ''
this.update()
this.armOutsideClose()
}
cancelNewClass() {
this.disarmOutsideClose()
if (!this.addingClass && !this.newClassName) return
this.addingClass = false
this.newClassName = ''
this.update()
}
confirmNewClass() {
const name = this.newClassName.trim()
if (!name) return
this.disarmOutsideClose()
this.addingClass = false
this.newClassName = ''
this.model.teachNewClass(name)
}
armOutsideClose() {
this.disarmOutsideClose()
this.#outsideHandler = (e: MouseEvent) => {
if ((e.target as Element | null)?.closest('.newclass-form')) return // click inside — keep it open
this.cancelNewClass()
}
// Defer so the click that opened the editor doesn't immediately close it.
setTimeout(() => { if (this.#outsideHandler) document.addEventListener('click', this.#outsideHandler) }, 0)
}
disarmOutsideClose() {
if (!this.#outsideHandler) return
document.removeEventListener('click', this.#outsideHandler)
this.#outsideHandler = null
}
view() {
return div(
div({ class: 'header' },
h1('Image Classifier'),
this.chartSection(),
),
this.stage(),
this.trainingSet(),
this.about(),
)
}
// --- stage: the hero card (drop bar + classified image + readout) ---
stage() {
if (!this.model.ready) {
return div({ class: 'stage' }, this.loading())
}
return div({
class: 'stage',
onDragOver: (e: DragEvent) => { e.preventDefault(); if (!this.dragOver) { this.dragOver = true; this.update() } },
onDragLeave: () => { if (this.dragOver) { this.dragOver = false; this.update() } },
onDrop: (e: DragEvent) => {
e.preventDefault()
this.dragOver = false
this.update()
const f = e.dataTransfer?.files?.[0]
if (f) this.pick(f)
},
},
this.dropBar(),
this.model.shot ? this.result() : null,
)
}
loading() {
return div({ class: 'loading' },
p({ class: 'loading-lead' }, 'This pretrained vision model runs entirely in your browser, on your GPU.'),
div({ class: 'loading-status' }, span({ class: 'spinner' }), span(this.model.status || 'Warming up…')),
)
}
fileInput() {
return input({
type: 'file',
accept: 'image/*',
class: 'fileinput',
onChange: (e: Event) => {
const f = (e.target as HTMLInputElement).files?.[0]
if (f) this.pick(f)
},
})
}
dropBar() {
return label({ class: ['dropbar', this.dragOver ? 'over' : ''] },
span({ class: 'dropbar-plus' }, '+'),
span('Drop an image here, or click to choose'),
this.fileInput(),
)
}
result() {
return div({ class: 'result' },
img({ src: this.model.shot!.url, class: 'shot-img' }),
this.readout(),
)
}
readout() {
const { probs, classes } = this.model
if (!probs) {
return div({ class: 'readout' },
div({ class: 'readout-wait' }, span({ class: 'spinner' }), 'reading the image…'),
)
}
let pred = 0
for (let i = 1; i < probs.length; i++) if (probs[i]! > probs[pred]!) pred = i
return div({ class: 'readout' },
div({ class: 'verdict' },
span({ class: 'verdict-class', style: { color: classColor(pred) } }, classes[pred]!),
span({ class: 'verdict-pct' }, `${(probs[pred]! * 100).toFixed(0)}% confident`),
),
div({ class: 'bars' }, classes.map((name, ci) => this.bar(name, ci, probs[ci]!))),
this.teach(),
)
}
bar(name: string, ci: number, prob: number) {
return div({ class: 'bar' },
span({ class: 'barlab' }, name),
div({ class: 'track' },
div({ class: 'fill', style: { width: `${(prob * 100).toFixed(1)}%`, background: classColor(ci) } }),
),
span({ class: 'barpct' }, `${(prob * 100).toFixed(0)}%`),
)
}
teach() {
return div({ class: 'teach' },
span({ class: 'teach-lbl' }, 'Not quite? Teach it — add this image to:'),
div({ class: 'teach-btns' },
this.model.classes.map((name, ci) =>
button({
class: 'teach-btn',
style: { borderColor: classColor(ci), color: classColor(ci) },
onClick: () => this.model.teachInto(ci),
}, name)),
this.addingClass ? this.newClassEditor() : this.newClassButton(),
),
)
}
newClassButton() {
return button({ class: ['teach-btn', 'newclass'], onClick: () => this.startNewClass() }, '+ new class')
}
newClassEditor() {
return div({ class: 'newclass-form' },
inputText({
target: this,
prop: () => this.newClassName,
attrs: {
placeholder: 'New class name',
class: 'newclass-input',
onMounted: (el: Element) => (el as HTMLInputElement).focus(),
onKeyDown: (e: KeyboardEvent) => {
if (e.key === 'Enter') this.confirmNewClass()
else if (e.key === 'Escape') this.cancelNewClass()
},
},
}),
button({ class: 'teach-btn', onClick: () => this.confirmNewClass() }, 'Add'),
)
}
// --- training-loss chart ---
chartSection() {
const { losses, retraining } = this.model
if (!losses.length) return null
const first = losses[0]!
const last = losses.at(-1)!
return div({ class: 'chartrow' },
this.chart(),
div({ class: 'chart-label' },
span({ class: 'chart-label-title' }, 'training loss'),
span({ class: 'chart-label-num' }, retraining ? 'retraining…' : `${first.toFixed(2)} → ${last.toFixed(3)}`),
),
)
}
// Filled area curve, full-bleed. Loss starts high (top-left) and falls to the
// floor; the empty upper-right is where the label overlays.
chart() {
const losses = this.model.losses
if (losses.length < 2) return null
const W = 600
const Hh = 150
const topPad = 12
const max = Math.max(...losses)
const min = Math.min(...losses)
const rng = max - min || 1
const x = (i: number) => i / (losses.length - 1) * W
const y = (l: number) => topPad + (max - l) / rng * (Hh - topPad)
const line = losses.map((l, i) => `${x(i).toFixed(1)},${y(l).toFixed(1)}`).join(' ')
const area = `0,${Hh} ${line} ${W},${Hh}`
return svg({ class: 'chart', viewBox: `0 0 ${W} ${Hh}`, preserveAspectRatio: 'none' },
polygon({ points: area, fill: 'color-mix(in srgb, var(--accent) 18%, transparent)', stroke: 'none' }),
polyline({ points: line, fill: 'none', stroke: 'var(--accent)', strokeWidth: 1.5, vectorEffect: 'non-scaling-stroke' }),
)
}
// --- training set ---
trainingSet() {
return div({ class: 'training' },
div({ class: 'training-head' },
h3('Training set'),
this.model.ready ? button({ class: 'reset-btn', onClick: () => this.reset() }, 'Reset') : null,
),
div({ class: 'gallery' }, this.model.classes.map((name, ci) => this.classColumn(name, ci))),
)
}
classColumn(name: string, ci: number) {
const thumbs = this.model.train.filter(e => e.label === ci)
return div({ class: 'classcol' },
div({ class: 'classlbl', style: { color: classColor(ci) } }, name),
div({ class: 'strip' }, thumbs.map(e =>
img({
src: e.url,
class: ['thumb', this.model.shot?.url === e.url ? 'selected' : ''],
style: { borderColor: classColor(ci) },
onClick: () => this.select(e.url, e.feat),
}))),
)
}
about() {
return div({ class: 'about' },
p('This is a pretrained image classifier running in your browser with tensorgrad. On startup, we train the model to recognize 3 categories based on the 12 images. You can continue to train the model by adding your own images and classifying them.'),
p('Under the hood the pretrained model is a frozen ViT-tiny that turns each image into a 192-dimensional feature vector. tensorgrad trains a one-layer head on top of those features, so the backbone never changes and only the head learns.'),
)
}
}
new App({ root: new Root(), id: 'app' })
```
**styles.css**
```css
:root {
--bg: rgb(255, 255, 255);
--fg: rgb(28, 28, 30);
--muted: rgb(82, 84, 92);
--panel: rgb(252, 252, 253);
--border: rgb(224, 224, 228);
--accent: rgb(58, 125, 232);
--track: rgb(233, 234, 238);
--thumb-hover-shadow: 0 0 14px rgba(0, 0, 0, .8);
}
html[data-theme="dark"] {
--bg: rgb(22, 22, 24);
--fg: rgb(232, 232, 236);
--muted: rgb(150, 152, 160);
--panel: rgb(32, 32, 38);
--border: rgb(58, 58, 64);
--accent: rgb(122, 162, 250);
--track: rgb(48, 48, 54);
--thumb-hover-shadow: 0 0 16px rgba(255, 255, 255, .28);
}
body {
max-width: 960px;
margin: 1.5rem auto 2rem;
padding: 0 1.25rem;
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.6;
}
h1 {
font-size: 2.3rem;
font-weight: 650;
line-height: 1.15;
margin: 0;
letter-spacing: -0.01em;
}
h3 {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
text-transform: uppercase;
letter-spacing: .05em;
}
/* header row — title beside the loss sparkline (stacks on mobile) */
.header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.35rem;
}
/* hero stage card */
.stage {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 14px;
margin-bottom: 2rem;
}
/* loss sparkline — bare, fills the width beside the title */
.chartrow {
position: relative;
overflow: hidden;
flex: 1;
min-width: 0;
}
.chart {
width: 100%;
height: 52px;
display: block;
}
.chart-label {
position: absolute;
top: .55rem;
right: .9rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: .05rem;
pointer-events: none;
}
.chart-label-title {
font-weight: 600;
font-size: .9rem;
}
.chart-label-num {
font-size: .8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* stage — the hero card */
.stage { padding: 1.25rem; }
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1.75rem 1rem;
text-align: center;
}
.loading-lead {
margin: 0;
color: var(--fg);
font-size: 1.05rem;
line-height: 1.5;
}
.loading-status {
display: flex;
align-items: center;
gap: .7rem;
color: var(--muted);
font-size: .95rem;
}
.dropbar {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: .7rem 1rem;
margin-bottom: 1.25rem;
border: 2px dashed var(--border);
border-radius: 10px;
color: var(--muted);
font-size: .95rem;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.dropbar:hover,
.dropbar.over {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.dropbar-plus {
font-size: 1.2rem;
font-weight: 300;
line-height: 1;
}
.fileinput { display: none; }
.result {
display: flex;
gap: 1.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.shot-img {
width: 240px;
height: 240px;
object-fit: cover;
border-radius: 12px;
display: block;
box-shadow: 0 1px 5px rgba(0, 0, 0, .14);
}
.readout {
flex: 1;
min-width: 280px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.readout-wait {
display: flex;
align-items: center;
gap: .6rem;
color: var(--muted);
padding-top: 1rem;
}
.verdict {
display: flex;
align-items: baseline;
gap: .6rem;
}
.verdict-class {
font-size: 2rem;
font-weight: 680;
letter-spacing: -0.01em;
text-transform: capitalize;
}
.verdict-pct {
font-size: 1.05rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.bars {
display: flex;
flex-direction: column;
gap: .5rem;
}
.bar {
display: flex;
align-items: center;
gap: .7rem;
}
.barlab {
width: 84px;
text-align: right;
color: var(--muted);
text-transform: capitalize;
}
.track {
flex: 1;
height: 18px;
background: var(--track);
border-radius: 9px;
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 9px;
transition: width .3s ease;
}
.barpct {
width: 40px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.teach {
display: flex;
flex-direction: column;
gap: .5rem;
margin-top: .25rem;
}
.teach-lbl {
color: var(--muted);
font-size: .95rem;
}
.teach-btns {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.teach-btn {
font-size: .95rem;
line-height: 1;
font-family: inherit;
background: var(--panel);
border: 1.5px solid var(--border);
border-radius: 8px;
padding: .35rem .8rem;
cursor: pointer;
text-transform: capitalize;
transition: background .12s;
}
.teach-btn:hover { background: color-mix(in srgb, var(--fg) 6%, var(--panel)); }
.teach-btn.newclass {
border-style: dashed;
color: var(--muted);
text-transform: none;
}
.newclass-form {
display: inline-flex;
gap: .4rem;
align-items: center;
}
.newclass-input {
font: inherit;
font-size: .95rem;
padding: .35rem .6rem;
border: 1.5px solid var(--accent);
border-radius: 8px;
background: var(--panel);
color: var(--fg);
width: 11rem;
}
.newclass-input:focus {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
/* training set */
.training { margin-bottom: 2rem; }
.training-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.reset-btn {
font-family: inherit;
font-size: .9rem;
background: var(--panel);
border: 1.5px solid var(--border);
border-radius: 8px;
padding: .3rem .8rem;
cursor: pointer;
color: var(--muted);
transition: border-color .12s, color .12s;
}
.reset-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.gallery {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 1.75rem;
}
.classcol {
display: flex;
flex-direction: column;
gap: .5rem;
}
.classlbl {
font-weight: 600;
text-transform: capitalize;
}
.strip {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.thumb {
width: 76px;
height: 76px;
object-fit: cover;
border-radius: 8px;
border: 2px solid;
display: block;
cursor: pointer;
transition: box-shadow .12s;
}
.thumb:hover { box-shadow: var(--thumb-hover-shadow); }
.thumb.selected { box-shadow: 0 0 0 3px var(--accent); }
/* about — at the bottom; functionality speaks first */
.about {
color: var(--muted);
font-size: 1rem;
line-height: 1.65;
margin: 0;
}
.about p { margin: 0 0 .9rem; }
.about p:last-child { margin-bottom: 0; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
display: inline-block;
animation: spin .8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* below ~700px the image + readout no longer sit side by side — stack them.
The image goes fluid (capped at its 240px size) so it can shrink on narrow
screens instead of pinning the column width and keeping the bars wide. */
@media (max-width: 700px) {
h1 { text-align: center; }
/* title and sparkline stack; the chart reclaims its full row (same height) */
.header { flex-direction: column; align-items: stretch; gap: .9rem; }
.result { flex-direction: column; }
.shot-img {
width: 100%;
max-width: 240px;
height: auto;
aspect-ratio: 1;
margin-inline: auto;
}
.readout {
width: 100%;
min-width: 0;
}
.verdict { justify-content: center; }
}
/* mobile: reclaim the page gutters and let thumbnail rows shrink to fit */
@media (max-width: 640px) {
body { padding: 0 .6rem; }
.gallery { gap: 1.25rem; }
.classcol { width: 100%; }
.strip { gap: 6px; }
.thumb {
flex: 1 1 0;
min-width: 0;
width: auto;
height: auto;
aspect-ratio: 1;
max-width: 84px;
}
.bar { gap: .5rem; }
.barlab { width: 60px; }
.track { min-width: 0; }
.barpct { width: 30px; }
}
```
**index.html**
```html
<div id="app"></div>
```
**config.json**
```json
{
"dependencies": {
"tensorgrad": "^0.4.6",
"domeleon": "^0.6.3"
},
"description": "Image classifier built with tensorgrad, running in your browser on WebGPU. Drop in photos, fix wrong guesses, and add new classes, live."
}
```