AI
AI Pre-Grade
Upload a photo and get an estimated PSA grade using AI analysis of centering, corners, edges, and surface.
diff --git a/public/style.css b/public/style.css
index f5d6928..9bc13f4 100644
--- a/public/style.css
+++ b/public/style.css
@@ -17,7 +17,7 @@
body {
background: var(--bg);
color: var(--text);
- font-family: 'Inter', system-ui, sans-serif;
+ font-family: 'Inter Tight', 'Inter', system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
@@ -46,14 +46,20 @@ a:hover { text-decoration: underline; }
}
header {
- position: relative;
- z-index: 1;
+ position: sticky;
+ top: 0;
+ z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
- padding: 20px 32px;
+ padding: 0 32px;
+ height: 64px;
max-width: 1200px;
margin: 0 auto;
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ background: rgba(7, 7, 10, 0.7);
+ border-bottom: 1px solid var(--border);
}
.logo {
@@ -67,6 +73,20 @@ header {
color: var(--gold);
}
+.beta-badge {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--red);
+ border: 1px solid rgba(255, 93, 93, 0.3);
+ background: rgba(255, 93, 93, 0.08);
+ padding: 2px 6px;
+ border-radius: 4px;
+ line-height: 1;
+}
+
.nav-link {
color: var(--muted);
font-size: 14px;
@@ -82,6 +102,16 @@ main {
padding: 0 32px 80px;
}
+.fade-up {
+ opacity: 0;
+ transform: translateY(24px);
+ transition: opacity 0.5s ease, transform 0.5s ease;
+}
+.fade-up.visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
.hero {
text-align: center;
padding: 60px 0 40px;
@@ -101,6 +131,17 @@ main {
margin: 0 auto 36px;
}
+.search-sticky {
+ position: sticky;
+ top: 64px;
+ z-index: 30;
+ background: rgba(7, 7, 10, 0.85);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ padding: 12px 0;
+ margin: -12px 0 0;
+}
+
.search-bar {
display: flex;
max-width: 620px;
@@ -150,9 +191,9 @@ main {
background: none;
border: 1px solid var(--border);
color: var(--muted);
- padding: 4px 10px;
- border-radius: 6px;
- font-size: 13px;
+ padding: 5px 14px;
+ border-radius: 9999px;
+ font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
@@ -160,7 +201,8 @@ main {
}
.hint:hover {
color: var(--gold);
- border-color: rgba(217, 182, 118, 0.3);
+ border-color: rgba(217, 182, 118, 0.4);
+ background: rgba(217, 182, 118, 0.06);
}
/* Results */
@@ -220,9 +262,10 @@ main {
text-align: center;
}
.psa-stat .label {
- font-size: 11px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
text-transform: uppercase;
- letter-spacing: 0.06em;
+ letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 4px;
}
@@ -384,11 +427,12 @@ main {
font-size: 13px;
}
.detail-field .detail-label {
- font-size: 11px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
text-transform: uppercase;
- letter-spacing: 0.04em;
+ letter-spacing: 0.08em;
color: var(--muted);
- margin-bottom: 2px;
+ margin-bottom: 4px;
}
.detail-field .detail-value {
font-family: 'Space Grotesk', system-ui, sans-serif;
@@ -462,11 +506,11 @@ main {
margin-bottom: 12px;
}
.detail-grade-section-label {
- font-family: 'Space Grotesk', system-ui, sans-serif;
+ font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.06em;
+ letter-spacing: 0.08em;
color: var(--gold);
margin-bottom: 12px;
}
@@ -542,18 +586,89 @@ main {
.detail-meta-row .detail-grid {
margin-bottom: 0;
}
-.detail-tabs { display: flex; gap: 4px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
-.detail-tab { background: none; border: none; color: var(--muted); font-family: 'Space Grotesk', system-ui, sans-serif; font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
-.detail-tab:hover { color: var(--text); }
-.detail-tab.active { color: var(--gold); background: var(--gold-dim); }
+.detail-tabs { display: flex; gap: 6px; margin-bottom: 14px; }
+.detail-tab { background: none; border: 1px solid var(--border); color: var(--muted); font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; padding: 6px 16px; border-radius: 9999px; cursor: pointer; transition: all 0.2s; }
+.detail-tab:hover { color: var(--text); border-color: rgba(255,255,255,0.15); }
+.detail-tab.active { color: var(--gold); border-color: var(--gold); background: rgba(217, 182, 118, 0.08); }
.detail-tab-panel { }
+.graded-badge {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ background: var(--inset);
+ border: 1px solid var(--border);
+ padding: 2px 7px;
+ border-radius: 4px;
+ margin-left: 6px;
+ vertical-align: middle;
+}
+
+.psa-inline {
+ background: var(--inset);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 14px 0;
+ margin-bottom: 14px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+}
+.psa-inline-stat {
+ padding: 0 16px;
+ border-right: 1px solid var(--border);
+}
+.psa-inline-stat:last-child { border-right: none; }
+.psa-inline-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+.psa-inline-value {
+ font-family: 'Space Grotesk', system-ui, sans-serif;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text);
+}
+.psa-inline-value.easy { color: var(--green); }
+.psa-inline-value.hard { color: var(--red); }
+.psa-inline-value.moderate { color: var(--gold); }
+.psa-inline-cost {
+ font-size: 12px;
+ color: var(--gold);
+ margin-left: 4px;
+ font-weight: 500;
+}
+.gem-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.gem-bar-track {
+ flex: 1;
+ height: 5px;
+ background: rgba(255,255,255,0.06);
+ border-radius: 3px;
+ overflow: hidden;
+ max-width: 60px;
+}
+.gem-bar-fill {
+ height: 100%;
+ background: var(--gold);
+ border-radius: 3px;
+}
+
.card-identity { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.card-id-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
-.card-id-badge { font-family: monospace; font-size: 12px; color: var(--gold); background: var(--gold-dim); padding: 3px 8px; border-radius: 4px; font-weight: 600; }
+.card-id-badge { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--gold); background: var(--gold-dim); padding: 3px 8px; border-radius: 4px; font-weight: 600; }
.card-id-rarity { font-family: 'Space Grotesk', system-ui, sans-serif; font-size: 11px; font-weight: 700; color: var(--text); background: var(--inset); border: 1px solid var(--border); padding: 2px 6px; border-radius: 3px; }
.card-id-set { font-size: 11px; color: var(--muted); }
-.card-id-num { font-size: 11px; color: var(--muted); font-family: monospace; }
+.card-id-num { font-size: 11px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }
.card-id-names { margin-top: 4px; display: flex; gap: 10px; }
.card-id-name { font-size: 11px; color: var(--text); }
.card-id-lang { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-right: 2px; }
@@ -562,7 +677,7 @@ main {
.arbitrage-sources { display: flex; gap: 8px; margin-top: 8px; }
.arb-source { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 10px; text-align: center; }
.arb-source.arb-cheapest { border-color: var(--green); background: rgba(124, 224, 168, 0.05); }
-.arb-source-name { font-family: 'Space Grotesk', system-ui, sans-serif; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
+.arb-source-name { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; }
.arb-cheapest .arb-source-name { color: var(--green); }
.arb-source-price { font-family: 'Space Grotesk', system-ui, sans-serif; font-size: 18px; font-weight: 700; color: var(--text); }
.arb-cheapest .arb-source-price { color: var(--green); }
@@ -790,6 +905,7 @@ main {
border-radius: var(--radius);
padding: 28px 24px;
text-align: center;
+ transition-delay: calc(var(--i, 0) * 100ms);
}
.feature-icon {
font-family: 'Space Grotesk', system-ui, sans-serif;
@@ -849,7 +965,8 @@ footer {
.results-right { position: static; }
.features { grid-template-columns: 1fr; }
.psa-signal { gap: 20px; }
- header { padding: 16px 20px; }
+ header { padding: 0 20px; height: 56px; }
+ .search-sticky { top: 56px; }
main { padding: 0 20px 60px; }
#alert-form { flex-direction: column; }
.search-bar { flex-direction: column; }
diff --git a/scripts/test.sh b/scripts/test.sh
index c3b3650..a5ffa3b 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -8,6 +8,7 @@ node --check scripts/psa-report.js || exit 1
node --check api.js || exit 1
node --check lib/data/redis-cache.js || exit 1
node --check lib/swagger.js || exit 1
+node --check test/smoke-test.js || exit 1
for f in \
extension/background.js \
@@ -94,4 +95,12 @@ else
echo "SKIP: API server not running on :3000"
fi
+# Smoke tests (skip if Playwright not installed)
+echo "=== smoke tests ==="
+if npx playwright --version > /dev/null 2>&1; then
+ node test/smoke-test.js || exit 1
+else
+ echo "SKIP: Playwright not installed (run: yarn playwright-install)"
+fi
+
echo "=== all checks passed ==="
diff --git a/test/smoke-test.js b/test/smoke-test.js
new file mode 100644
index 0000000..467275d
--- /dev/null
+++ b/test/smoke-test.js
@@ -0,0 +1,219 @@
+import { chromium } from "playwright";
+import { spawn } from "child_process";
+
+const PORT = 3099;
+const BASE = `http://localhost:${PORT}`;
+let server;
+let passed = 0;
+let failed = 0;
+
+function assert(condition, msg) {
+ if (condition) { passed++; console.log(` PASS: ${msg}`); }
+ else { failed++; console.error(` FAIL: ${msg}`); }
+}
+
+async function startServer() {
+ return new Promise((resolve, reject) => {
+ server = spawn("node", ["api.js"], {
+ env: { ...process.env, API_PORT: PORT, NODE_ENV: "test" },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ server.stdout.on("data", (d) => {
+ if (d.toString().includes("listening")) resolve();
+ });
+ server.stderr.on("data", (d) => {
+ const msg = d.toString();
+ if (!msg.includes("warning") && !msg.includes("ExperimentalWarning")) process.stderr.write(d);
+ });
+ setTimeout(() => reject(new Error("Server start timeout")), 15000);
+ });
+}
+
+async function run() {
+ console.log("Starting API server on :" + PORT);
+ await startServer();
+
+ const browser = await chromium.launch();
+
+ try {
+ // --- Desktop viewport ---
+ console.log("\n[Desktop]");
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await page.goto(BASE);
+
+ // Header
+ console.log("Header:");
+ assert(await page.locator("header").isVisible(), "header visible");
+ const headerStyle = await page.locator("header").evaluate(el => getComputedStyle(el).position);
+ assert(headerStyle === "sticky", "header is sticky");
+ assert(await page.locator(".beta-badge").isVisible(), "beta badge visible");
+ assert(await page.locator(".logo").isVisible(), "logo visible");
+
+ // Search bar
+ console.log("Search:");
+ assert(await page.locator("#search-input").isVisible(), "search input visible");
+ assert(await page.locator("#search-btn").isVisible(), "search button visible");
+ const stickyEl = await page.locator(".search-sticky");
+ assert(await stickyEl.count() > 0, "search sticky wrapper exists");
+
+ // Hint pills
+ console.log("Hints:");
+ const hints = page.locator(".hint");
+ assert(await hints.count() === 3, "3 sample hint pills");
+ const hintRadius = await hints.first().evaluate(el => getComputedStyle(el).borderRadius);
+ assert(hintRadius === "9999px", "hint pills are fully rounded");
+
+ // Feature cards
+ console.log("Features:");
+ const features = page.locator(".feature");
+ assert(await features.count() === 3, "3 feature cards");
+
+ // Click first hint (Pikachu PSA 10)
+ console.log("Sample search:");
+ await hints.first().click();
+ await page.waitForSelector(".listing-card", { timeout: 10000 });
+
+ const cards = page.locator(".listing-card");
+ assert(await cards.count() > 0, "listing cards rendered");
+
+ // Results header
+ assert(await page.locator(".demo-badge").first().isVisible(), "sample data badge visible");
+
+ // Detail panel
+ console.log("Detail panel:");
+ assert(await page.locator(".detail-panel").isVisible(), "detail panel visible");
+ assert(await page.locator(".detail-title").isVisible(), "detail title visible");
+ assert(await page.locator(".detail-summary").isVisible(), "price summary visible");
+
+ // Tabs
+ const tabs = page.locator(".detail-tab");
+ assert(await tabs.count() >= 1, "detail tabs exist");
+ const pricesTab = page.locator('.detail-tab[data-dtab="prices"]');
+ assert(await pricesTab.count() === 1, "Prices tab exists");
+ const tabFont = await pricesTab.evaluate(el => getComputedStyle(el).fontFamily);
+ assert(tabFont.includes("JetBrains Mono"), "tabs use JetBrains Mono");
+
+ // PSA inline stats (Pikachu PSA 10 has PSA signal)
+ await pricesTab.click();
+ const psaInline = page.locator(".psa-inline");
+ if (await psaInline.count() > 0) {
+ console.log("PSA inline:");
+ assert(await psaInline.isVisible(), "PSA inline stats visible in Prices tab");
+ const statCells = page.locator(".psa-inline-stat");
+ assert(await statCells.count() === 4, "4 PSA stat cells (gem/pop/difficulty/tier)");
+ const gemBar = page.locator(".gem-bar-track");
+ assert(await gemBar.count() > 0, "gem progress bar exists");
+ }
+
+ // Arbitrage
+ const arbContainer = page.locator("#arbitrage-container");
+ await page.waitForTimeout(1500);
+ if (await arbContainer.isVisible()) {
+ console.log("Arbitrage:");
+ const arbSources = page.locator(".arb-source");
+ assert(await arbSources.count() >= 2, "at least 2 arbitrage sources");
+ const cheapest = page.locator(".arb-cheapest");
+ assert(await cheapest.count() === 1, "cheapest source highlighted");
+ }
+
+ // Price chart
+ const chartContainer = page.locator("#price-chart-container");
+ if (await chartContainer.isVisible()) {
+ console.log("Price chart:");
+ assert(await page.locator("#price-chart").isVisible(), "chart canvas visible");
+ assert(await page.locator("#price-chart-stats").isVisible(), "chart stats visible");
+ }
+
+ // Source filters (multi-source search)
+ const sourceFilters = page.locator(".source-filter");
+ if (await sourceFilters.count() > 0) {
+ console.log("Source filters:");
+ assert(await sourceFilters.count() >= 2, "source filter pills rendered");
+ }
+
+ // Card identity
+ const cardId = page.locator("#card-identity");
+ await page.waitForTimeout(1000);
+ if (await cardId.isVisible()) {
+ console.log("Card identity:");
+ assert(await page.locator(".card-id-badge").isVisible(), "card ID badge visible");
+ }
+
+ // View on source link
+ const viewLink = page.locator(".detail-actions a");
+ if (await viewLink.count() > 0) {
+ const href = await viewLink.getAttribute("href");
+ assert(href && href.startsWith("http"), "view link has valid URL");
+ }
+
+ // Click second hint (Greninja — has AI grade)
+ console.log("Grade tab:");
+ await page.locator('.hint[data-q="Mega Greninja ex SAR"]').click();
+ await page.waitForSelector(".listing-card", { timeout: 10000 });
+
+ const gradeTab = page.locator('.detail-tab[data-dtab="grade"]');
+ if (await gradeTab.count() > 0) {
+ await gradeTab.click();
+ assert(await page.locator(".detail-grade").isVisible(), "grade breakdown visible");
+ const gradeBars = page.locator(".grade-bar-item");
+ assert(await gradeBars.count() === 4, "4 subgrade bars (centering/corners/edges/surface)");
+ assert(await page.locator(".grade-bar-lowest").count() === 1, "lowest subgrade highlighted");
+ }
+
+ // Slab badge (check Pikachu slabs have GRADED badge)
+ console.log("Slab badge:");
+ await hints.first().click();
+ await page.waitForSelector(".listing-card", { timeout: 10000 });
+ const gradedBadge = page.locator(".graded-badge");
+ if (await gradedBadge.count() > 0) {
+ assert(await gradedBadge.first().isVisible(), "GRADED badge visible for slab listing");
+ }
+
+ // --- Mobile viewport ---
+ console.log("\n[Mobile]");
+ const mobile = await browser.newPage({ viewport: { width: 375, height: 667 } });
+ await mobile.goto(BASE);
+
+ assert(await mobile.locator("header").isVisible(), "header visible on mobile");
+ assert(await mobile.locator("#search-input").isVisible(), "search input visible on mobile");
+
+ const bodyWidth = await mobile.evaluate(() => document.body.scrollWidth);
+ assert(bodyWidth <= 375, "no horizontal overflow on mobile");
+
+ await mobile.locator(".hint").first().click();
+ await mobile.waitForSelector(".listing-card", { timeout: 10000 });
+ assert(await mobile.locator(".listing-card").count() > 0, "listings render on mobile");
+ assert(await mobile.locator(".detail-panel").isVisible(), "detail panel visible on mobile");
+
+ await mobile.close();
+ await page.close();
+
+ // --- Static assets ---
+ console.log("\n[Static assets]");
+ const page2 = await browser.newPage();
+ for (const [path, type] of [
+ ["/", "text/html"],
+ ["/style.css", "text/css"],
+ ["/app.js", "javascript"],
+ ]) {
+ const res = await page2.goto(BASE + path);
+ assert(res.status() === 200, `${path} returns 200`);
+ const ct = res.headers()["content-type"] || "";
+ assert(ct.includes(type), `${path} content-type includes ${type}`);
+ }
+ await page2.close();
+
+ } finally {
+ await browser.close();
+ server.kill();
+ }
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed ? 1 : 0);
+}
+
+run().catch(e => {
+ console.error(e);
+ if (server) server.kill();
+ process.exit(1);
+});
diff --git a/test/unit-test.js b/test/unit-test.js
index 7068cf6..8f2b711 100644
--- a/test/unit-test.js
+++ b/test/unit-test.js
@@ -18,7 +18,7 @@ import {
querySeeksJapaneseMarket,
filterToLikelyTcgCards,
} from "../lib/search/filters.js";
-import { isDemoQuery, getDemoResult, getDemoSearchResult, listDemoCards } from "../lib/demo.js";
+import { isDemoQuery, getDemoResult, getDemoSearchResult, listDemoCards } from "../lib/data/demo.js";
import { parseCardIdentity, buildCardId, SET_NAME_MAP } from "../lib/data/card-identity.js";
let passed = 0;