Skip to content
Merged
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
6 changes: 6 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ The signature structure. A page advances as a sequence of bands rather than one

One per page, at the foot. Nightfall surface, `rounded-md` (16px), generous padding, white bold copy, one inverse pill button. This is where the ask lands, and it is the only dark thing on the page.

Implemented as `src/components/TerminalCta.astro` (heading prop, body slot, `actions` and optional `footnote` slots). Use it rather than hand-building the block.

### Implementation

The type scale, bands, buttons, links and focus rings above live as Tailwind class strings in `src/utils/designClasses.ts`. Import them on public pages instead of redefining them per page.

### Navigation

`Navigation.astro`, shared by every live page through `Layout.astro`. Ink text at rest, Grove Green on hover and active, Outfit label weight throughout, one primary action at the right. Never serif.
Expand Down
118 changes: 118 additions & 0 deletions src/components/MailtoFallback.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
// Fallback for mailto links that open nothing (no mail app configured,
// webmail users, locked-down work machines). Add `data-mailto-fallback` to an
// <a href="mailto:..."> (use `data-mailto-fallback="dark"` on the Nightfall
// CTA) and render this component once on the page.
//
// On click the address is copied to the clipboard (it must happen inside the
// click gesture or browsers refuse) and the mailto proceeds normally. A page
// cannot see whether a mail app opened, so we use the standard signal: if the
// window has not blurred or been hidden shortly afterwards, nothing took over,
// and a small "Email address copied" tooltip appears above the link. If the
// clipboard is unavailable, the address is selected instead with a
// Ctrl+C / Cmd+C hint. Links read their href at click time, so addresses
// assembled by script (the obfuscated one on /contact) work too.
---

<script>
const SETTLE_MS = 1200; // time for a mail app or webmail tab to take focus
const VISIBLE_MS = 2200;
const BUBBLE_BASE =
"pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-sm px-2 py-1 text-xs font-bold opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none";
const BUBBLE_LIGHT = "bg-ink text-white";
const BUBBLE_DARK = "bg-paper text-ink";

let status: HTMLElement | null = null;

// One shared polite live region so screen readers hear the outcome.
function announce(message: string): void {
if (!status) {
status = document.createElement("span");
status.className = "sr-only";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
document.body.append(status);
}
status.textContent = message;
}

function selectText(el: Element): void {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}

// Positioned in document coordinates on <body>, so it never changes the
// link's text, accessible name or layout. Coordinates go through the CSSOM
// (style.setProperty), which CSP permits; a style="" attribute would not.
function showBubble(link: HTMLAnchorElement, text: string): void {
link.ownerDocument.querySelectorAll("[data-mailto-bubble]").forEach((el) => el.remove());
const rect = link.getBoundingClientRect();
const bubble = document.createElement("span");
bubble.dataset.mailtoBubble = "";
bubble.setAttribute("aria-hidden", "true");
bubble.className = `${BUBBLE_BASE} ${
link.dataset.mailtoFallback === "dark" ? BUBBLE_DARK : BUBBLE_LIGHT
}`;
bubble.textContent = text;
bubble.style.setProperty("left", `${rect.left + rect.width / 2 + window.scrollX}px`);
bubble.style.setProperty("top", `${rect.top + window.scrollY - 6}px`);
document.body.append(bubble);
requestAnimationFrame(() => bubble.classList.replace("opacity-0", "opacity-100"));
window.setTimeout(() => {
bubble.classList.replace("opacity-100", "opacity-0");
window.setTimeout(() => bubble.remove(), 200);
}, VISIBLE_MS);
}

function emailFromHref(href: string): string {
return decodeURIComponent(href.replace(/^mailto:/i, "").split("?")[0]);
}

function handleClick(link: HTMLAnchorElement): void {
const href = link.getAttribute("href") ?? "";
if (!/^mailto:/i.test(href)) return;
const email = emailFromHref(href);

const copied: Promise<boolean> = navigator.clipboard
? navigator.clipboard.writeText(email).then(
() => true,
() => false
)
: Promise.resolve(false);

let handedOff = false;
const markHandedOff = () => {
handedOff = true;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") handedOff = true;
};
window.addEventListener("blur", markHandedOff);
window.addEventListener("pagehide", markHandedOff);
document.addEventListener("visibilitychange", onVisibility);

window.setTimeout(async () => {
window.removeEventListener("blur", markHandedOff);
window.removeEventListener("pagehide", markHandedOff);
document.removeEventListener("visibilitychange", onVisibility);
if (handedOff) return;

if (await copied) {
showBubble(link, "Email address copied");
announce(`No email app opened. ${email} was copied to your clipboard.`);
} else {
selectText(link);
const key = /Mac|iPhone|iPad/.test(navigator.userAgent) ? "Cmd+C" : "Ctrl+C";
showBubble(link, `Press ${key} to copy`);
announce(`No email app opened. The address is selected; press ${key} to copy it.`);
}
}, SETTLE_MS);
}

document.querySelectorAll<HTMLAnchorElement>("a[data-mailto-fallback]").forEach((link) => {
link.addEventListener("click", () => handleClick(link));
});
</script>
49 changes: 49 additions & 0 deletions src/components/TerminalCta.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
// The terminal call to action (DESIGN.md §5): the single Nightfall block per
// page, placed last before the footer. Pass the heading as a prop; the default
// slot is the supporting copy, the "actions" slot holds the buttons (use
// inverseButton / ghostOnDarkButton from utils/designClasses), and the
// optional "footnote" slot sits beneath them.
import { container } from "../utils/designClasses";

interface Props {
heading: string;
/** id for the heading, referenced by the section's aria-labelledby. */
headingId?: string;
/**
* Set false when the section above already ends with bottom padding on a
* Paper surface, so the gap is not doubled. Keep true after a band.
*/
padTop?: boolean;
}

const { heading, headingId = "terminal-cta-heading", padTop = true } = Astro.props;
const spacing = padTop ? "py-16 min-[810px]:py-20" : "pb-16 min-[810px]:pb-20";
const hasFootnote = Astro.slots.has("footnote");
---

<section aria-labelledby={headingId} class={spacing}>
<div class={container}>
<div class="rounded-md bg-nightfall px-6 py-[clamp(3rem,5vw,4.5rem)] sm:px-10 lg:px-16">
<h2
id={headingId}
class="max-w-[20ch] text-[clamp(30px,4vw,46px)] font-extrabold leading-[1.1] text-white"
>
{heading}
</h2>
<div class="mt-5 max-w-[55ch] text-[17px] leading-[1.7] text-white/70">
<slot />
</div>
<div class="mt-8 flex flex-col gap-3 min-[480px]:flex-row min-[480px]:flex-wrap">
<slot name="actions" />
</div>
{
hasFootnote && (
<p class="mt-8 text-[15px] text-white/70">
<slot name="footnote" />
</p>
)
}
</div>
</div>
</section>
Loading
Loading