Compare commits

..

2 commits

6 changed files with 693 additions and 240 deletions

View file

@ -5,6 +5,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::Json; use axum::Json;
use base64::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::email; use crate::email;
use crate::serve::AppState; use crate::serve::AppState;
@ -257,6 +258,92 @@ 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( fn extract_qa_reply_from_message(
recipients: &[Recipient], recipients: &[Recipient],
expected_domain: &str, expected_domain: &str,
@ -290,14 +377,27 @@ fn extract_qa_reply_from_message(
pub async fn webhook( pub async fn webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<MtaHookPayload>, body: String,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> { ) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
if !webhook_secret_matches(&headers, &state.webhook_secret) { if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!("Rejected webhook: invalid secret"); eprintln!("Rejected webhook: invalid secret");
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
} }
if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) { 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 {
let db = state let db = state
.db .db
.lock() .lock()
@ -313,15 +413,16 @@ pub async fn webhook(
return Ok(Json(MtaHookResponse { action: "discard" })); return Ok(Json(MtaHookResponse { action: "discard" }));
} }
// No Q&A recipient matched — let Stalwart deliver normally eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
Ok(Json(MtaHookResponse { action: "accept" })) Ok(Json(MtaHookResponse { action: "accept" }))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::http::HeaderMap; use axum::http::HeaderMap;
use serde_json::Value;
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload}; use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
#[test] #[test]
fn extracts_reply_from_current_stalwart_payload() { fn extracts_reply_from_current_stalwart_payload() {
@ -391,4 +492,27 @@ mod tests {
assert!(webhook_secret_matches(&headers, "topsecret")); 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,95 +6,130 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-title" content="Jet Pham" /> <meta name="apple-mobile-web-app-title" content="Jet Pham" />
<title>Jet Pham - Software Extremist</title> <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="canonical" href="https://jetpham.com/" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-icon.png" /> <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: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:type" content="website" />
<meta property="og:url" content="https://jetpham.com/" /> <meta property="og:url" content="https://jetpham.com/" />
<meta property="og:site_name" content="Jet Pham" /> <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:width" content="512" />
<meta property="og:image:height" content="512" /> <meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Jet Pham" /> <meta property="og:image:alt" content="Jet Pham" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Jet Pham - Software Extremist" /> <meta name="twitter:title" content="Jet Pham - Software Extremist" />
<meta name="twitter:description" content="Jet Pham's personal website. Software extremist." /> <meta
<meta name="twitter:image" content="https://jetpham.com/web-app-manifest-512x512.png" /> 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"> <script type="application/ld+json">
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Person", "@type": "Person",
"name": "Jet Pham", "name": "Jet Pham",
"givenName": "Jet", "givenName": "Jet",
"familyName": "Pham", "familyName": "Pham",
"description": "Software extremist.", "description": "Software extremist.",
"url": "https://jetpham.com", "url": "https://jetpham.com",
"jobTitle": "Software Extremist", "jobTitle": "Software Extremist",
"hasOccupation": { "hasOccupation": {
"@type": "Occupation", "@type": "Occupation",
"name": "Hacker" "name": "Hacker"
}, },
"email": "jet@extremist.software", "email": "jet@extremist.software",
"image": "https://jetpham.com/jet.svg", "image": "https://jetpham.com/jet.svg",
"alumniOf": { "alumniOf": {
"@type": "CollegeOrUniversity", "@type": "CollegeOrUniversity",
"name": "University of San Francisco", "name": "University of San Francisco",
"url": "https://www.usfca.edu" "url": "https://www.usfca.edu"
}, },
"hasCredential": { "hasCredential": {
"@type": "EducationalOccupationalCredential", "@type": "EducationalOccupationalCredential",
"credentialCategory": "degree", "credentialCategory": "degree",
"name": "Bachelor of Science in Computer Science" "name": "Bachelor of Science in Computer Science"
}, },
"homeLocation": { "homeLocation": {
"@type": "City", "@type": "City",
"name": "San Francisco, CA" "name": "San Francisco, CA"
}, },
"workLocation": { "workLocation": {
"@type": "City", "@type": "City",
"name": "San Francisco, CA" "name": "San Francisco, CA"
}, },
"memberOf": { "memberOf": {
"@type": "Organization", "@type": "Organization",
"name": "Noisebridge", "name": "Noisebridge",
"url": "https://www.noisebridge.net" "url": "https://www.noisebridge.net"
}, },
"affiliation": { "affiliation": {
"@type": "Organization", "@type": "Organization",
"name": "Noisebridge", "name": "Noisebridge",
"url": "https://www.noisebridge.net" "url": "https://www.noisebridge.net"
}, },
"sameAs": [ "sameAs": [
"https://github.com/jetpham", "https://github.com/jetpham",
"https://x.com/exmistsoftware", "https://x.com/exmistsoftware",
"https://bsky.app/profile/extremist.software", "https://bsky.app/profile/extremist.software",
"https://git.extremist.software" "https://git.extremist.software"
] ]
} }
</script> </script>
</head> </head>
<body style="background:#000"> <body style="background: #000">
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas> <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>
<nav aria-label="Main navigation" class="flex justify-center px-4"> <nav aria-label="Main navigation" class="flex justify-center px-4">
<div class="relative px-[2ch] py-[1ch] mt-[2ch] w-full max-w-[66.666667%] min-w-fit"> <div
class="relative mt-[2ch] w-full max-w-[66.666667%] min-w-fit px-[2ch] py-[1ch]"
>
<div <div
class="pointer-events-none absolute inset-0" 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);" 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" aria-hidden="true"
></div> ></div>
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
<div class="relative z-10 flex justify-center gap-[2ch]"> <div class="relative z-10 flex justify-center gap-[2ch]">
<a href="/">[HOME]</a> <a href="/" data-nav-link>[HOME]</a>
<a href="/qa">[Q&A]</a> <a href="/qa" data-nav-link>[Q&amp;A]</a>
</div> </div>
</div> </div>
</nav> </nav>
<div id="outlet"></div> <main id="outlet" tabindex="-1"></main>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

62
src/lib/qa.ts Normal file
View file

@ -0,0 +1,62 @@
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,4 +1,11 @@
import { getQuestions, submitQuestion } from "~/lib/api"; import { getQuestions, submitQuestion, type Question } from "~/lib/api";
import {
clearQuestionDraft,
formatExactTimestamp,
formatRelativeTimestamp,
readQuestionDraft,
writeQuestionDraft,
} from "~/lib/qa";
import { frostedBox } from "~/components/frosted-box"; import { frostedBox } from "~/components/frosted-box";
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
@ -7,81 +14,197 @@ function escapeHtml(str: string): string {
return div.innerHTML; 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) { export async function qaPage(outlet: HTMLElement) {
const draft = readQuestionDraft();
outlet.innerHTML = ` outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4"> <div class="flex flex-col items-center justify-start px-4">
${frostedBox(` ${frostedBox(`
<form id="qa-form"> <form id="qa-form" novalidate>
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]"> <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> <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" <textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea" class="qa-textarea"
placeholder="Type your question..."></textarea> aria-describedby="qa-helper qa-validation char-count"
<div class="flex justify-between mt-[1ch]"> placeholder="Type your question...">${escapeHtml(draft)}</textarea>
<span id="char-count" style="color: var(--dark-gray);">0/200</span> <p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p>
<button type="submit" class="qa-button">[SUBMIT]</button> <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>
</div> </div>
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
</fieldset> </fieldset>
</form> </form>
<div id="qa-list" class="mt-[2ch]">Loading...</div> <div id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div>
`)} `)}
</div>`; </div>`;
const form = document.getElementById("qa-form") as HTMLFormElement; const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement; const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const charCount = document.getElementById("char-count")!; const submitButton = document.getElementById(
const status = document.getElementById("qa-status")!; "qa-submit",
const list = document.getElementById("qa-list")!; ) 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();
});
}
}
input.addEventListener("input", () => { input.addEventListener("input", () => {
charCount.textContent = `${input.value.length}/200`; hasInteracted = true;
writeQuestionDraft(input.value);
updateValidation();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
}); });
form.addEventListener("submit", (e) => { form.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
const question = input.value.trim(); if (isSubmitting) return;
if (!question) return;
status.textContent = "Submitting..."; const question = input.value.trim();
status.style.color = "var(--light-gray)"; hasInteracted = true;
if (!updateValidation() || !question) {
setStatus("Write a question before submitting.", "var(--light-red)");
input.focus();
return;
}
isSubmitting = true;
submitButton.disabled = true;
setStatus("Submitting...", "var(--light-gray)");
submitQuestion(question) submitQuestion(question)
.then(() => { .then(() => {
input.value = ""; input.value = "";
charCount.textContent = "0/200"; clearQuestionDraft();
status.textContent = "Question submitted! It will appear here once answered."; hasInteracted = false;
status.style.color = "var(--light-green)"; updateValidation();
setStatus(
"Question submitted! It will appear here once answered.",
"var(--light-green)",
);
input.focus();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
status.textContent = err instanceof Error ? err.message : "Failed to submit question."; setStatus(
status.style.color = "var(--light-red)"; err instanceof Error ? err.message : "Failed to submit question.",
"var(--light-red)",
);
})
.finally(() => {
isSubmitting = false;
submitButton.disabled = false;
}); });
}); });
try { updateValidation();
const questions = await getQuestions(); await loadQuestions();
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,12 +27,26 @@ export function route(path: string, handler: PageHandler) {
export function navigate(path: string) { export function navigate(path: string) {
history.pushState(null, "", path); history.pushState(null, "", path);
void render(); window.scrollTo({ top: 0, behavior: "auto" });
void render(true);
} }
async function 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) {
const path = location.pathname; const path = location.pathname;
const outlet = document.getElementById("outlet")!; const outlet = document.getElementById("outlet")!;
updateNavState(path);
for (const r of routes) { for (const r of routes) {
const match = path.match(r.pattern); const match = path.match(r.pattern);
@ -43,6 +57,7 @@ async function render() {
}); });
outlet.innerHTML = ""; outlet.innerHTML = "";
await r.handler(outlet, params); await r.handler(outlet, params);
if (focusOutlet) outlet.focus();
return; return;
} }
} }
@ -51,14 +66,20 @@ async function render() {
if (notFoundHandler) { if (notFoundHandler) {
await notFoundHandler(outlet, {}); await notFoundHandler(outlet, {});
} }
if (focusOutlet) outlet.focus();
} }
export function initRouter() { export function initRouter() {
window.addEventListener("popstate", () => void render()); window.addEventListener("popstate", () => void render(true));
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const anchor = (e.target as HTMLElement).closest("a"); const anchor = (e.target as HTMLElement).closest("a");
if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) { if (
anchor?.origin === location.origin &&
!anchor.hash &&
!anchor.hasAttribute("download") &&
!anchor.hasAttribute("target")
) {
e.preventDefault(); e.preventDefault();
navigate(anchor.pathname); navigate(anchor.pathname);
} }

View file

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