diff --git a/domain-info-card.css b/domain-info-card.css new file mode 100644 index 0000000..6e839ec --- /dev/null +++ b/domain-info-card.css @@ -0,0 +1,214 @@ +/* + * domain-info-card.css + * Styles for the Domain Information Card — issue #184 (CyberShield_URL). + * + * Dark, monospace, "packet inspector" aesthetic — matches the threat-dashboard + * design language already established in style.css. + * + * No dependency on Tailwind or any external component library. + */ + +/* ── Container ─────────────────────────────────────────────────────────────── */ +#domainInfoCard { + margin-top: 16px; +} + +/* ── Card shell ────────────────────────────────────────────────────────────── */ +.dic-card { + background: #161f27; + border: 1px solid #26323d; + border-radius: 12px; + overflow: hidden; + font-family: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, + Menlo, monospace; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.35); + /* Subtle slide-in when the card first appears */ + animation: dic-slide-in 0.25s ease; +} + +@keyframes dic-slide-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Header ────────────────────────────────────────────────────────────────── */ +.dic-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px; + background: #10161c; + border-bottom: 1px solid #26323d; +} + +.dic-header-icon { + font-size: 1rem; + line-height: 1; +} + +.dic-header-title { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #a8b8c8; +} + +/* ── Idle & error text ─────────────────────────────────────────────────────── */ +.dic-idle, +.dic-error { + padding: 20px 18px; + font-size: 0.8rem; + color: #5a7080; + font-style: italic; +} + +.dic-error { + color: #c04a3a; +} + +/* ── Row table ─────────────────────────────────────────────────────────────── */ +.dic-body { + padding: 6px 0; +} + +.dic-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 9px 18px; + border-bottom: 1px solid rgba(38, 50, 61, 0.6); + gap: 12px; + /* Micro-animation: rows fade in on render */ + animation: dic-row-fade 0.2s ease both; +} + +.dic-row:last-child { + border-bottom: none; +} + +.dic-row:hover { + background: rgba(53, 216, 200, 0.04); + transition: background 0.15s ease; +} + +@keyframes dic-row-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ── Reduced-motion: disable all card/row animations ────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .dic-card, + .dic-row { + animation: none; + } +} + +/* ── Label ─────────────────────────────────────────────────────────────────── */ +.dic-label { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5a7080; + white-space: nowrap; + flex-shrink: 0; +} + +.dic-icon { + font-size: 0.9rem; + line-height: 1; +} + +/* ── Value ─────────────────────────────────────────────────────────────────── */ +.dic-value { + font-size: 0.82rem; + text-align: right; + word-break: break-all; + flex: 1; + min-width: 0; +} + +/* Populated value: accent cyan */ +.dic-populated { + color: #35d8c8; +} + +/* Empty/missing placeholder: muted amber — visually distinct from "has data" */ +.dic-empty { + color: #a8894f; + font-style: italic; +} + +/* ── Responsive: <=420px — stack label above value, both left-aligned ──────── */ +@media (max-width: 420px) { + .dic-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .dic-value { + text-align: left; + } +} + +/* ── Light-mode overrides (matches html.light-mode toggle in style.css) ──────── */ +.light-mode .dic-card { + background: #f4f7fa; + border-color: #d0dde8; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.light-mode .dic-header { + background: #e8eff5; + border-color: #d0dde8; +} + +.light-mode .dic-header-title { + color: #4a6070; +} + +.light-mode .dic-row { + border-color: rgba(208, 221, 232, 0.7); +} + +.light-mode .dic-row:hover { + background: rgba(53, 216, 200, 0.06); +} + +.light-mode .dic-label { + color: #4a6070; +} + +.light-mode .dic-populated { + color: #0e9e8a; +} + +.light-mode .dic-empty { + color: #a07830; +} + +.light-mode .dic-idle, +.light-mode .dic-error { + color: #8aa0b0; +} + +.light-mode .dic-error { + color: #c04a3a; +} diff --git a/domain-info-card.js b/domain-info-card.js new file mode 100644 index 0000000..7cb6ca8 --- /dev/null +++ b/domain-info-card.js @@ -0,0 +1,172 @@ +/** + * domain-info-card.js + * Implements #184 — Domain Information Card for the CyberShield threat dashboard. + * + * Integration: call renderDomainInfoCard(url) after showResult() in checkSecurity(). + * The card is injected into #domainInfoCard in index.html. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// PSL LIMITATION (KNOWN FOLLOW-UP): +// The splitHostname heuristic below treats the last dot-separated label as the +// TLD and does NOT consult the Public Suffix List (PSL). Compound TLDs such as +// .co.uk or .com.au will split incorrectly (e.g. mail.google.co.uk → domain +// "co", TLD ".uk"). Fixing this properly requires a real PSL dependency (`psl` +// or `tldts`) and should be a separate, deliberate change — see issue #184. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Splits a hostname into { subdomain, domain, tld }. + * Returns null values where the concept does not apply (IPs, localhost, etc.). + * + * @param {string} hostname + * @returns {{ subdomain: string|null, domain: string, tld: string|null }} + */ +function splitHostname(hostname) { + const isIPv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + const isIPv6 = hostname.includes(":"); + if (isIPv4 || isIPv6 || !hostname.includes(".")) { + // IP addresses, "localhost", or any single-label host have no + // meaningful subdomain/root/TLD split. + return { subdomain: null, domain: hostname, tld: null }; + } + const labels = hostname.split("."); + const tld = labels[labels.length - 1]; + const domain = labels[labels.length - 2]; + const subdomainLabels = labels.slice(0, -2); + const subdomain = subdomainLabels.length > 0 ? subdomainLabels.join(".") : null; + return { subdomain, domain, tld }; +} + +/** + * Parse a raw URL string using the native URL API. + * Returns a result object or { ok: false } on parse failure. + * + * @param {string} rawUrl + * @returns {{ ok: true, fields: object } | { ok: false }} + */ +function parseUrl(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch (_) { + return { ok: false }; + } + + const protocol = parsed.protocol.replace(":", "").toUpperCase(); + const hostname = parsed.hostname; + + // Build query string: join all key=value pairs with " & "; null if zero params. + const queryPairs = []; + parsed.searchParams.forEach((value, key) => { + queryPairs.push(value ? `${key}=${value}` : key); + }); + const query = queryPairs.length > 0 ? queryPairs.join(" & ") : null; + + // Fragment: strip leading "#"; null if empty. + const fragment = parsed.hash ? parsed.hash.replace(/^#/, "") : null; + + // Path: parsed.pathname is always at least "/"; treat "/" as meaningful. + const path = parsed.pathname || null; + + const { subdomain, domain, tld } = splitHostname(hostname); + + return { + ok: true, + fields: { + protocol, + hostname, + subdomain, + domain, + tld: tld ? `.${tld}` : null, + path, + query, + fragment, + }, + }; +} + +/** + * Renders a single info row. + * + * @param {string} icon - emoji icon + * @param {string} label - uppercase label text + * @param {string|null} value - field value; null/empty renders em dash placeholder + * @returns {string} HTML string + */ +function infoRow(icon, label, value) { + const isEmpty = value === null || value === undefined || value === ""; + const displayValue = isEmpty ? "\u2014" : value; + const valueClass = isEmpty ? "dic-value dic-empty" : "dic-value dic-populated"; + return ` +
+ + + ${label} + + ${displayValue} +
`; +} + +/** + * Renders the Domain Information Card into the #domainInfoCard element. + * Three states: + * idle — no url provided yet + * error — URL constructor threw (malformed string) + * success — all 8 fields rendered + * + * @param {string|undefined} url - the just-scanned URL string + */ +function renderDomainInfoCard(url) { + const container = document.getElementById("domainInfoCard"); + if (!container) return; + + const header = ` +
+ + Domain Information +
`; + + // ── IDLE STATE ────────────────────────────────────────────────────────────── + if (!url || url.trim() === "") { + container.innerHTML = ` +
+ ${header} +
Scan a URL to see its structural breakdown.
+
`; + return; + } + + const result = parseUrl(url); + + // ── ERROR STATE ───────────────────────────────────────────────────────────── + if (!result.ok) { + container.innerHTML = ` +
+ ${header} +
Couldn't parse this URL. Check the format and try again.
+
`; + return; + } + + // ── SUCCESS STATE ─────────────────────────────────────────────────────────── + const { protocol, hostname, subdomain, domain, tld, path, query, fragment } = result.fields; + + container.innerHTML = ` +
+ ${header} +
+ ${infoRow("\uD83C\uDF10", "Protocol", protocol)} + ${infoRow("\uD83C\uDFE0", "Hostname", hostname)} + ${infoRow("\uD83E\uDDE9", "Subdomain", subdomain)} + ${infoRow("\uD83C\uDF0D", "Domain", domain)} + ${infoRow("\uD83C\uDFF7\uFE0F", "TLD", tld)} + ${infoRow("\uD83D\uDCC2", "Path", path)} + ${infoRow("\uD83D\uDD0D", "Query", query)} + ${infoRow("\uD83D\uDD16", "Fragment", fragment)} +
+
`; +} + +// Expose to global scope so script.js (non-module) can call it directly. +window.renderDomainInfoCard = renderDomainInfoCard; diff --git a/index.html b/index.html index 7033a3a..10d9719 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ CyberShield — URL Security Scanner + @@ -80,6 +81,8 @@

CyberSheild URL Scanner

+ +
@@ -170,5 +173,6 @@

Resources

+ diff --git a/script.js b/script.js index 2c902f0..609a9b3 100644 --- a/script.js +++ b/script.js @@ -196,10 +196,12 @@ async function checkSecurity() { saveToHistory(url, 'safe', []); showResult('safe', '✓ URL is Safe', 'No known threats detected via Google Safe Browsing.', url, []); } + renderDomainInfoCard(url); // domain info card — single hoisted call covers both safe & danger } catch (err) { showResult('error', 'Backend Not Connected', `Ensure your backend server is running.
Error: ${err.message}`, '', []); + renderDomainInfoCard(''); // reset card to idle on backend failure } finally { if (btn) btn.disabled = false; }