Compare commits

..

No commits in common. "691394445a7e0ca8b4a738f3ed024da135c165c3" and "6a652ed4f36cb06be3dc5893922acd3bb600c675" have entirely different histories.

6 changed files with 238 additions and 691 deletions

View file

@ -5,7 +5,6 @@ use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::email;
use crate::serve::AppState;
@ -258,92 +257,6 @@ fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(
)
}
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn string_from_value(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Object(map) => map
.get("address")
.or_else(|| map.get("email"))
.or_else(|| map.get("value"))
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
_ => None,
}
}
fn recipients_from_value(value: Option<&Value>) -> Vec<Recipient> {
let Some(value) = value else {
return Vec::new();
};
match value {
Value::Array(values) => values
.iter()
.filter_map(|v| string_from_value(v).map(Recipient::Address))
.collect(),
_ => string_from_value(value)
.map(Recipient::Address)
.into_iter()
.collect(),
}
}
fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| {
value_at_path(value, path)
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
})
}
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> {
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
for message in messages {
if let Some(reply) = extract_qa_reply_from_message(
&recipients_from_value(value_at_path(message, &["envelope", "to"])),
expected_domain,
string_at_paths(
message,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.as_deref(),
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
) {
return Some(reply);
}
}
}
extract_qa_reply_from_message(
&recipients_from_value(value_at_path(payload, &["envelope", "to"])),
expected_domain,
string_at_paths(
payload,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.as_deref(),
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
)
}
fn extract_qa_reply_from_message(
recipients: &[Recipient],
expected_domain: &str,
@ -377,27 +290,14 @@ fn extract_qa_reply_from_message(
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: String,
Json(payload): Json<MtaHookPayload>,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!("Rejected webhook: invalid secret");
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
let payload_value: Value = match serde_json::from_str(&body) {
Ok(payload) => payload,
Err(err) => {
eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}");
return Ok(Json(MtaHookResponse { action: "accept" }));
}
};
let parsed_reply = serde_json::from_value::<MtaHookPayload>(payload_value.clone())
.ok()
.and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain))
.or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain));
if let Some((id, body)) = parsed_reply {
if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) {
let db = state
.db
.lock()
@ -413,16 +313,15 @@ pub async fn webhook(
return Ok(Json(MtaHookResponse { action: "discard" }));
}
eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
// No Q&A recipient matched — let Stalwart deliver normally
Ok(Json(MtaHookResponse { action: "accept" }))
}
#[cfg(test)]
mod tests {
use axum::http::HeaderMap;
use serde_json::Value;
use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload};
#[test]
fn extracts_reply_from_current_stalwart_payload() {
@ -492,27 +391,4 @@ mod tests {
assert!(webhook_secret_matches(&headers, "topsecret"));
}
#[test]
fn extracts_reply_from_value_with_string_recipient() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": "qa@extremist.software"
},
"message": {
"headers": {
"subject": "Re: 9 - hi"
},
"contents": "Answer body"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((9, "Answer body".to_string()))
);
}
}

View file

@ -6,130 +6,95 @@
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
<title>Jet Pham - Software Extremist</title>
<meta
name="description"
content="Jet Pham's personal website. Software extremist."
/>
<meta name="description" content="Jet Pham's personal website. Software extremist." />
<link rel="canonical" href="https://jetpham.com/" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-icon.png" />
<link
rel="preload"
href="/Web437_IBM_VGA_8x16.woff"
as="font"
type="font/woff"
crossorigin
/>
<link rel="preload" href="/Web437_IBM_VGA_8x16.woff" as="font" type="font/woff" crossorigin />
<meta property="og:title" content="Jet Pham - Software Extremist" />
<meta
property="og:description"
content="Jet Pham's personal website. Software extremist."
/>
<meta property="og:description" content="Jet Pham's personal website. Software extremist." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://jetpham.com/" />
<meta property="og:site_name" content="Jet Pham" />
<meta
property="og:image"
content="https://jetpham.com/web-app-manifest-512x512.png"
/>
<meta property="og:image" content="https://jetpham.com/web-app-manifest-512x512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Jet Pham" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
<meta
name="twitter:description"
content="Jet Pham's personal website. Software extremist."
/>
<meta
name="twitter:image"
content="https://jetpham.com/web-app-manifest-512x512.png"
/>
<meta name="twitter:description" content="Jet Pham's personal website. Software extremist." />
<meta name="twitter:image" content="https://jetpham.com/web-app-manifest-512x512.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Jet Pham",
"givenName": "Jet",
"familyName": "Pham",
"description": "Software extremist.",
"url": "https://jetpham.com",
"jobTitle": "Software Extremist",
"hasOccupation": {
"@type": "Occupation",
"name": "Hacker"
},
"email": "jet@extremist.software",
"image": "https://jetpham.com/jet.svg",
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "University of San Francisco",
"url": "https://www.usfca.edu"
},
"hasCredential": {
"@type": "EducationalOccupationalCredential",
"credentialCategory": "degree",
"name": "Bachelor of Science in Computer Science"
},
"homeLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"workLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"memberOf": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"affiliation": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"sameAs": [
"https://github.com/jetpham",
"https://x.com/exmistsoftware",
"https://bsky.app/profile/extremist.software",
"https://git.extremist.software"
]
}
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Jet Pham",
"givenName": "Jet",
"familyName": "Pham",
"description": "Software extremist.",
"url": "https://jetpham.com",
"jobTitle": "Software Extremist",
"hasOccupation": {
"@type": "Occupation",
"name": "Hacker"
},
"email": "jet@extremist.software",
"image": "https://jetpham.com/jet.svg",
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "University of San Francisco",
"url": "https://www.usfca.edu"
},
"hasCredential": {
"@type": "EducationalOccupationalCredential",
"credentialCategory": "degree",
"name": "Bachelor of Science in Computer Science"
},
"homeLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"workLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"memberOf": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"affiliation": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"sameAs": [
"https://github.com/jetpham",
"https://x.com/exmistsoftware",
"https://bsky.app/profile/extremist.software",
"https://git.extremist.software"
]
}
</script>
</head>
<body style="background: #000">
<a class="skip-link" href="#outlet">Skip to content</a>
<canvas
id="canvas"
class="fixed top-0 left-0 -z-10 h-screen w-screen"
aria-hidden="true"
></canvas>
<body style="background:#000">
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas>
<nav aria-label="Main navigation" class="flex justify-center px-4">
<div
class="relative mt-[2ch] w-full max-w-[66.666667%] min-w-fit px-[2ch] py-[1ch]"
>
<div class="relative px-[2ch] py-[1ch] mt-[2ch] w-full max-w-[66.666667%] min-w-fit">
<div
class="pointer-events-none absolute inset-0"
style="
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
"
aria-hidden="true"
></div>
<div
class="absolute inset-0 border-2 border-white"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);"
aria-hidden="true"
></div>
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
<div class="relative z-10 flex justify-center gap-[2ch]">
<a href="/" data-nav-link>[HOME]</a>
<a href="/qa" data-nav-link>[Q&amp;A]</a>
<a href="/">[HOME]</a>
<a href="/qa">[Q&A]</a>
</div>
</div>
</nav>
<main id="outlet" tabindex="-1"></main>
<div id="outlet"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,62 +0,0 @@
const DRAFT_KEY = "qa-draft";
const relativeTime = new Intl.RelativeTimeFormat(undefined, {
numeric: "auto",
});
function getValidDate(dateString: string): Date | null {
const date = new Date(dateString);
return Number.isNaN(date.getTime()) ? null : date;
}
export function readQuestionDraft(): string {
try {
return localStorage.getItem(DRAFT_KEY) ?? "";
} catch {
return "";
}
}
export function writeQuestionDraft(value: string) {
try {
localStorage.setItem(DRAFT_KEY, value);
} catch {
// Ignore storage failures.
}
}
export function clearQuestionDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
// Ignore storage failures.
}
}
export function formatExactTimestamp(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function formatRelativeTimestamp(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
const diffMs = date.getTime() - Date.now();
const diffMinutes = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
if (Math.abs(diffMinutes) < 60)
return relativeTime.format(diffMinutes, "minute");
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day");
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
date,
);
}

View file

@ -1,11 +1,4 @@
import { getQuestions, submitQuestion, type Question } from "~/lib/api";
import {
clearQuestionDraft,
formatExactTimestamp,
formatRelativeTimestamp,
readQuestionDraft,
writeQuestionDraft,
} from "~/lib/qa";
import { getQuestions, submitQuestion } from "~/lib/api";
import { frostedBox } from "~/components/frosted-box";
function escapeHtml(str: string): string {
@ -14,197 +7,81 @@ function escapeHtml(str: string): string {
return div.innerHTML;
}
function formatQuestionDates(question: Question): string {
const asked = formatRelativeTimestamp(question.created_at);
const answered = formatRelativeTimestamp(question.answered_at);
const askedExact = formatExactTimestamp(question.created_at);
const answeredExact = formatExactTimestamp(question.answered_at);
return `
<span title="${escapeHtml(askedExact)}">Asked ${escapeHtml(asked)}</span>
&middot;
<span title="${escapeHtml(answeredExact)}">Answered ${escapeHtml(answered)}</span>`;
}
function renderQuestions(list: HTMLElement, questions: Question[]) {
if (questions.length === 0) {
list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">No answers yet</legend>
<p>No questions have been answered yet.</p>
<p class="qa-meta">Ask something above and check back once a reply is posted.</p>
</fieldset>`;
return;
}
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="qa-meta">${formatQuestionDates(q)}</p>
</fieldset>`,
)
.join("");
}
export async function qaPage(outlet: HTMLElement) {
const draft = readQuestionDraft();
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<form id="qa-form" novalidate>
<form id="qa-form">
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
<label class="sr-only" for="qa-input">Question</label>
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
aria-describedby="qa-helper qa-validation char-count"
placeholder="Type your question...">${escapeHtml(draft)}</textarea>
<p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p>
<div class="mt-[0.5ch] flex justify-between gap-[1ch]">
<p id="qa-validation" class="qa-meta" aria-live="polite"></p>
<span id="char-count" class="qa-meta">${draft.length}/200</span>
</div>
<div class="mt-[1ch] flex justify-between items-center gap-[1ch]">
<p id="qa-status" class="qa-meta" aria-live="polite"></p>
<button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
placeholder="Type your question..."></textarea>
<div class="flex justify-between mt-[1ch]">
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
<button type="submit" class="qa-button">[SUBMIT]</button>
</div>
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
</fieldset>
</form>
<div id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div>
<div id="qa-list" class="mt-[2ch]">Loading...</div>
`)}
</div>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const submitButton = document.getElementById(
"qa-submit",
) as HTMLButtonElement;
const charCount = document.getElementById("char-count") as HTMLSpanElement;
const validation = document.getElementById(
"qa-validation",
) as HTMLParagraphElement;
const status = document.getElementById("qa-status") as HTMLParagraphElement;
const list = document.getElementById("qa-list") as HTMLDivElement;
let isSubmitting = false;
let hasInteracted = draft.trim().length > 0;
function setStatus(message: string, color: string) {
status.textContent = message;
status.style.color = color;
}
function updateValidation() {
const trimmed = input.value.trim();
const remaining = 200 - input.value.length;
charCount.textContent = `${input.value.length}/200`;
if (trimmed.length === 0 && hasInteracted) {
validation.textContent = "Question cannot be empty.";
validation.style.color = "var(--light-red)";
input.setAttribute("aria-invalid", "true");
return false;
}
input.removeAttribute("aria-invalid");
if (remaining <= 20) {
validation.textContent = `${remaining} characters left.`;
validation.style.color =
remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)";
return true;
}
validation.textContent = "";
return true;
}
async function loadQuestions() {
list.textContent = "Loading answered questions...";
list.style.color = "var(--light-gray)";
list.style.textAlign = "left";
try {
const questions = await getQuestions();
renderQuestions(list, questions);
} catch {
list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--light-red);">Load failed</legend>
<p>Failed to load answered questions.</p>
<p class="qa-meta">
<button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button>
</p>
</fieldset>`;
list.style.color = "var(--light-red)";
const retryButton = document.getElementById(
"qa-retry",
) as HTMLButtonElement | null;
retryButton?.addEventListener("click", () => {
void loadQuestions();
});
}
}
const charCount = document.getElementById("char-count")!;
const status = document.getElementById("qa-status")!;
const list = document.getElementById("qa-list")!;
input.addEventListener("input", () => {
hasInteracted = true;
writeQuestionDraft(input.value);
updateValidation();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
charCount.textContent = `${input.value.length}/200`;
});
form.addEventListener("submit", (e) => {
e.preventDefault();
if (isSubmitting) return;
const question = input.value.trim();
hasInteracted = true;
if (!updateValidation() || !question) {
setStatus("Write a question before submitting.", "var(--light-red)");
input.focus();
return;
}
if (!question) return;
isSubmitting = true;
submitButton.disabled = true;
setStatus("Submitting...", "var(--light-gray)");
status.textContent = "Submitting...";
status.style.color = "var(--light-gray)";
submitQuestion(question)
.then(() => {
input.value = "";
clearQuestionDraft();
hasInteracted = false;
updateValidation();
setStatus(
"Question submitted! It will appear here once answered.",
"var(--light-green)",
);
input.focus();
charCount.textContent = "0/200";
status.textContent = "Question submitted! It will appear here once answered.";
status.style.color = "var(--light-green)";
})
.catch((err: unknown) => {
setStatus(
err instanceof Error ? err.message : "Failed to submit question.",
"var(--light-red)",
);
})
.finally(() => {
isSubmitting = false;
submitButton.disabled = false;
status.textContent = err instanceof Error ? err.message : "Failed to submit question.";
status.style.color = "var(--light-red)";
});
});
updateValidation();
await loadQuestions();
try {
const questions = await getQuestions();
if (questions.length === 0) {
list.textContent = "No questions answered yet.";
list.style.color = "var(--dark-gray)";
} else {
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
Asked ${q.created_at} · Answered ${q.answered_at}
</p>
</fieldset>`,
)
.join("");
}
} catch {
list.textContent = "Failed to load questions.";
list.style.color = "var(--light-red)";
list.style.textAlign = "center";
}
}

View file

@ -27,26 +27,12 @@ export function route(path: string, handler: PageHandler) {
export function navigate(path: string) {
history.pushState(null, "", path);
window.scrollTo({ top: 0, behavior: "auto" });
void render(true);
void render();
}
function updateNavState(path: string) {
const navLinks =
document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]");
navLinks.forEach((link) => {
if (link.pathname === path) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
}
async function render(focusOutlet = false) {
async function render() {
const path = location.pathname;
const outlet = document.getElementById("outlet")!;
updateNavState(path);
for (const r of routes) {
const match = path.match(r.pattern);
@ -57,7 +43,6 @@ async function render(focusOutlet = false) {
});
outlet.innerHTML = "";
await r.handler(outlet, params);
if (focusOutlet) outlet.focus();
return;
}
}
@ -66,20 +51,14 @@ async function render(focusOutlet = false) {
if (notFoundHandler) {
await notFoundHandler(outlet, {});
}
if (focusOutlet) outlet.focus();
}
export function initRouter() {
window.addEventListener("popstate", () => void render(true));
window.addEventListener("popstate", () => void render());
document.addEventListener("click", (e) => {
const anchor = (e.target as HTMLElement).closest("a");
if (
anchor?.origin === location.origin &&
!anchor.hash &&
!anchor.hasAttribute("download") &&
!anchor.hasAttribute("target")
) {
if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) {
e.preventDefault();
navigate(anchor.pathname);
}

View file

@ -9,232 +9,144 @@
}
:root {
--font-sans:
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"Source Code Pro", monospace;
--font-mono:
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"Source Code Pro", monospace;
--font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
--font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
/* 16-color palette */
--black: #000000;
--blue: #0000aa;
--green: #00aa00;
--cyan: #00aaaa;
--red: #aa0000;
--magenta: #aa00aa;
--brown: #aa5500;
--light-gray: #aaaaaa;
--blue: #0000AA;
--green: #00AA00;
--cyan: #00AAAA;
--red: #AA0000;
--magenta: #AA00AA;
--brown: #AA5500;
--light-gray: #AAAAAA;
--dark-gray: #555555;
--light-blue: #5555ff;
--light-green: #55ff55;
--light-cyan: #55ffff;
--light-red: #ff5555;
--light-magenta: #ff55ff;
--yellow: #ffff55;
--white: #ffffff;
--light-blue: #5555FF;
--light-green: #55FF55;
--light-cyan: #55FFFF;
--light-red: #FF5555;
--light-magenta: #FF55FF;
--yellow: #FFFF55;
--white: #FFFFFF;
}
/* Global BBS-style 80-character width constraint - responsive */
html {
height: 100%;
width: min(
80ch,
100vw
); /* 80 characters wide on desktop, full width on mobile */
padding: 1rem;
margin: 0 auto;
font-family: "IBM VGA", monospace;
font-size: 1.25rem; /* Smaller font size for mobile */
white-space: normal;
line-height: 1;
box-sizing: border-box;
}
/* Desktop font size */
@media (min-width: 768px) {
/* Global BBS-style 80-character width constraint - responsive */
html {
font-size: 1.5rem;
height: 100%;
width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
padding: 1rem;
margin: 0 auto;
font-family: "IBM VGA", monospace;
font-size: 1.25rem; /* Smaller font size for mobile */
white-space: normal;
line-height: 1;
box-sizing: border-box;
}
}
/* Apply CGA theme to body */
body {
height: 100%;
background-color: var(--black);
color: var(--white);
margin: 0;
padding: 0;
/* Desktop font size */
@media (min-width: 768px) {
html {
font-size: 1.5rem;
}
}
/* Apply CGA theme to body */
body {
height: 100%;
background-color: var(--black);
color: var(--white);
margin: 0;
padding: 0;
}
/* Global focus ring styles for all tabbable elements */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus,
[tabindex]:focus,
[contenteditable]:focus {
outline: 2px solid white;
outline-offset: -2px;
border-radius: 0;
}
/* Link styles - blue without underline */
a {
color: var(--light-blue);
text-decoration: none;
}
a:hover {
background-color: var(--light-blue);
color: var(--black);
}
#outlet {
display: block;
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 2px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* Global focus ring styles for all tabbable elements */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus,
[tabindex]:focus,
[contenteditable]:focus {
outline: 2px solid white;
outline-offset: -2px;
border-radius: 0;
}
.qa-button {
border: none;
padding: 0.25ch 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
/* Link styles - blue without underline */
a {
color: var(--light-blue);
text-decoration: none;
text-underline-offset: 0.2ch;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
a:hover,
a:focus-visible,
a[aria-current="page"] {
background-color: var(--light-blue);
color: var(--black);
text-decoration: underline;
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
a[href^="http://"]::after,
a[href^="https://"]::after {
content: " [EXT]";
color: var(--dark-gray);
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
.skip-link {
position: absolute;
left: 1rem;
top: 0;
transform: translateY(-150%);
padding: 0.5ch 1ch;
background: var(--yellow);
color: var(--black);
z-index: 20;
}
.project-content p {
margin-bottom: 1ch;
}
.skip-link:focus {
transform: translateY(1rem);
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 2px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.project-content code {
color: var(--yellow);
}
.qa-textarea::placeholder {
color: var(--dark-gray);
}
.project-content pre {
border: 2px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
.qa-button {
border: none;
padding: 0.25ch 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
.project-content a {
color: var(--light-blue);
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
.qa-button:disabled {
color: var(--dark-gray);
cursor: wait;
}
.qa-button:disabled:hover {
background: transparent;
color: var(--dark-gray);
}
.qa-helper {
margin-top: 1ch;
color: var(--dark-gray);
}
.qa-meta {
margin-top: 0.5ch;
color: var(--dark-gray);
}
.qa-inline-action {
border: none;
background: transparent;
color: var(--light-blue);
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2ch;
}
.qa-inline-action:hover,
.qa-inline-action:focus-visible {
background-color: var(--light-blue);
color: var(--black);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
.project-content p {
margin-bottom: 1ch;
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
.project-content code {
color: var(--yellow);
}
.project-content pre {
border: 2px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
.project-content a {
color: var(--light-blue);
}