Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions domain-info-card.css
Original file line number Diff line number Diff line change
@@ -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;
}
172 changes: 172 additions & 0 deletions domain-info-card.js
Original file line number Diff line number Diff line change
@@ -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 `
<div class="dic-row" role="row">
<span class="dic-label" role="rowheader">
<span class="dic-icon" aria-hidden="true">${icon}</span>
${label}
</span>
<span class="${valueClass}" role="cell">${displayValue}</span>
</div>`;
}

/**
* 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 = `
<div class="dic-header">
<span class="dic-header-icon" aria-hidden="true">&#127760;</span>
<span class="dic-header-title">Domain Information</span>
</div>`;

// ── IDLE STATE ──────────────────────────────────────────────────────────────
if (!url || url.trim() === "") {
container.innerHTML = `
<div class="dic-card" aria-label="Domain Information Card">
${header}
<div class="dic-idle">Scan a URL to see its structural breakdown.</div>
</div>`;
return;
}

const result = parseUrl(url);

// ── ERROR STATE ─────────────────────────────────────────────────────────────
if (!result.ok) {
container.innerHTML = `
<div class="dic-card" aria-label="Domain Information Card">
${header}
<div class="dic-error">Couldn&#39;t parse this URL. Check the format and try again.</div>
</div>`;
return;
}

// ── SUCCESS STATE ───────────────────────────────────────────────────────────
const { protocol, hostname, subdomain, domain, tld, path, query, fragment } = result.fields;

container.innerHTML = `
<div class="dic-card" aria-label="Domain Information Card">
${header}
<div class="dic-body" role="table" aria-label="URL component breakdown">
${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)}
</div>
</div>`;
}

// Expose to global scope so script.js (non-module) can call it directly.
window.renderDomainInfoCard = renderDomainInfoCard;
Loading