diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 6b4e27cb..58235cf1 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -8,7 +8,8 @@ const BUDGET_BYTES: Record = { // bundle without adding a UI framework or runtime dependency. // Raised from 54 kB for the in-app lighting colour picker: the SV panel, // hue bar, drag thumbs, swatch, and hex field in the dedicated Lighting tab. - ".css": 55_500, + // Raised from 55.5 kB for the standalone Mouse Check diagnostics page. + ".css": 61_500, // Raised from 280 kB for the production Logitech onboard-profile codec, // guarded flash editor, verification exporter, upstream Finalmouse driver, // the dedicated Viper Mini protocol driver, Viper V3 sleep/low-power plus @@ -28,7 +29,9 @@ const BUDGET_BYTES: Record = { // registry now bundles the Cobra HID client, its extended-matrix lighting // payloads, and the Cobra picker filter alongside the other Razer drivers. // Protocol tests and preview fixtures remain unbundled. - ".js": 435_000, + // Raised from 435 kB for Mouse Check's HID inventory UI and reuse of the + // protocol package's validated Razer request/response codec. + ".js": 444_000, }; const ASSETS = join("dist", "assets"); diff --git a/check.html b/check.html new file mode 100644 index 00000000..9c9a490f --- /dev/null +++ b/check.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + Mouse Check — OpenMouse HID Diagnostics + + + +
+ + + diff --git a/src/check.css b/src/check.css new file mode 100644 index 00000000..6b09889b --- /dev/null +++ b/src/check.css @@ -0,0 +1,307 @@ +:root { + color: #f1f1f3; + background: #09090b; + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; background: #09090b; } +button { font: inherit; cursor: pointer; color: inherit; } +a { color: inherit; text-decoration: none; } +button:focus-visible, a:focus-visible { outline: 2px solid #69d28d; outline-offset: 3px; } + +.check-shell { + width: min(100% - 2rem, 860px); + margin: 0 auto; + padding: 2rem 0 4rem; +} + +/* Header */ +.check-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 3rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid #212125; +} +.check-wordmark { + font-size: 1rem; + font-weight: 600; + letter-spacing: -.025em; +} +.check-wordmark span { + color: #55555b; + font-weight: 400; +} +.check-back { + color: #77777c; + font-size: .75rem; + transition: color .15s ease; +} +.check-back:hover { color: #f1f1f3; } + +/* Hero */ +.check-hero { margin-bottom: 2rem; } +.check-hero .overline { + margin: 0 0 .6rem; + color: #69d28d; + font-family: "JetBrains Mono", monospace; + font-size: .62rem; + letter-spacing: .1em; +} +.check-hero h1 { + margin: 0 0 .75rem; + font-size: clamp(1.9rem, 4vw, 2.8rem); + font-weight: 500; + letter-spacing: -.04em; + line-height: .96; +} +.check-hero p { + max-width: 560px; + margin: 0; + color: #85858a; + font-size: .88rem; + line-height: 1.6; +} + +/* Browser compat */ +.compat-banner { + display: flex; + align-items: center; + gap: .75rem; + padding: .75rem 1rem; + border-radius: 9px; + margin-bottom: 1.75rem; + font-size: .78rem; +} +.compat-banner.ok { border: 1px solid rgb(105 210 141 / 25%); background: rgb(105 210 141 / 7%); color: #a3e6bc; } +.compat-banner.warn { border: 1px solid rgb(255 174 100 / 25%); background: rgb(255 174 100 / 7%); color: #ffb97a; } +.compat-banner-dot { width: .45rem; height: .45rem; flex: 0 0 auto; border-radius: 50%; } +.compat-banner.ok .compat-banner-dot { background: #69d28d; box-shadow: 0 0 0 3px rgb(105 210 141 / 15%); } +.compat-banner.warn .compat-banner-dot { background: #ffb97a; } + +/* Scan button */ +.scan-row { + display: flex; + align-items: center; + gap: .75rem; + margin-bottom: 2rem; +} +.scan-button { + padding: .7rem 1.25rem; + border: 1px solid #69d28d; + border-radius: 9px; + background: #69d28d; + color: #07120b; + font-size: .8rem; + font-weight: 650; + transition: filter .15s ease, transform .15s ease; +} +.scan-button:hover { filter: brightness(1.08); transform: translateY(-1px); } +.scan-button:disabled { cursor: wait; opacity: .6; transform: none; } +.scan-note { color: #55555b; font-size: .72rem; } + +/* Empty / status */ +.check-empty { + padding: 3rem 0; + color: #55555b; + font-size: .82rem; + text-align: center; +} + +/* Results */ +.results-list { display: grid; gap: .75rem; } + +.device-card { + padding: 1.1rem 1.25rem; + border: 1px solid #252529; + border-radius: 12px; + background: #111113; +} +.device-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: .9rem; +} +.device-card-identity strong { + display: block; + margin-bottom: .25rem; + font-size: .9rem; + font-weight: 550; +} +.device-card-identity small { + color: #55555b; + font-family: "JetBrains Mono", monospace; + font-size: .6rem; + letter-spacing: .05em; +} +.verdict-badge { + flex: 0 0 auto; + padding: .22rem .55rem; + border-radius: 999px; + font-family: "JetBrains Mono", monospace; + font-size: .58rem; + font-weight: 700; + letter-spacing: .07em; + white-space: nowrap; +} +.verdict-full { border: 1px solid rgb(105 210 141 / 30%); background: rgb(105 210 141 / 10%); color: #69d28d; } +.verdict-partial { border: 1px solid rgb(255 210 100 / 30%); background: rgb(255 210 100 / 8%); color: #ffd264; } +.verdict-limited { border: 1px solid rgb(255 174 100 / 30%); background: rgb(255 174 100 / 8%); color: #ffb97a; } +.verdict-blocked { border: 1px solid rgb(255 110 110 / 25%); background: rgb(255 110 110 / 8%); color: #ff8e8e; } +.verdict-unknown { border: 1px solid #2f2f34; background: #161618; color: #77777c; } + +/* Interfaces table */ +.iface-section { + margin-top: .75rem; + padding-top: .75rem; + border-top: 1px solid #1e1e22; +} +.iface-label { + margin-bottom: .45rem; + color: #55555b; + font-family: "JetBrains Mono", monospace; + font-size: .58rem; + letter-spacing: .09em; +} +.iface-row { + display: flex; + align-items: center; + gap: .65rem; + padding: .38rem 0; + border-bottom: 1px solid #1a1a1e; + color: #b3b3b7; + font-size: .72rem; +} +.iface-row:last-child { border-bottom: 0; } +.iface-page { + flex: 0 0 auto; + min-width: 110px; + color: #77777c; + font-family: "JetBrains Mono", monospace; + font-size: .62rem; +} +.iface-type { flex: 1; } +.iface-reports { + color: #4d4d52; + font-family: "JetBrains Mono", monospace; + font-size: .6rem; +} + +/* TX test results (Razer) */ +.tx-section { + margin-top: .75rem; + padding-top: .75rem; + border-top: 1px solid #1e1e22; +} +.tx-label { + margin-bottom: .5rem; + color: #55555b; + font-family: "JetBrains Mono", monospace; + font-size: .58rem; + letter-spacing: .09em; +} +.tx-row { + display: flex; + align-items: center; + gap: .75rem; + padding: .3rem 0; + font-size: .72rem; +} +.tx-id { + min-width: 36px; + font-family: "JetBrains Mono", monospace; + font-size: .65rem; + font-weight: 500; +} +.tx-ok { color: #69d28d; } +.tx-fail { color: #4d4d52; } +.tx-fw { margin-left: auto; color: #55555b; font-family: "JetBrains Mono", monospace; font-size: .62rem; } + +/* Open result */ +.open-result { + display: flex; + align-items: center; + gap: .5rem; + margin-top: .65rem; + padding: .45rem .65rem; + border-radius: 7px; + background: #0d0d0f; + color: #77777c; + font-size: .7rem; +} +.open-result.ok { color: #a3e6bc; } +.open-result.err { color: #ff8e8e; } +.open-dot { + width: .35rem; + height: .35rem; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; +} + +/* Copy section */ +.copy-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1.75rem; + padding-top: 1.25rem; + border-top: 1px solid #1a1a1e; +} +.copy-section p { + margin: 0; + color: #55555b; + font-size: .72rem; +} +.copy-button { + padding: .55rem .9rem; + border: 1px solid #2f2f34; + border-radius: 8px; + background: #161618; + color: #c5c5c9; + font-size: .72rem; + font-weight: 550; + transition: border-color .15s ease, color .15s ease; +} +.copy-button:hover { border-color: #55555b; color: #f1f1f3; } +.copy-button.copied { border-color: rgb(105 210 141 / 40%); color: #69d28d; } + +/* Scanning indicator */ +.scanning-state { + padding: 2rem 0; + color: #55555b; + font-size: .78rem; + text-align: center; + animation: pulse 1.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: .5; } + 50% { opacity: 1; } +} + +/* Section count */ +.results-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.results-heading span { + color: #55555b; + font-family: "JetBrains Mono", monospace; + font-size: .62rem; + letter-spacing: .08em; +} + +@media (max-width: 600px) { + .check-shell { padding: 1.25rem 0 3rem; } + .device-card-head { flex-direction: column; gap: .5rem; } + .copy-section { flex-direction: column; align-items: flex-start; } +} diff --git a/src/check.ts b/src/check.ts new file mode 100644 index 00000000..b5a184c5 --- /dev/null +++ b/src/check.ts @@ -0,0 +1,462 @@ +import "./check.css"; +import { + decodeFirmwareVersion, + decodeRazerResponse, + encodeRazerRequest, + type RazerCommand, +} from "@openmouse/protocol/razer"; + +// --------------------------------------------------------------------------- +// Brand registry — all known gaming mouse vendor IDs +// --------------------------------------------------------------------------- + +const BRANDS: Record = { + 0x1532: "Razer", + 0x31E3: "Razer HyperPolling", + 0x046D: "Logitech", + 0x1038: "SteelSeries", + 0x1B1C: "Corsair", + 0x0951: "HyperX / Kingston", + 0x0B05: "ASUS ROG", + 0x1E7D: "Roccat", + 0x258A: "SinoWealth / Glorious", + 0x30FA: "SinoWealth", + 0x1770: "Zowie / BenQ", + 0x093A: "PixArt", + 0x3710: "Pulsar", + 0x3367: "Endgame Gear", + 0x36A7: "WLMouse", + 0x373E: "Lamzu", + 0x3554: "Teevolution / VGN", + 0x373B: "ATK", + 0x361D: "Finalmouse", + 0x3434: "Keychron", + 0x2FE3: "moddoMOUSE", + 0x1915: "Orbital", + 0x0C45: "Redragon", + 0x1BCF: "Alienware", +}; + +// OpenMouse-supported brands (driver exists in the control app) +const OPENMOUSE_SUPPORTED = new Set([0x1532, 0x046D, 0x3710, 0x3367, 0x36A7, 0x373E, 0x3554, 0x373B, 0x361D, 0x3434, 0x2FE3, 0x1915]); + +// Safety: skip raw feature-report probing for these vendors. +// Endgame Gear (0x3367): OP1 8K has no firmware guards — unrecognised +// feature-report bytes accidentally triggered a bootloader reset in testing. +// The OpenMouse driver handles these devices through its own safe protocol. +const SKIP_FEATURE_PROBE = new Set([0x3367]); + +// Broad filters — vendorId only so all interfaces appear +const SCAN_FILTERS: HIDDeviceFilter[] = Object.keys(BRANDS).map((vid) => ({ + vendorId: Number(vid), +})); + +// --------------------------------------------------------------------------- +// Razer TX-ID protocol +// --------------------------------------------------------------------------- + +const RAZER_TX_IDS = [0xFF, 0x3F, 0x1F] as const; +const RAZER_FIRMWARE_COMMAND: RazerCommand = { + commandClass: 0x00, + commandId: 0x81, + dataSize: 0x02, +}; + +function buildRazerPacket(txId: number): Uint8Array { + return encodeRazerRequest(RAZER_FIRMWARE_COMMAND, txId); +} + +async function tryRazerTx( + device: HIDDevice, + txId: number, +): Promise<{ ok: boolean; firmware: string | null }> { + const packet = buildRazerPacket(txId); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await device.sendFeatureReport(0x00, packet); + await sleep(120); + const resp = await device.receiveFeatureReport(0x00); + const data = new Uint8Array(resp.buffer.slice(resp.byteOffset, resp.byteOffset + resp.byteLength)); + if (data[1] !== txId) continue; + const args = decodeRazerResponse(data, RAZER_FIRMWARE_COMMAND); + return { ok: true, firmware: decodeFirmwareVersion(args) }; + } catch { + // continue + } + await sleep(60); + } + return { ok: false, firmware: null }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function hex(n: number, width = 4): string { + return n.toString(16).toUpperCase().padStart(width, "0"); +} + +function usagePageLabel(page: number): string { + if (page >= 0xFF00) return `Vendor (0x${hex(page)})`; + switch (page) { + case 0x0001: return "Generic Desktop"; + case 0x000C: return "Consumer"; + case 0x000B: return "Telephony"; + case 0x0006: return "Generic Device"; + default: return `0x${hex(page)}`; + } +} + +function reportSummary(col: HIDCollectionInfo): string { + const parts: string[] = []; + if (col.inputReports.length > 0) parts.push(`${col.inputReports.length}× input`); + if (col.outputReports.length > 0) parts.push(`${col.outputReports.length}× output`); + if (col.featureReports.length > 0) parts.push(`${col.featureReports.length}× feature`); + return parts.join(" ") || "no reports"; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!)); +} + +type Verdict = "full" | "partial" | "limited" | "blocked" | "unknown"; +const VERDICT_LABEL: Record = { + full: "WEBHID FULL", + partial: "PARTIAL", + limited: "LIMITED", + blocked: "NATIVE ONLY", + unknown: "UNKNOWN", +}; + +interface DeviceResult { + device: HIDDevice; + brand: string; + vendorId: number; + productId: number; + openmouseSupported: boolean; + isRazer: boolean; + opened: boolean; + openError: string | null; + verdict: Verdict; + verdictNote: string; + txResults: Array<{ txId: number; ok: boolean; firmware: string | null }>; +} + +// --------------------------------------------------------------------------- +// Assessment (pre-open, based on collections) +// --------------------------------------------------------------------------- + +function assessCollections( + vendorId: number, + collections: readonly HIDCollectionInfo[], +): { verdict: Verdict; note: string } { + if (vendorId === 0x31E3) { + return { verdict: "blocked", note: "HyperPolling dongle — mouhid.sys blocks WebHID on Windows" }; + } + const hasVendor = collections.some((c) => c.usagePage >= 0xFF00); + const hasVendorFeature = collections.some((c) => c.usagePage >= 0xFF00 && c.featureReports.length > 0); + const hasStdOnly = !hasVendor && collections.some((c) => c.usagePage === 0x01 && c.usage === 0x02); + + if (hasVendorFeature) return { verdict: "full", note: "Vendor interface with feature reports visible" }; + if (hasVendor) return { verdict: "partial", note: "Vendor interface found — feature report visibility varies by OS/browser" }; + if (hasStdOnly) return { verdict: "limited", note: "Only standard boot-mouse interface visible to WebHID" }; + return { verdict: "unknown", note: "No collections visible — interface may be blocked" }; +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +async function scanDevices(devices: HIDDevice[]): Promise { + const results: DeviceResult[] = []; + + for (const device of devices) { + const vendorId = device.vendorId; + const productId = device.productId; + const brand = BRANDS[vendorId] ?? `Unknown (VID 0x${hex(vendorId)})`; + const isRazer = vendorId === 0x1532 || vendorId === 0x31E3; + const openmouseSupported = OPENMOUSE_SUPPORTED.has(vendorId); + + const { verdict: preVerdict, note: preNote } = assessCollections(vendorId, device.collections); + + let opened = false; + let openError: string | null = null; + let verdict: Verdict = preVerdict; + let verdictNote = preNote; + const txResults: DeviceResult["txResults"] = []; + + // Try to open + try { + if (!device.opened) await device.open(); + opened = true; + } catch (err) { + openError = err instanceof Error ? err.message : "Could not open device"; + if (preVerdict === "full" || preVerdict === "partial") { + verdict = "blocked"; + verdictNote = `OS/browser blocked open: ${openError}`; + } + } + + // Razer TX test (only if opened, and is Razer VID) + if (opened && vendorId === 0x1532) { + let anyOk = false; + for (const txId of RAZER_TX_IDS) { + const result = await tryRazerTx(device, txId); + txResults.push({ txId, ...result }); + if (result.ok) anyOk = true; + } + if (anyOk) { + verdict = "full"; + verdictNote = "Razer HID protocol responding — TX-ID test passed"; + } else if (preVerdict !== "blocked") { + verdict = "partial"; + verdictNote = "Device opened but TX-ID test received no valid response"; + } + } + + // Generic feature report probe (non-Razer, if opened) + if (opened && !isRazer && verdict !== "blocked") { + if (SKIP_FEATURE_PROBE.has(vendorId)) { + // No raw feature-report probing — this vendor's firmware has no safety + // guards and unrecognised bytes can corrupt device state. + verdictNote = `${brand} — interface accessible (probe skipped for safety)`; + } else { + const vendorCollections = device.collections.filter((c) => c.usagePage >= 0xFF00 && c.featureReports.length > 0); + if (vendorCollections.length > 0) { + const reportId = vendorCollections[0].featureReports[0].reportId; + try { + await device.receiveFeatureReport(reportId); + verdict = "full"; + verdictNote = "Feature report readable on vendor interface"; + } catch { + verdict = "partial"; + verdictNote = "Vendor interface accessible but feature report read failed"; + } + } + } + } + + results.push({ + device, + brand, + vendorId, + productId, + openmouseSupported, + isRazer, + opened, + openError, + verdict, + verdictNote, + txResults, + }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +function renderCard(result: DeviceResult): string { + const nameRaw = result.device.productName || `${result.brand} Mouse`; + const name = escapeHtml(nameRaw); + const vidpid = `VID_${hex(result.vendorId)} · PID_${hex(result.productId)}`; + + const badge = `${VERDICT_LABEL[result.verdict]}`; + const supportBadge = result.openmouseSupported + ? `OPENMOUSE` + : ""; + + // Collections + const ifaceRows = result.device.collections.map((col) => ` +
+ ${usagePageLabel(col.usagePage)} + Usage 0x${hex(col.usage, 2)} + ${reportSummary(col)} +
`).join(""); + + const ifaceSection = result.device.collections.length > 0 ? ` +
+
HID COLLECTIONS
+ ${ifaceRows} +
` : ""; + + // TX results (Razer) + const txSection = result.txResults.length > 0 ? ` +
+
RAZER TX-ID TEST
+ ${result.txResults.map((t) => ` +
+ TX 0x${hex(t.txId, 2)} + ${t.ok ? "✓ responded" : "✗ no response"} + ${t.firmware ? `FW ${t.firmware}` : ""} +
`).join("")} +
` : ""; + + // Open result + const openResult = result.opened + ? `
Device opened successfully
` + : `
${escapeHtml(result.openError ?? "Could not open")}
`; + + // Verdict note + const verdictNote = `
${escapeHtml(result.verdictNote)}
`; + + return `
+
+
+ ${name} + ${escapeHtml(result.brand)} · ${vidpid} +
+
+ ${supportBadge} + ${badge} +
+
+ ${ifaceSection} + ${txSection} + ${openResult} + ${verdictNote} +
`; +} + +function buildDiscordSummary(results: DeviceResult[]): string { + const lines: string[] = ["**OpenMouse HID Diagnostic Report**", ""]; + for (const r of results) { + const name = r.device.productName || `${r.brand} Mouse`; + const verdict = VERDICT_LABEL[r.verdict]; + lines.push(`**${name}** — \`${r.brand}\` | \`VID_${hex(r.vendorId)}\` | **${verdict}**`); + lines.push(`> ${r.verdictNote}`); + const collections = r.device.collections + .map((c) => `\`${usagePageLabel(c.usagePage)}\``) + .join(", "); + if (collections) lines.push(`> Interfaces: ${collections}`); + if (r.txResults.length > 0) { + const txSummary = r.txResults + .map((t) => `TX 0x${hex(t.txId, 2)}: ${t.ok ? `✅ FW ${t.firmware ?? "?"}` : "❌"}`) + .join(" | "); + lines.push(`> ${txSummary}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// App init +// --------------------------------------------------------------------------- + +const root = document.querySelector("#check-app"); +if (!root) throw new Error("check-app root not found"); + +const supportsWebHid = "hid" in navigator; + +root.innerHTML = ` +
+
+ OpenMouse / Mouse Check + ← Back to site +
+ +
+

HID DIAGNOSTICS

+

Mouse Check

+

Scan your connected gaming mice to see which interfaces are accessible via WebHID — and which require a native driver.

+
+ +
+ +
+ + You'll see a browser prompt to select devices. +
+ +
+
`; + +// Browser compat banner +const compatBanner = document.querySelector("#compat-banner")!; +if (supportsWebHid) { + compatBanner.innerHTML = `
WebHID is available in this browser — device scanning is supported.
`; +} else { + compatBanner.innerHTML = `
WebHID is not available. Use Chrome or Edge on desktop to run the diagnostics.
`; +} + +// Scan button +document.querySelector("#scan-btn")?.addEventListener("click", () => { + void runScan(); +}); + +async function runScan(): Promise { + const btn = document.querySelector("#scan-btn")!; + const area = document.querySelector("#results-area")!; + + if (!navigator.hid) return; + + btn.disabled = true; + btn.textContent = "Scanning…"; + area.innerHTML = `
Waiting for browser prompt…
`; + + let devices: HIDDevice[]; + try { + devices = await navigator.hid.requestDevice({ filters: SCAN_FILTERS }); + } catch { + area.innerHTML = `
Scan cancelled or no device selected.
`; + btn.disabled = false; + btn.textContent = "Scan for mice"; + return; + } + + if (devices.length === 0) { + area.innerHTML = `
No matching devices selected.
`; + btn.disabled = false; + btn.textContent = "Scan for mice"; + return; + } + + area.innerHTML = `
Testing ${devices.length} device${devices.length !== 1 ? "s" : ""}…
`; + + let results: DeviceResult[]; + try { + results = await scanDevices(devices); + } catch (err) { + area.innerHTML = `
Scan error: ${escapeHtml(err instanceof Error ? err.message : String(err))}
`; + btn.disabled = false; + btn.textContent = "Scan for mice"; + return; + } + + const cards = results.map(renderCard).join(""); + const summaryText = buildDiscordSummary(results); + + area.innerHTML = ` +
+ RESULTS — ${results.length} DEVICE${results.length !== 1 ? "S" : ""} +
+
${cards}
+
+

Share these results with the OpenMouse team on Discord.

+ +
`; + + document.querySelector("#copy-btn")?.addEventListener("click", (ev) => { + void navigator.clipboard.writeText(summaryText).then(() => { + const btn2 = ev.currentTarget as HTMLButtonElement; + btn2.textContent = "Copied!"; + btn2.classList.add("copied"); + setTimeout(() => { + btn2.textContent = "Copy for Discord"; + btn2.classList.remove("copied"); + }, 2000); + }); + }); + + btn.disabled = false; + btn.textContent = "Scan again"; +} diff --git a/src/control-template.ts b/src/control-template.ts index 73c1af61..f9bc9e1a 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -33,6 +33,7 @@ export function controlTemplate(buildLabel: string): string { ${buildLabel} Development build - not the final product diff --git a/src/control.css b/src/control.css index bb937d9f..de7c57b0 100644 --- a/src/control.css +++ b/src/control.css @@ -94,7 +94,7 @@ button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid nav { display: grid; gap: .3rem; margin-top: 1.4rem; } -.nav-item { padding: .7rem .8rem; border: 0; border-radius: 7px; background: transparent; color: var(--muted); text-align: left; font-size: .82rem; } +.nav-item { display: block; width: 100%; box-sizing: border-box; padding: .7rem .8rem; border: 0; border-radius: 7px; background: transparent; color: var(--muted); text-align: left; text-decoration: none; font-size: .82rem; } .nav-item:hover { background: #1b1b1e; color: #fff; } .control-shell .nav-item[aria-current="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); } .interface-settings-button::before { margin-right: .5rem; color: var(--ui-accent); content: "⚙"; font-size: .72rem; } diff --git a/vite.config.ts b/vite.config.ts index 6e00ee7e..a828ba6b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ rollupOptions: { input: { main: resolve(__dirname, "index.html"), + check: resolve(__dirname, "check.html"), }, }, },