/* Card Grader — UI */ const state = { settings: {}, modelGuide: {}, history: [] }; const $ = (sel) => document.querySelector(sel); function esc(text) { return String(text === null || text === undefined ? '' : text) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } /* ------------------------------------------------------------------ api */ // The path this app is mounted under (e.g. "/cards"), or "" at the domain // root — set server-side via a data attribute since a script has no other // reliable way to know where it was served from. const APP_BASE = document.documentElement.dataset.base || ''; async function api(path, options = {}) { const response = await fetch(APP_BASE + path, { headers: { 'Content-Type': 'application/json' }, ...options, body: options.body ? JSON.stringify(options.body) : undefined, }); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; } if (!response.ok) { throw new Error((data && data.error) || `Request failed (${response.status})`); } return data; } function banner(message, isError = false) { const node = $('#banner'); if (!message) { node.hidden = true; return; } node.className = isError ? 'banner err' : 'banner'; node.textContent = message; node.hidden = false; if (!isError) setTimeout(() => { node.hidden = true; }, 3200); } /* ------------------------------------------------------------- file read */ // // Read straight to a data URL at pick time, and only clear the input once // the bytes are safely in hand. Holding File objects and reading them later // looks equivalent and isn't: clearing input.value (which is what lets you // re-pick the same file) can detach the underlying blob in some browsers, so // a read attempted afterward fails with no useful reason. Reading now // sidesteps that, and the data URL doubles as the preview source. function bytesToBase64(bytes) { // Chunked: fromCharCode.apply on a multi-megabyte array blows the stack. let binary = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(binary); } async function readOneImage(file) { // Two independent read paths, because they fail independently. FileReader // is the older API and is the one that trips over files a phone exposes // through a cloud provider or a scoped-storage URI; file.arrayBuffer() is // the modern path and frequently succeeds where it doesn't. Trying both // turns a hard failure into a retry, and if both fail the real // DOMException name gets reported rather than a guess at the cause. const errors = []; try { const buffer = await file.arrayBuffer(); if (buffer && buffer.byteLength) { const type = file.type || 'image/jpeg'; return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`; } errors.push('arrayBuffer returned nothing'); } catch (err) { errors.push(`arrayBuffer: ${err && err.name ? err.name : err}`); } try { return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error || new Error('unknown')); reader.readAsDataURL(file); }); } catch (err) { errors.push(`FileReader: ${err && err.name ? err.name : err}`); } const detail = errors.join('; '); const hint = /NotReadable|NotFound/i.test(detail) ? ' The file may be stored in the cloud rather than on the device — open it once in your photo app so it downloads, then try again.' : ' Try saving a copy of it first, then pick the copy.'; throw new Error(`Could not read "${file.name || 'that file'}" (${detail}).${hint}`); } async function readPickedImages(input, limit) { const picked = Array.from(input.files || []).slice(0, Math.max(0, limit)); const out = []; for (const file of picked) { out.push({ name: file.name || 'upload', dataUrl: await readOneImage(file) }); } input.value = ''; return out; } function pickedToPayload(items) { return items.map((i) => ({ filename: i.name, image_base64: i.dataUrl.split(',')[1] })); } /* --------------------------------------------------------------- grading */ const SEVERITY_PILL = { none: 'pill-grade', minor: 'pill-marginal', moderate: 'pill-marginal', major: 'pill-critical', cannot_assess: 'pill-raw', }; const SEVERITY_LABEL = { none: 'clean', minor: 'minor', moderate: 'moderate', major: 'major', cannot_assess: "can't tell from photo", }; const gradeState = { files: [], result: null, busy: false }; function renderGradeBlock(g, opts = {}) { if (!g) return ''; const m = g.edge_measurements || null; let measuredLine = ''; if (m && m.reliable === false) { measuredLine = `not measurable — ${m.reason || "this card's finish"}`; } else if (m && m.edges) { const sides = ['top', 'right', 'bottom', 'left']; measuredLine = sides .filter((s) => m.edges[s]) .map((s) => `${s} ${m.edges[s].percent.toFixed(0)}%`).join(' · '); // An edge excluded for reading as a different material (a die-cut clear // window, a foil strip) comes back with no score. Listing only the // edges that DID measure would quietly present three sides as if they // were all four — say which one is missing and why. const skipped = sides.filter((s) => !m.edges[s] && (m.edge_notes || {})[s]); if (skipped.length) { const why = m.edge_notes[skipped[0]]; measuredLine += `${measuredLine ? '; ' : ''}${skipped.join(' and ')} not measured — ${why}`; } } const cm = g.centering_measurement || null; const centeringLine = cm && cm.reliable === false ? `not measurable — ${cm.reason || "this card's cut"}` : (cm && cm.horizontal_label ? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : ''); // Only worth surfacing when it's away from noise — most cards sit within // a percent or two of standard and repeating that number on every card // would just be clutter, not information. Shown against its best-matching // standard; which standard is actually relevant is a judgment call the // model made with the photo in hand, not something this line re-derives. const am = g.aspect_measurement || null; const bestDev = am && am.against_standards && am.best_match ? am.against_standards[am.best_match].deviation_percent : null; const aspectLine = (bestDev !== null && bestDev >= 3) ? `ratio ${bestDev}% off standard (${am.best_match}) — ` + `verify this isn't just a rotated photo before reading it as trimming` : ''; const rows = ['centering', 'corners', 'edges', 'surface'].map((key) => { const cat = (g.categories || {})[key] || {}; const sev = cat.severity || 'cannot_assess'; let extra = ''; if (key === 'edges' && measuredLine) { extra = `
measured whitening — ${esc(measuredLine)}
`; } else if (key === 'centering' && centeringLine) { extra = `
${esc(centeringLine)}
`; } else if (key === 'corners' && aspectLine) { extra = `
${esc(aspectLine)}
`; } return ` ${key} ${esc(SEVERITY_LABEL[sev])} ${esc(cat.observation || '')}${extra} `; }).join(''); return `
A photo can't show surface scratches, print lines, or light edge wear the way a grader's raking light does — and a seller's listing photo is often lit to hide them. Treat this as a rough screen, not a prediction of what it comes back as.
${renderSlab(g, opts)}
${rows}
${(g.limitations || []).length ? `
Couldn't check:
` : ''} ${g.note ? `

${esc(g.note)}

` : ''}`; } const GRADE_WORD = { 10: 'Gem Mint', 9: 'Mint', 8: 'NM-MT', 7: 'Near Mint', 6: 'EX-MT', 5: 'Excellent', 4: 'VG-EX', 3: 'Very Good', 2: 'Good', 1: 'Poor', }; const TYPE_LABEL = { pokemon: 'Pokémon', sports: 'Sports', other_tcg: 'TCG', other: 'Card', }; function slabClass(grade) { if (grade === null || grade === undefined) return 'slab-low'; if (grade >= 10) return 'slab-10'; if (grade === 9) return 'slab-9'; if (grade >= 7) return 'slab-8'; if (grade >= 5) return 'slab-6'; return 'slab-low'; } function renderSlab(g, opts = {}) { const grade = g.estimated_grade; const range = (g.grade_low !== null && g.grade_high !== null && g.grade_low !== g.grade_high) ? `realistically ${g.grade_low}–${g.grade_high}` : ''; const title = opts.title || g.card_note || 'Card'; const confidence = g.confidence ? `${g.confidence} confidence` : ''; // The kicker sits where a real slab label carries its grader's logo. It // says ESTIMATE first and always — the label is styled to look like the // real thing, so it has to say plainly that it isn't one. const kicker = ['Grade estimate', g.card_type ? (TYPE_LABEL[g.card_type] || 'Card') : ''] .filter(Boolean).join(' · '); // A label needs a focal line to sit right against the grade block. Where // the card name is suppressed (the history modal already heads with it), // the range takes that slot rather than leaving the face half-empty. const primary = opts.hideTitle ? (range || confidence) : title; const secondary = opts.hideTitle ? (range ? confidence : '') : [range, confidence].filter(Boolean).join(' · '); return `
${esc(kicker)}
${primary ? `
${esc(primary)}
` : ''} ${secondary ? `
${esc(secondary)}
` : ''}
${grade === null || grade === undefined ? '—' : grade} ${esc(GRADE_WORD[grade] || 'estimate')}
`; } function renderGradeReview() { const el = $('#grade-review'); if (!gradeState.files.length && !gradeState.result) { el.innerHTML = ''; return; } if (!gradeState.result) { el.innerHTML = `

${gradeState.files.length} photo(s) ready

more angles = a tighter estimate
${gradeState.files.map((f) => ``).join('')}
`; return; } const g = gradeState.result.grade; const cost = gradeState.result.grade.estimated_cost; el.innerHTML = `

Grade estimate

${cost ? `about $${cost.toFixed(3)}` : ''}
${renderGradeBlock(g)}
`; } async function runGrade() { gradeState.busy = true; renderGradeReview(); const status = $('#grade-status'); status.hidden = false; status.textContent = `Reading ${gradeState.files.length} photo(s)… this takes a few seconds and costs a few cents.`; try { const body = { images: pickedToPayload(gradeState.files), label: ($('#grade-label').value || '').trim() || null, }; // A fresh grade always runs on the server's configured default model // (there's no per-grade model picker), so that's whose provider the // personal key needs to match. const key = myApiKeyForModel(state.settings.vision_model); if (key) body.api_key = key; gradeState.result = await api('/api/grade', { method: 'POST', body }); status.hidden = true; $('#grade-label').value = ''; loadHistory().catch(() => {}); loadUsage().catch(() => {}); } catch (err) { status.textContent = `Grading failed: ${err.message}`; } finally { gradeState.busy = false; renderGradeReview(); } } function resetGradeState() { gradeState.files = []; gradeState.result = null; gradeState.busy = false; $('#grade-status').hidden = true; renderGradeReview(); } async function addPickedFrom(input) { try { const picked = await readPickedImages(input, 6 - gradeState.files.length); if (!picked.length) return; gradeState.files.push(...picked); renderGradeReview(); } catch (err) { $('#grade-status').hidden = false; $('#grade-status').textContent = err.message; } } $('#btn-grade').addEventListener('click', () => { resetGradeState(); $('#grade-file').click(); }); $('#grade-file').addEventListener('change', (e) => addPickedFrom(e.target)); // Camera shots add onto whatever's already picked (front, then flip to the // back for a second shot) rather than resetting — resetGradeState() is only // for starting a fresh card, which the library button already does. $('#btn-camera').addEventListener('click', () => $('#grade-camera').click()); $('#grade-camera').addEventListener('change', (e) => addPickedFrom(e.target)); $('#grade-review').addEventListener('click', (e) => { if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; } if (e.target.closest('#grade-cancel') || e.target.closest('#grade-discard')) { resetGradeState(); return; } if (e.target.closest('#grade-run')) { if (!gradeState.busy) runGrade(); } }); /* --------------------------------------------------------------- history */ function gradePillClass(grade) { if (grade === null || grade === undefined) return 'pill-raw'; if (grade >= 8) return 'pill-grade'; if (grade >= 5) return 'pill-marginal'; return 'pill-critical'; } // Mirrors slabClass so a history chip and that card's slab always land on // the same colour band — they're the same verdict shown at two sizes. function gradeChipClass(grade) { if (grade === null || grade === undefined) return 'grade-chip-na'; if (grade >= 10) return 'grade-chip-10'; if (grade === 9) return 'grade-chip-9'; if (grade >= 7) return 'grade-chip-8'; if (grade >= 5) return 'grade-chip-6'; return 'grade-chip-low'; } const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; function fmtWhen(iso) { if (!iso) return ''; const s = String(iso); const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/); if (!m) return s.replace('T', ' ').slice(0, 16); const nowYear = String(new Date().getFullYear()); const day = `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}`; // Only spend width on the year when it isn't this year's. return `${m[1] === nowYear ? day : `${day} ${m[1]}`} · ${m[4]}`; } async function loadHistory() { const data = await api('/api/history'); state.history = data.grades; renderHistory(); } function renderHistory() { const rows = state.history; $('#history-count').textContent = rows.length ? `${rows.length} graded` : ''; $('#history-empty').hidden = rows.length > 0; $('#history-body').innerHTML = rows.map((g) => `
${g.thumbnail ? `` : ''}
${esc(g.label || g.card_note || 'Untitled card')}
${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)
${ g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`} ${esc(g.confidence || '')} ${esc(fmtWhen(g.created_at))}
`).join(''); } /* ----------------------------------------------------------------- spend */ function money(v) { const n = Number(v || 0); // Sub-cent totals are the norm early on; rounding those to $0.00 would // make the panel look broken rather than cheap. return n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`; } async function loadUsage() { let data; try { data = await api('/api/usage'); } catch (_) { return; // never let this block the rest of the page } const users = data.users || []; const panel = $('#usage-panel'); panel.hidden = users.length === 0; if (!users.length) return; const t = data.totals || {}; $('#usage-total').textContent = `${t.grades || 0} grading call(s) · ${money(t.server_cost).replace('<', 'under ')} on the server key`; $('#usage-body').innerHTML = users.map((u) => { const isYou = data.you && u.username === data.you; return ` ${esc(u.username)}${isYou ? ' you' : ''} ${u.grades} ${money(u.server_cost)} ${money(u.own_cost)} ${esc(fmtWhen(u.last_used))} `; }).join(''); } function renderEntryModal(g) { $('#entry-modal').innerHTML = `

${esc(g.label || g.card_note || 'Untitled card')}

${esc(fmtWhen(g.created_at))} · ${g.usage && g.usage.model ? esc(g.usage.model) : ''}
${renderGradeBlock(g, { hideTitle: true })}
`; $('#entry-modal').hidden = false; $('#entry-scrim').hidden = false; } function closeEntryModal() { $('#entry-modal').hidden = true; $('#entry-scrim').hidden = true; } /* --------------------------------------------------------------- regrade */ // // Instant when the card's original photo(s) were saved (everything graded // from now on); otherwise falls back to picking the photo(s) again, same as // grading fresh, just updating this row instead of creating a new one. let regradeTargetId = null; function applyRegradeResult(g) { const idx = state.history.findIndex((row) => row.id === g.id); if (idx === -1) state.history.unshift(g); else state.history[idx] = g; // created_at was bumped server-side to reflect the just-finished regrade, // so re-sort to match — otherwise the card sits wherever it used to be // until the next full reload. state.history.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id); renderHistory(); const openDelete = $('#entry-modal').querySelector('[data-history-delete]'); if (!$('#entry-modal').hidden && openDelete && Number(openDelete.dataset.historyDelete) === g.id) { renderEntryModal(g); } } async function runRegrade(id, body) { banner('Regrading…'); try { const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body }); applyRegradeResult(data.grade); loadUsage().catch(() => {}); // a regrade is billed too banner('Regraded.'); } catch (err) { banner(`Regrade failed: ${err.message}`, true); } } function startRegrade(id, hasImages) { if (hasImages) { runRegrade(id, {}); } else { // Say why the picker is opening. Otherwise a Regrade that silently // asks for a photo looks broken — especially on a card that regraded // instantly last week, before its stored photo aged out. banner("This card's photo is no longer stored — pick it again to regrade."); regradeTargetId = id; $('#regrade-file').click(); } } $('#regrade-file').addEventListener('change', async (e) => { const id = regradeTargetId; regradeTargetId = null; if (!id) return; let picked; try { picked = await readPickedImages(e.target, 6); } catch (err) { banner(err.message, true); return; } if (!picked.length) return; await runRegrade(id, { images: pickedToPayload(picked) }); }); $('#history-body').addEventListener('click', async (e) => { const del = e.target.closest('[data-history-delete]'); if (del) { e.stopPropagation(); await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' }); await loadHistory(); return; } const regrade = e.target.closest('[data-history-regrade]'); if (regrade) { e.stopPropagation(); startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1'); return; } const row = e.target.closest('[data-history-row]'); if (row) { try { const data = await api(`/api/history/${row.dataset.historyRow}`); renderEntryModal(data.grade); } catch (err) { banner(err.message, true); } } }); $('#entry-modal').addEventListener('click', async (e) => { if (e.target.closest('#entry-close')) return closeEntryModal(); const del = e.target.closest('[data-history-delete]'); if (del) { await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' }); closeEntryModal(); await loadHistory(); return; } const regrade = e.target.closest('[data-history-regrade]'); if (regrade) { startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1'); return; } if (e.target.closest('#entry-save-label')) { const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete; const label = $('#entry-label').value.trim(); await api(`/api/history/${id}`, { method: 'PATCH', body: { label } }); closeEntryModal(); await loadHistory(); banner('Renamed.'); } }); $('#entry-scrim').addEventListener('click', closeEntryModal); /* -------------------------------------------------------------- settings */ async function openSettings() { const s = state.settings; try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } const modelOptions = Object.entries(state.modelGuide).map(([id, info]) => ``).join(''); const isAdmin = s.is_admin; $('#settings-modal').innerHTML = `

Settings

Your API keys

Used when the selected model is a Claude model. Stored only in this browser, never saved on the server. Get one at console.anthropic.com.
Used when the selected model is GPT-5.6 Sol. Same deal — this browser only. Get one at platform.openai.com.
${ (s.anthropic_key_configured || s.openai_key_configured) ? 'This server already has a key set up for at least one provider, so you can leave the matching field above blank and use that instead — but then the owner pays for your grades.' : 'This server has no key of its own yet, so you need one above to grade anything.' }

Server settings${isAdmin ? '' : ' locked'}

${!isAdmin ? `
Only the admin can change server settings on this instance. Use your own key above.
` : `
Neither key is ever sent back to a browser once saved. Leave a field blank to keep whatever's already stored for it.
Sonnet 5 is the default for a tested reason — run head-to-head against Haiku on the same cards, Haiku misread a PSA centering tolerance and landed three grades off. GPT-5.6 Sol hasn't been run against real cards here yet, so treat its results with more scrutiny until it has.
`}
`; $('#settings-modal').hidden = false; $('#modal-scrim').hidden = false; } /* A personal key lives in localStorage, never on the server — that's what lets someone use a shared instance without spending the owner's credits. Keyed per provider since a Claude key and an OpenAI key aren't interchangeable and someone may reasonably hold both. */ function myApiKey(provider) { try { if (provider === 'anthropic') { // One-time migration: friends who set a key before OpenAI support // existed had it under the old unprefixed name. Move it once rather // than losing it. const legacy = localStorage.getItem('cardgrader_api_key'); if (legacy && !localStorage.getItem('cardgrader_api_key_anthropic')) { localStorage.setItem('cardgrader_api_key_anthropic', legacy); localStorage.removeItem('cardgrader_api_key'); } } return localStorage.getItem(`cardgrader_api_key_${provider}`) || ''; } catch (_) { return ''; } } function setMyApiKey(provider, value) { try { const key = `cardgrader_api_key_${provider}`; if (value) localStorage.setItem(key, value); else localStorage.removeItem(key); } catch (_) { /* private browsing — the field just won't persist */ } } // Which of the two personal keys applies to whatever model is actually // going to run — the currently configured default, unless a specific grade // requests a different one (nothing does yet, but the lookup is already // provider-aware for when it does). function myApiKeyForModel(modelId) { const provider = (state.modelGuide[modelId] || {}).provider || 'anthropic'; return myApiKey(provider); } function closeSettings() { $('#settings-modal').hidden = true; $('#modal-scrim').hidden = true; } async function saveSettings() { setMyApiKey('anthropic', $('#set-my-key-anthropic').value.trim()); setMyApiKey('openai', $('#set-my-key-openai').value.trim()); // Only push server settings when this instance allows it, and only send a // key when one was actually typed — an empty box means "leave it alone", // not "erase it". const modelField = $('#set-model'); if (modelField) { const payload = { vision_model: modelField.value }; const typedAnthropic = $('#set-api-key-anthropic').value.trim(); const typedOpenai = $('#set-api-key-openai').value.trim(); if (typedAnthropic) payload.anthropic_api_key = typedAnthropic; if (typedOpenai) payload.openai_api_key = typedOpenai; state.settings = await api('/api/settings', { method: 'POST', body: payload }); } closeSettings(); updateModelCostHint(); banner('Settings saved.'); } function updateModelCostHint() { const info = state.modelGuide[state.settings.vision_model]; $('#model-cost-hint').textContent = info ? `~$${info.per_grade.toFixed(3)} per grade (${info.label})` : ''; } $('#btn-settings').addEventListener('click', () => openSettings().catch((err) => banner(err.message, true))); $('#settings-modal').addEventListener('click', (e) => { if (e.target.closest('#settings-close')) return closeSettings(); if (e.target.closest('#settings-save')) saveSettings().catch((err) => banner(err.message, true)); }); $('#modal-scrim').addEventListener('click', closeSettings); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSettings(); closeEntryModal(); } }); /* ---------------------------------------------------------------- load */ async function load() { state.settings = await api('/api/settings'); try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } updateModelCostHint(); await loadHistory(); loadUsage().catch(() => {}); const activeProvider = (state.modelGuide[state.settings.vision_model] || {}).provider || 'anthropic'; const serverHasKey = activeProvider === 'openai' ? state.settings.openai_key_configured : state.settings.anthropic_key_configured; if (!serverHasKey && !myApiKey(activeProvider)) { banner(`Add your ${activeProvider === 'openai' ? 'OpenAI' : 'Anthropic'} API key in ` + 'Settings before grading a card.'); } } load().catch((err) => banner(`Could not load: ${err.message}`, true)); /* ------------------------------------------------------------------ pwa */ // // Service workers only run in a secure context: https, or localhost. Over a // plain LAN address (http://192.168.x.x) registration silently fails and the // app stays a normal web page — which is exactly why the README pushes you // through a tunnel rather than just handing friends your LAN IP. if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register(APP_BASE + '/sw.js').catch(() => { /* Not fatal — the app works fine uninstalled. */ }); }); } // Android/Chrome fires this instead of showing its own prompt, so the offer // has to be surfaced deliberately. iOS never fires it: Safari has no // programmatic install, only the manual Share > Add to Home Screen, so the // hint below covers that case instead of pretending a button exists. let deferredInstall = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredInstall = e; const btn = $('#btn-install'); if (btn) btn.hidden = false; }); document.addEventListener('click', async (e) => { if (!e.target.closest('#btn-install')) return; if (!deferredInstall) { banner("No install prompt available — try Chrome's own menu (⋮ → Install app) instead.", true); return; } try { await deferredInstall.prompt(); const choice = await deferredInstall.userChoice; if (choice.outcome !== 'accepted') banner(`Install ${choice.outcome}.`); } catch (err) { banner(`Install failed: ${err.message}`, true); } finally { deferredInstall = null; $('#btn-install').hidden = true; } });