initial commit

This commit is contained in:
2026-06-01 01:43:58 -05:00
commit bb5bf9e028
17 changed files with 7158 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "astro-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 4321
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# build output
dist/
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# env
.env
.env.production
# macOS
.DS_Store
# editors
.vscode/
.idea/
+120
View File
@@ -0,0 +1,120 @@
<!-- SEED: re-run /impeccable document once there's code to capture the actual tokens and components. -->
---
name: Owen
description: Personal site — work, writing, point of view.
---
# Design System: Owen
## 1. Overview
**Creative North Star: "The Late-Shift Studio"**
A serious studio after hours. The room is dark — not stage-black, not glossy-black, a tinted near-black warm enough to feel like a real space. One magenta-purple lamp picks out what matters: a name, a section title, a focus ring, the live link in a list. The room stays warm; the lamp is deliberately cool. The temperature contrast is the brand: a person at work in a warm space, with focused electric light on the active surface. Everything else falls into precise but quiet darkness, with off-white type doing the load-bearing work. The grid is visible because the work is the proof; alignment is ruler-precise; nothing is decorative for its own sake. The mood is not "developer aesthetic" — it is "someone in the middle of a long, considered piece of work, and the lamp is still on."
The system explicitly rejects the generic SaaS landing-page shape (hero metric, gradient accent, identical card grid, tracked uppercase eyebrows on every section), the bootcamp-portfolio template (three identical project cards + stock illustration + generic "about me"), designer-tryhard maximalism (scroll-jacked, motion-heavy, look-at-me), and the resume-as-a-webpage (linear text dump with no point of view). It also rejects its own first reflex: dark mode plus a cool neon accent (cyan / electric blue / blurple) plus monospace-everywhere is the Linear / Stripe / Twitch monoculture. Engineering-precise is the *reference*; cool-terminal is the *trap*. We sit clearly outside it: warm-tinted surface, magenta-leaning purple (not blurple), sentence-case sans for voice with mono only for metadata.
**Key Characteristics:**
- Tinted-warm near-black surface as the body; magenta-purple accent ≤10% of any view.
- Editorial sans carries voice and hierarchy; mono carries metadata (dates, tags, code, counts).
- Visible, respected grid. Ruler-precise alignment across every section.
- Motion is responsive (state changes, transitions), never choreographed. Reveals enhance an already-visible default.
- One accent. Many tokens, one voice.
## 2. Colors
A restrained palette: a warm-tinted near-black surface, off-white ink, and a single magenta-purple accent that earns every appearance. The strategy is *commitment, not variety* — the accent's rarity is what makes it sharp.
### Primary
- **Magenta-purple accent** — `oklch(0.72 0.21 325)` in dark, `oklch(0.45 0.23 322)` in light. Hue ~322325 (magenta-leaning, deliberately not the 270290 blurple lane that fintech / SaaS dark themes settled into). Calibrated against the warm-dark surface so it reads as *deliberate cool light* — an electric desk lamp in a warm room. Used at ≤10% of any rendered view. Earns its place on: the email/social links, the live "now" marker on the current role, focus rings, link hover, the hashtag chip wash on hover.
### Neutral
- **Body surface** — warm-tinted near-black. *Exact value to be resolved during implementation*. Tinted slightly toward the accent's hue family (not cool, not magazine-cream); reads as the actual room, not a stock dark theme.
- **Ink** — off-white tuned to hit ≥4.5:1 against body surface. Carries all body copy and most headings.
- **Muted ink** — for metadata, captions, secondary text. Must still hit ≥4.5:1 against body surface; the lazy "very-light-gray on dark" failure is forbidden.
- **Divider / line work** — hairline (1px) ink at low opacity. The grid is seen, not shouted.
### Named Rules
**The One Lamp Rule.** Exactly one accent. Magenta-purple and nothing else. Adding a second hue *anywhere* (a teal link, a citrus highlight, a green success state) breaks the metaphor and the brand. Success, error, and info states use weight, position, and icons — not new hues.
**The 10% Ceiling.** The accent covers ≤10% of any rendered view, measured roughly by ink coverage. If a section feels like it has too much purple, it does — remove the weakest instance, not the strongest.
**The Warm-Room, Cool-Lamp Rule.** The body surface stays *tinted warm* (hue ~70, very low chroma) even though the accent is cool. This is intentional contrast, not an oversight: the warmth carries the room, the accent carries the focused light. Pure neutral black (#000) is still forbidden — it reads as default dark mode, not as a place. Re-tinting the surface toward the accent's hue (cool / purple-tinted near-black) is also forbidden: it collapses the warm/cool contrast that *is* the brand.
## 3. Typography
**Display & Body Font:** an editorial sans-serif — *family to be chosen at implementation*. Direction: a well-cut neo-grotesque or modern humanist (Söhne / Neue Haas Grotesk / Inter Display family-of-feel), with the *display* and *text* optical sizes pulled apart. One family doing the heading work and the body work, with weight and size carrying hierarchy.
**Mono Font:** a real mono — *family to be chosen at implementation*. Direction: a humanist or editorial mono (JetBrains Mono / Berkeley Mono / IBM Plex Mono family-of-feel), not a default system mono. Reserved for dates, tags, counts, code, post slugs, and any tabular metadata.
**Character:** The pairing is a typesetter's pairing, not a developer's. The sans is the voice; the mono is the marginalia. They share x-height and weight discipline. Together they should read like a well-set technical journal — confident, precise, no decoration.
### Hierarchy
- **Display** (medium / 500, `clamp(2.5rem, 6vw, 5rem)`, line-height 1.01.05, letter-spacing ≥ -0.04em): owner's name on the home page; one moment per long-form post. Used sparingly.
- **Headline** (medium / 500, 2rem2.5rem, line-height 1.1): section titles. Visible from the table of contents, not from the hero.
- **Title** (medium / 500, 1.25rem1.5rem, line-height 1.25): subsection titles, project titles in the index.
- **Body** (regular / 400, 1rem1.0625rem, line-height 1.551.65, max 6575ch): all running prose. Off-white on warm-dark; never light-gray-on-dark.
- **Label / Mono** (regular / 400, 0.8125rem0.875rem, sans for inline labels, mono for metadata; case = sentence): dates, tags, counts, "current role", reading time. *Uppercase tracked labels above every section are forbidden* (eyebrow trope).
### Named Rules
**The No-Eyebrow Rule.** Tiny uppercase tracked eyebrows above each section ("ABOUT", "WORK", "WRITING") are forbidden. Section hierarchy comes from the heading itself, position, and rule-line dividers — not from eyebrows.
**The Mono-Earns-It Rule.** Mono appears only where it carries genuine technical content: dates, tags, code, slugs, numerics, machine-readable strings. Mono used decoratively (a tagline in mono "to look technical") is forbidden — it's the terminal-clone trap.
**The Three-Family Ceiling.** Sans + mono. Two families, no third. A "fun" third display face anywhere on the site is forbidden.
## 4. Elevation
The system is **flat by default**. No drop shadows for decoration. Depth comes from:
1. Surface tone (body / muted-line-work) — two surfaces, one rule line.
2. The accent — anything that lights up reads as *foreground*.
3. State (hover, focus, active) — interactive elements lift via accent or weight, never via a synthesized 3D shadow.
Shadows appear, rarely, as a focus ring or a glow under the accent on hover — and only when they materially improve the interaction. Stock "card with soft shadow" is forbidden.
### Named Rules
**The Flat-By-Default Rule.** Surfaces are flat at rest. Elevation appears only as a response to state. No card has a resting shadow.
## 5. Components
*Omitted in seed mode — no components exist yet. Re-run `/impeccable document` after the first features ship to capture buttons, links, navigation, post lists, project entries, and any signature components.*
When components do land, they must obey the Named Rules in §2 and §3, the Flat-By-Default Rule in §4, and the Do's and Don'ts in §6. The first component to ship should be the **link** (in long-form prose and in the work index) — it carries the accent and sets the rest of the system's interaction grammar.
## 6. Do's and Don'ts
### Do:
- **Do** use exactly one accent (magenta-purple, hue ~322325) across the entire site. Everything else is the tinted warm-dark surface and off-white ink.
- **Do** verify body contrast ≥4.5:1 against the warm-dark surface. Muted ink for metadata must still hit 4.5:1; "elegantly faded gray" on dark is the most common failure and is forbidden.
- **Do** carry hierarchy with weight and scale (≥1.25 ratio between steps), not with eyebrows, icons, or color.
- **Do** reserve mono for genuine technical content (dates, tags, code, slugs).
- **Do** keep alignment ruler-precise. The grid is the brand.
- **Do** give every animation a `@media (prefers-reduced-motion: reduce)` alternative — crossfade or instant transition, not "motion silently disappears".
- **Do** size the display heading with a clamp ceiling ≤ 6rem (~96px) and letter-spacing ≥ -0.04em.
- **Do** cap body line length at 6575ch on long-form pages.
### Don't:
- **Don't** ship the generic SaaS landing-page shape (hero metric + gradient accent + identical card grid + tracked uppercase eyebrows). Named in PRODUCT.md as a primary anti-reference.
- **Don't** ship the bootcamp-grad portfolio shape (three identical project cards with screenshots, big gradient name, generic "About me" with stock illustration). Named in PRODUCT.md.
- **Don't** ship designer-tryhard maximalism — scroll-jacking, heavy motion, intentionally hard navigation. Named in PRODUCT.md.
- **Don't** ship the resume-as-a-webpage (linear list of jobs and skills, no point of view). If a PDF could replace it, the page has failed. Named in PRODUCT.md.
- **Don't** ship the second-order trap: dark mode + cool blurple accent (hue 270290) on cool-tinted near-black + monospace everywhere. That is the Stripe / Linear / Twitch SaaS monoculture, not the brief. The magenta-leaning purple (hue ~325) on warm-tinted near-black is intentionally outside that lane.
- **Don't** re-tint the surface toward the accent's hue "for consistency". The warm/cool temperature contrast IS the brand. A surface tinted cool/purple turns the page into Stripe-clone overnight.
- **Don't** use gradient text (`background-clip: text` + gradient). Forbidden everywhere.
- **Don't** add side-stripe borders (`border-left > 1px` as a colored accent on cards or callouts). Forbidden.
- **Don't** use glassmorphism, decorative blurs, or stock soft shadows on cards.
- **Don't** put a tracked uppercase eyebrow above every section. One section having a deliberate label is fine; every section is the AI grammar.
- **Don't** number sections (`01 · About / 02 · Work / 03 · Writing`) unless the section is *literally* an ordered sequence whose order carries meaning.
- **Don't** use em dashes — or `--` — in copy. Commas, colons, semicolons, periods, parentheses. (This rule applies to copy. Tokens that legitimately use em dashes in display are fine.)
- **Don't** use marketing buzzwords (*streamline, empower, supercharge, leverage, transform, seamless, world-class, enterprise-grade, next-generation, cutting-edge, mission-critical*). Plain nouns and verbs that describe what the thing literally does.
- **Don't** carry meaning in color alone. The accent's job is *emphasis*, not categorical encoding.
+48
View File
@@ -0,0 +1,48 @@
# Product
## Register
brand
## Users
A mixed audience finding the site through different routes: recruiters and hiring managers vetting credibility, peers in the industry (engineers, designers, founders) skimming for substance, and occasional drive-by readers landing on a piece of writing. They arrive expecting either to *evaluate* the owner or to *read* something. Both jobs should be one click away from the home page; nobody should have to dig for either the work index or the writing.
## Product Purpose
A personal site for Owen — work, writing, and a credible point of view in one place. Success is that a skeptical visitor (peer or recruiter) closes the tab knowing this person builds things carefully and writes about them clearly. The site itself is part of the proof: if it reads as well-built, the case is already half made before they read a word of content.
## Brand Personality
**Confident, sharp, technical.** Voice is direct and specific. Plain nouns and verbs over adjectives. No hedging, no humblebrag, no "passionate about X". The site sounds like someone who has done the work and is comfortable showing it without dressing it up. Emotionally, it should evoke the same feeling as opening a well-organized tool: things are where you'd expect, the grid is respected, nothing is wasted.
## Anti-references
The site should explicitly not resemble any of these:
- **Generic SaaS landing page.** Hero metric, gradient accent, identical card grid, tiny tracked uppercase eyebrows above every section. The saturated AI default of 2026. If a section starts to drift this way, rewrite the structure.
- **Bootcamp-grad portfolio.** Three project screenshots in identical cards, big gradient name, a generic "About me" with a stock illustration and a list of icons for skills. Generic in shape and generic in voice.
- **Designer-tryhard maximalism.** Scroll-jacked, motion-heavy, hard to navigate, "look at me" design. Motion is fine; performance art is not.
- **Resume-as-a-webpage.** A linear text dump of jobs and skills with no point of view. If the site could be replaced by a PDF without losing anything, it has failed.
**Second-order trap to dodge:** "confident, sharp, technical" + dark mode is a one-step trip to the terminal-native / Linear-clone monoculture (monospace everywhere, near-black surface, single neon accent, changelog grid). Engineering-precise is the *reference*, not the *outfit*. Borrow the grid discipline and the metadata typography; don't borrow the look wholesale.
## Design Principles
1. **The site is the proof.** Construction quality is part of the argument. Everything that ships — alignment, type scale, motion, copy — is evidence the owner builds carefully. No "good enough for a personal site" shortcuts.
2. **Density over decoration.** Real content (work, writing) outranks chrome. Whitespace serves the content; it isn't a substitute for having something to say. If a section has nothing in it, it doesn't exist yet — don't fill it with a decorative placeholder.
3. **Sharpness is commitment.** Pick a type scale, a grid, an accent, an interaction grammar — and respect them everywhere. Inconsistency reads as carelessness; consistency at this level reads as taste.
4. **Don't perform "developer".** Dark mode, monospace, and terminal motifs earn their place by doing a job (metadata, code, hierarchy). They are not the brand. If a choice is justified only by "developer sites look like this", remove it.
5. **Quiet motion, never silent.** Motion is part of the build, not a layer on top. Reveals enhance an already-visible default. Each transition fits what it reveals; nothing reaches for stock entrance animations. Respects `prefers-reduced-motion` everywhere.
## Accessibility & Inclusion
Target: **WCAG 2.2 AA**, with these non-negotiables:
- Body contrast ≥4.5:1, large/bold ≥3:1. Placeholder text held to body contrast, not the muted-gray default.
- Full keyboard navigation, with visible, non-default focus states that match the brand (not the browser ring).
- `prefers-reduced-motion` honored on every animation — crossfade or instant alternative, never "just disable JS motion".
- Light theme available alongside the dark default; the toggle persists and respects `prefers-color-scheme` on first load.
- All interactive controls hit a 24×24 minimum touch target.
Color choices are checked against a deuteranopia simulation before shipping; meaning never carried by hue alone.
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://rummage.cc',
server: {
host: '127.0.0.1',
port: 4321,
},
devToolbar: { enabled: false },
});
+5568
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "rummage-cc",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@fontsource-variable/geist": "^5.1.1",
"@fontsource-variable/geist-mono": "^5.1.1",
"astro": "^5.7.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="oklch(0.178 0.006 70)" rx="4"/>
<rect x="8" y="8" width="16" height="16" fill="oklch(0.72 0.21 325)" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

+43
View File
@@ -0,0 +1,43 @@
---
---
<nav class="theme-toggle" aria-label="Theme">
<button
type="button"
data-theme-set="dark"
aria-pressed="true"
aria-label="Use dark theme"
>NIGHT</button>
<span class="theme-toggle__sep" aria-hidden="true">·</span>
<button
type="button"
data-theme-set="light"
aria-pressed="false"
aria-label="Use light theme"
>DAY</button>
</nav>
<script>
const root = document.documentElement;
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-theme-set]');
function applyTheme(theme: 'dark' | 'light', persist = true) {
root.dataset.theme = theme;
buttons.forEach((b) => {
const isActive = b.dataset.themeSet === theme;
b.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
if (persist) {
try { localStorage.setItem('theme', theme); } catch {}
}
}
const initial = (root.dataset.theme === 'light' ? 'light' : 'dark') as 'dark' | 'light';
applyTheme(initial, false);
buttons.forEach((b) => {
b.addEventListener('click', () => {
const next = b.dataset.themeSet as 'dark' | 'light';
applyTheme(next, true);
});
});
</script>
+27
View File
@@ -0,0 +1,27 @@
export type Area = {
title: string;
body: string;
};
export const practice: Area[] = [
{
title: 'Networking & Systems Administration',
body: 'Design and maintain business network infrastructure. Administer mail servers, web servers, and databases. Implement security and monitoring.',
},
{
title: 'Desktop Support & Technical Services',
body: 'End-user support across Windows, Mac, and Linux. Hardware and software troubleshooting, system imaging and deployment, IT asset management.',
},
{
title: 'Programming & Automation Engineering',
body: 'Automation through scripting and open-source development. Custom tools for operational workflows, CI/CD pipelines, and system monitoring.',
},
{
title: 'Web Development & Frontend Engineering',
body: 'Web applications with Svelte and Tailwind. Responsive, accessible interfaces for internal tools and client work.',
},
{
title: 'Creative Production & Media Services',
body: 'Video production, live audio-visual systems, and digital content for broadcast and clients.',
},
];
+15
View File
@@ -0,0 +1,15 @@
export const tools: string[] = [
'Linux',
'macOS',
'Windows',
'Python',
'TypeScript',
'Svelte',
'TailwindCSS',
'Docker',
'Git',
'GitHub',
'nginx',
'Azure',
'GoogleWorkspace',
];
+41
View File
@@ -0,0 +1,41 @@
export type Role = {
title: string;
company: string;
years: string;
current?: boolean;
bullets: string[];
};
export const work: Role[] = [
{
title: "CIO / Co-founder",
company: "SimpleLogic Solutions LLC",
years: "present",
current: true,
bullets: [
"Founded and operate a managed IT services company and infrastructure platform, providing systems administration, networking, cloud, and cybersecurity services to businesses across Tennessee.",
"Design, deploy, and maintain Linux servers, virtualization platforms, business networks, device management systems, backups, and cloud infrastructure.",
"Lead product development, software engineering, business operations, and technical strategy for Launchd and internal automation platforms.",
],
},
{
title: "Owner & Operator",
company: "NixLabs Networks",
years: "20212025",
bullets: [
"Designed, deployed, and maintained secure network infrastructure, hosting platforms, and Linux-based systems for clients.",
"Automated infrastructure management and operational workflows using Bash, Python, and open-source technologies.",
"Provided technical support, troubleshooting, and systems administration for production server environments.",
],
},
{
title: "Field Technician",
company: "Metro Nashville Public Schools",
years: "2022-2023",
bullets: [
"Installed, configured, and maintained computer hardware, software, and networking equipment across district facilities.",
"Troubleshot and resolved technical issues for staff and students, minimizing service interruptions and downtime.",
"Assisted with device imaging, software deployment, inventory management, and large-scale technology rollouts.",
],
},
];
+53
View File
@@ -0,0 +1,53 @@
---
import '@fontsource-variable/geist';
import '@fontsource-variable/geist-mono';
import '../styles/global.css';
import ThemeToggle from '../components/ThemeToggle.astro';
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<meta name="color-scheme" content="dark light" />
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://rummage.cc" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href="https://rummage.cc/" />
<script is:inline>
(function () {
try {
const stored = localStorage.getItem('theme');
const prefersLight =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches;
const theme = stored === 'light' || stored === 'dark'
? stored
: prefersLight ? 'light' : 'dark';
document.documentElement.dataset.theme = theme;
} catch (_) {
document.documentElement.dataset.theme = 'dark';
}
})();
</script>
</head>
<body>
<ThemeToggle />
<slot />
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
---
import Layout from "../layouts/Layout.astro";
import { work } from "../data/work";
import { practice } from "../data/practice";
import { tools } from "../data/tools";
const description =
"Systems engineer and founder building open, scalable infrastructure, cloud platforms, and networks that prioritize simplicity over complexity.";
---
<script>
import "../scripts/toolset-drag";
</script>
<Layout title="Owen Rummage" description={description}>
<div class="page">
<header class="hero">
<h1 class="hero__name">Owen Rummage</h1>
<p class="hero__statement">
Systems engineer and founder building open, scalable
infrastructure, cloud platforms, and networks that prioritize
simplicity over complexity.
</p>
<p class="hero__contact">
<a class="link" href="mailto:hello@rummage.cc"
>hello@rummage.cc</a
>
<a
class="link"
href="https://github.com/owenrummage"
rel="me noopener"
target="_blank">github.com/owenrummage</a
>
<a
class="link"
href="https://www.linkedin.com/in/orummage"
rel="me noopener"
target="_blank">linkedin.com/in/orummage</a
>
</p>
</header>
<hr class="rule" />
<section class="work" aria-label="Work history">
{
work.map((role) => (
<article
class:list={["role", role.current && "role--current"]}
>
<div class="role__meta">
{role.current ? (
<span class="role__live">
<span>present</span>
</span>
) : (
<span class="role__years">{role.years}</span>
)}
</div>
<div class="role__body">
<h2 class="role__title">{role.title}</h2>
<p class="role__company">{role.company}</p>
<ul class="role__bullets">
{role.bullets.map((line) => (
<li>{line}</li>
))}
</ul>
</div>
</article>
))
}
</section>
<hr class="rule" />
<section class="practice" aria-label="Areas of practice">
{
practice.map((area, i) => (
<article class="area">
<div class="area__meta">
{i === 0 && (
<span class="area__label">practice</span>
)}
</div>
<div class="area__body">
<h2 class="area__title">{area.title}</h2>
<p class="area__desc">{area.body}</p>
</div>
</article>
))
}
</section>
<section class="toolset" aria-label="Tools">
<div class="toolset__meta">
<span class="toolset__label">tools</span>
</div>
<ul class="toolset__list">
{tools.map((t) => <li>{t}</li>)}
</ul>
</section>
<aside class="motto" aria-label="Closing note">
<div class="motto__meta" aria-hidden="true">motto</div>
<p class="motto__text">
Stay humble enough to learn, and confident enough to build.
</p>
</aside>
<hr class="rule" />
<footer class="foot">
<span class="foot__build">build 0.1 / Nashville</span>
<span class="foot__home">rummage.cc</span>
</footer>
</div>
</Layout>
+321
View File
@@ -0,0 +1,321 @@
// Toolset drag-to-rearrange
// Custom pointer-events implementation with FLIP reorder animation,
// velocity-based tilt on grab, spring-y drop, localStorage persistence.
const STORAGE_KEY = 'rummage:toolset-order';
const DRAG_THRESHOLD = 5; // px before drag begins (so clicks don't trigger it)
const FLIP_DURATION = 280;
const DROP_DURATION = 360;
const TILT_GAIN = 1.8;
const TILT_CLAMP = 14;
const TILT_DECAY = 0.32;
const SCALE_ACTIVE = 1.06;
type ChipMap = Map<HTMLLIElement, DOMRect>;
function tokenOf(el: Element): string {
return (el.textContent || '').trim();
}
function readReducedMotion(): boolean {
return (
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function snapshotPositions(list: HTMLUListElement): ChipMap {
const map: ChipMap = new Map();
for (const child of list.children) {
map.set(child as HTMLLIElement, child.getBoundingClientRect());
}
return map;
}
function flipSiblings(
list: HTMLUListElement,
prev: ChipMap,
exclude: HTMLLIElement,
duration: number,
): void {
for (const c of list.children) {
const child = c as HTMLLIElement;
if (child === exclude) continue;
const before = prev.get(child);
if (!before) continue;
const after = child.getBoundingClientRect();
const dx = before.left - after.left;
const dy = before.top - after.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
child.classList.add('is-flipping');
child.style.transition = 'none';
child.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
// Force a synchronous reflow so the no-transition transform commits
// BEFORE we install the transition. Reading offsetWidth flushes
// pending layout; this is more reliable than requestAnimationFrame,
// which can stall in non-rendering contexts.
void child.offsetWidth;
child.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
child.style.transform = '';
window.setTimeout(() => {
child.style.transition = '';
child.style.transform = '';
child.classList.remove('is-flipping');
}, duration + 40);
}
}
function restoreOrder(list: HTMLUListElement, items: HTMLLIElement[]): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const order = JSON.parse(stored);
if (!Array.isArray(order)) return;
const byToken = new Map<string, HTMLLIElement>(
items.map((i) => [tokenOf(i), i]),
);
const placed = new Set<HTMLLIElement>();
for (const token of order) {
if (typeof token !== 'string') continue;
const el = byToken.get(token);
if (el) {
list.appendChild(el);
placed.add(el);
}
}
for (const i of items) {
if (!placed.has(i)) list.appendChild(i);
}
} catch {
/* ignore */
}
}
function persistOrder(list: HTMLUListElement): void {
try {
const order = Array.from(list.children).map((c) => tokenOf(c));
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch {
/* ignore */
}
}
function attachChip(chip: HTMLLIElement, list: HTMLUListElement): void {
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
let prevX = 0;
let tilt = 0;
let dragging = false;
const reduce = readReducedMotion();
function applyDragTransform(x: number, y: number): void {
// Measure the chip's natural (untransformed) rect to compute the
// delta needed to put it under the pointer.
chip.style.transition = 'none';
chip.style.transform = '';
const natural = chip.getBoundingClientRect();
const desiredLeft = x - offsetX;
const desiredTop = y - offsetY;
const dx = desiredLeft - natural.left;
const dy = desiredTop - natural.top;
const target = Math.max(
-TILT_CLAMP,
Math.min(TILT_CLAMP, (x - prevX) * TILT_GAIN),
);
tilt = tilt + (target - tilt) * TILT_DECAY;
prevX = x;
const scale = reduce ? 1 : SCALE_ACTIVE;
const rot = reduce ? 0 : tilt;
chip.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale}) rotate(${rot}deg)`;
}
function maybeSwap(x: number, y: number): void {
let target: HTMLLIElement | null = null;
let insertBefore = false;
for (const c of list.children) {
const sib = c as HTMLLIElement;
if (sib === chip) continue;
// Skip chips currently mid-FLIP — their visual rect is in
// transit and would cause oscillating re-swaps.
if (sib.classList.contains('is-flipping')) continue;
const r = sib.getBoundingClientRect();
if (y < r.top || y > r.bottom) continue;
if (x >= r.left && x <= r.right) {
target = sib;
insertBefore = x < r.left + r.width / 2;
break;
}
}
if (!target) return;
const all = Array.from(list.children);
const sibIdx = all.indexOf(target);
const myIdx = all.indexOf(chip);
let newIdx = insertBefore ? sibIdx : sibIdx + 1;
if (newIdx > myIdx) newIdx -= 1;
if (newIdx === myIdx) return;
const prev = snapshotPositions(list);
if (insertBefore) {
list.insertBefore(chip, target);
} else {
list.insertBefore(chip, target.nextSibling);
}
flipSiblings(list, prev, chip, reduce ? 60 : FLIP_DURATION);
}
function beginDrag(): void {
dragging = true;
tilt = 0;
chip.classList.add('is-dragging');
list.classList.add('is-dragging-list');
document.body.classList.add('is-toolset-dragging');
}
function dropAnimate(): void {
// FLIP the chip from its current visible position to its natural rest
// position, animating out scale, rotate, color, and shadow together.
const visible = chip.getBoundingClientRect();
const heldTilt = tilt;
chip.style.transition = 'none';
chip.style.transform = '';
const natural = chip.getBoundingClientRect();
const dx = visible.left - natural.left;
const dy = visible.top - natural.top;
const scale = reduce ? 1 : SCALE_ACTIVE;
const rot = reduce ? 0 : heldTilt;
chip.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale}) rotate(${rot}deg)`;
const dur = reduce ? 100 : DROP_DURATION;
const easing = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Synchronous force-reflow commits the no-transition mid-drag
// transform before we install the new transition. More reliable
// than RAF in non-rendering contexts.
void chip.offsetWidth;
chip.style.transition =
`transform ${dur}ms ${easing}, ` +
`background-color ${dur}ms ${easing}, ` +
`color ${dur}ms ${easing}, ` +
`box-shadow ${dur}ms ${easing}`;
chip.style.transform = '';
// Drop the dragging classes at the same instant the transform
// starts animating, so color/shadow fade together with position.
chip.classList.remove('is-dragging');
list.classList.remove('is-dragging-list');
document.body.classList.remove('is-toolset-dragging');
window.setTimeout(() => {
chip.style.transition = '';
chip.style.transform = '';
// Defensive: ensure classes are cleared even if the synchronous
// path above was interrupted by an earlier exception.
chip.classList.remove('is-dragging');
list.classList.remove('is-dragging-list');
document.body.classList.remove('is-toolset-dragging');
}, dur + 40);
}
function teardownEvents(): void {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
}
function onPointerDown(e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (pointerId !== null) return;
pointerId = e.pointerId;
const r = chip.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
offsetX = e.clientX - r.left;
offsetY = e.clientY - r.top;
prevX = e.clientX;
tilt = 0;
dragging = false;
try {
chip.setPointerCapture(e.pointerId);
} catch {
/* some browsers throw if already captured */
}
// Listen at the window level so events still fire even if pointer
// capture is lost (e.g. mid-reorder, mid-frame DOM moves) or the
// cursor leaves the chip's visual bounds.
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
}
function onPointerMove(e: PointerEvent): void {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!dragging) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
beginDrag();
}
e.preventDefault();
// CRITICAL ORDER: swap first (which may change the chip's natural
// rect), THEN apply transform against the new natural so the chip
// stays locked to the pointer with no visual jump.
maybeSwap(e.clientX, e.clientY);
applyDragTransform(e.clientX, e.clientY);
}
function onPointerUp(e: PointerEvent): void {
if (e.pointerId !== pointerId) return;
try {
chip.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
teardownEvents();
const wasDragging = dragging;
pointerId = null;
dragging = false;
if (!wasDragging) return;
dropAnimate();
persistOrder(list);
}
chip.addEventListener('pointerdown', onPointerDown);
}
function init(): void {
const list = document.querySelector<HTMLUListElement>('.toolset__list');
if (!list) return;
const items = Array.from(list.children) as HTMLLIElement[];
if (items.length === 0) return;
restoreOrder(list, items);
// After restoreOrder the children are re-arranged but the list reference
// stays. Re-collect references for the attach loop.
Array.from(list.children).forEach((c) =>
attachChip(c as HTMLLIElement, list),
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
+734
View File
@@ -0,0 +1,734 @@
/* ============================================================
* Owen Rummage — design tokens & global styles
* ============================================================ */
:root {
/* Dark theme (default). Warm-tinted near-black surface kept for human
warmth; magenta-purple accent reads as deliberate task-light against it
(warm room, cool focused light). */
--color-surface: oklch(0.178 0.006 70);
--color-surface-rise: oklch(0.225 0.007 72);
--color-ink: oklch(0.935 0.008 90);
--color-ink-soft: oklch(0.86 0.008 88);
--color-muted: oklch(0.69 0.008 82);
--color-faint: oklch(0.45 0.008 78);
--color-accent: oklch(0.72 0.21 325);
--color-accent-strong: oklch(0.78 0.22 325);
--color-rule: color-mix(in oklch, var(--color-ink) 14%, transparent);
--color-rule-strong: color-mix(in oklch, var(--color-ink) 28%, transparent);
--color-focus-ring: color-mix(
in oklch,
var(--color-accent) 70%,
transparent
);
--color-accent-wash: color-mix(
in oklch,
var(--color-accent) 14%,
transparent
);
/* Type */
--font-sans:
"Geist Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
--font-mono:
"Geist Mono Variable", ui-monospace, SFMono-Regular, "JetBrains Mono",
"IBM Plex Mono", "Cascadia Code", Menlo, Consolas, monospace;
/* Type scale */
--fs-display: clamp(2.5rem, 1.6rem + 3.6vw, 3.875rem);
--fs-statement: clamp(1.0625rem, 0.96rem + 0.45vw, 1.1875rem);
--fs-role-title: 1.25rem;
--fs-role-company: 1rem;
--fs-body: 1rem;
--fs-meta: 0.8125rem;
--fs-contact: 0.9375rem;
--fs-tiny: 0.75rem;
/* Rhythm */
--leading-display: 1.04;
--leading-statement: 1.45;
--leading-body: 1.6;
--leading-tight: 1.2;
--tracking-display: -0.032em;
--tracking-tight: -0.012em;
--tracking-mono: -0.01em;
--tracking-label: 0.04em;
/* Layout */
--container-max: 760px;
--container-pad-x: clamp(1.5rem, 4vw, 2.5rem);
--container-pad-top: clamp(3.5rem, 8vw, 6rem);
--container-pad-bottom: clamp(5rem, 12vw, 8rem);
--col-meta: 124px;
--gap-meta: 2rem;
/* Motion */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 160ms;
--dur-base: 240ms;
--dur-slow: 420ms;
/* z-index scale */
--z-base: 1;
--z-toggle: 10;
--z-focus: 20;
color-scheme: dark;
}
[data-theme="light"] {
--color-surface: oklch(0.97 0 0);
--color-surface-rise: oklch(0.93 0 0);
--color-ink: oklch(0.2 0 0);
--color-ink-soft: oklch(0.32 0 0);
--color-muted: oklch(0.45 0 0);
--color-faint: oklch(0.62 0 0);
--color-accent: oklch(0.45 0.23 322);
--color-accent-strong: oklch(0.4 0.24 318);
--color-rule: color-mix(in oklch, var(--color-ink) 14%, transparent);
--color-rule-strong: color-mix(in oklch, var(--color-ink) 26%, transparent);
--color-focus-ring: color-mix(
in oklch,
var(--color-accent) 70%,
transparent
);
--color-accent-wash: color-mix(
in oklch,
var(--color-accent) 12%,
transparent
);
color-scheme: light;
}
/* ============================================================
* Reset (tight, no bloat)
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
font-feature-settings: "kern", "liga", "calt", "ss01", "ss02";
font-variant-ligatures: common-ligatures contextual;
background: var(--color-surface);
}
body {
background: var(--color-surface);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: var(--fs-body);
line-height: var(--leading-body);
font-weight: 400;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
min-height: 100dvh;
transition:
background var(--dur-base) var(--ease-out-quint),
color var(--dur-base) var(--ease-out-quint);
}
img,
svg,
video,
canvas {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
button {
background: none;
border: none;
padding: 0;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: 1px;
}
/* ============================================================
* Page shell
* ============================================================ */
.page {
max-width: var(--container-max);
margin: 0 auto;
padding: var(--container-pad-top) var(--container-pad-x)
var(--container-pad-bottom);
}
/* ============================================================
* Theme toggle (fixed corner)
* ============================================================ */
.theme-toggle {
position: fixed;
top: clamp(1rem, 2.4vw, 1.75rem);
right: clamp(1.25rem, 3vw, 2rem);
z-index: var(--z-toggle);
display: inline-flex;
align-items: center;
gap: 0.5ch;
font-family: var(--font-mono);
font-size: var(--fs-tiny);
letter-spacing: var(--tracking-mono);
color: var(--color-muted);
}
.theme-toggle button {
font: inherit;
color: inherit;
padding: 0.25rem 0.125rem;
transition: color var(--dur-fast) var(--ease-out-quint);
position: relative;
}
.theme-toggle button:hover {
color: var(--color-ink);
}
.theme-toggle button[aria-pressed="true"] {
color: var(--color-accent);
}
.theme-toggle__sep {
color: var(--color-faint);
user-select: none;
}
/* ============================================================
* Hero (identity)
* ============================================================ */
.hero {
display: flex;
flex-direction: column;
gap: clamp(1.25rem, 3vw, 1.75rem);
}
.hero__name {
font-family: var(--font-sans);
font-size: var(--fs-display);
font-weight: 500;
line-height: var(--leading-display);
letter-spacing: var(--tracking-display);
color: var(--color-ink);
text-wrap: balance;
font-feature-settings: "kern", "ss01";
font-variation-settings: "wght" 540;
}
.hero__statement {
font-size: var(--fs-statement);
line-height: var(--leading-statement);
letter-spacing: var(--tracking-tight);
color: var(--color-ink-soft);
max-width: 58ch;
text-wrap: pretty;
}
.hero__contact {
font-family: var(--font-mono);
font-size: var(--fs-contact);
letter-spacing: var(--tracking-mono);
color: var(--color-muted);
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 1.25rem;
}
/* ============================================================
* Link grammar
* ============================================================ */
.link {
color: var(--color-accent);
text-decoration: none;
position: relative;
padding-bottom: 0.15em;
background-image: linear-gradient(
to right,
var(--color-accent),
var(--color-accent)
);
background-position: 0 100%;
background-repeat: no-repeat;
background-size: 100% 1px;
transition:
color var(--dur-fast) var(--ease-out-quint),
background-size var(--dur-base) var(--ease-out-quint);
}
.link:hover {
color: var(--color-accent-strong);
background-size: 100% 2px;
}
.link:focus-visible {
outline-offset: 4px;
}
.link--ink {
color: var(--color-ink);
background-image: linear-gradient(
to right,
var(--color-rule-strong),
var(--color-rule-strong)
);
}
.link--ink:hover {
color: var(--color-accent);
background-image: linear-gradient(
to right,
var(--color-accent),
var(--color-accent)
);
}
/* ============================================================
* Hairline divider
* ============================================================ */
.rule {
border: 0;
height: 1px;
background: var(--color-rule);
margin-block: clamp(3rem, 6vw, 4.5rem);
width: 100%;
}
/* ============================================================
* Work index (marginalia grid)
* ============================================================ */
.work {
display: flex;
flex-direction: column;
}
.role {
display: grid;
grid-template-columns: var(--col-meta) 1fr;
gap: var(--gap-meta);
padding-block: clamp(1.75rem, 3.5vw, 2.5rem);
border-top: 1px solid transparent;
transition: border-color var(--dur-base) var(--ease-out-quint);
}
.role + .role {
border-top-color: var(--color-rule);
}
.role__meta {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.625rem;
row-gap: 0.35rem;
font-family: var(--font-mono);
font-size: var(--fs-meta);
letter-spacing: var(--tracking-mono);
color: var(--color-muted);
padding-top: 0.35rem; /* optical align with role title baseline */
white-space: nowrap;
}
.role--current .role__meta {
column-gap: 0.3rem;
}
.role__years {
font-variant-numeric: tabular-nums;
}
.role__live {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-accent);
font-family: var(--font-mono);
font-size: var(--fs-tiny);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
font-variation-settings: "wght" 500;
}
.role__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-accent);
display: inline-block;
box-shadow: 0 0 0 0 var(--color-accent);
animation: pulse 2s var(--ease-out-quint) infinite;
}
.role__body {
display: flex;
flex-direction: column;
gap: 0.875rem;
max-width: 64ch;
}
.role__title {
font-family: var(--font-sans);
font-size: var(--fs-role-title);
font-weight: 500;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--color-ink);
text-wrap: balance;
font-variation-settings: "wght" 520;
}
.role__company {
font-family: var(--font-sans);
font-size: var(--fs-role-company);
color: var(--color-muted);
letter-spacing: var(--tracking-tight);
line-height: 1.35;
}
.role__bullets {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: var(--color-ink-soft);
}
.role__bullets li {
font-size: var(--fs-body);
line-height: var(--leading-body);
text-wrap: pretty;
}
.role--current .role__title {
color: var(--color-ink);
}
/* ============================================================
* Practice (areas of competence — marginalia grid)
* ============================================================ */
.practice {
display: flex;
flex-direction: column;
}
.area {
display: grid;
grid-template-columns: var(--col-meta) 1fr;
gap: var(--gap-meta);
padding-block: clamp(1.5rem, 3vw, 2.25rem);
border-top: 1px solid transparent;
transition: border-color var(--dur-base) var(--ease-out-quint);
}
.area + .area {
border-top-color: var(--color-rule);
}
.area__meta {
font-family: var(--font-mono);
font-size: var(--fs-meta);
letter-spacing: var(--tracking-mono);
color: var(--color-muted);
padding-top: 0.35rem;
}
.area__label {
display: inline-block;
}
.area__body {
display: flex;
flex-direction: column;
gap: 0.625rem;
max-width: 64ch;
}
.area__title {
font-family: var(--font-sans);
font-size: var(--fs-role-title);
font-weight: 500;
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--color-ink);
text-wrap: balance;
font-variation-settings: "wght" 520;
}
.area__desc {
font-size: var(--fs-body);
line-height: var(--leading-body);
color: var(--color-ink-soft);
text-wrap: pretty;
}
/* ============================================================
* Tools (single dense row in marginalia grid)
* ============================================================ */
.toolset {
display: grid;
grid-template-columns: var(--col-meta) 1fr;
gap: var(--gap-meta);
padding-block: clamp(1.75rem, 3.5vw, 2.5rem);
border-top: 1px solid var(--color-rule);
}
.toolset__meta {
font-family: var(--font-mono);
font-size: var(--fs-meta);
letter-spacing: var(--tracking-mono);
color: var(--color-muted);
padding-top: 0.35rem;
}
.toolset__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
font-family: var(--font-mono);
font-size: var(--fs-meta);
color: var(--color-ink-soft);
letter-spacing: var(--tracking-mono);
align-items: baseline;
}
.toolset__list li {
position: relative;
display: inline-flex;
align-items: baseline;
padding: 0.25em 0.5em;
margin: -0.25em 0;
border-radius: 3px;
color: var(--color-ink-soft);
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
font-variant-numeric: tabular-nums;
transition:
background-color var(--dur-base) var(--ease-out-quint),
color var(--dur-fast) var(--ease-out-quint),
transform var(--dur-base) var(--ease-out-quint),
box-shadow var(--dur-base) var(--ease-out-quint);
will-change: transform;
}
.toolset__list li:active {
cursor: grabbing;
}
.toolset__list li.is-dragging {
cursor: grabbing;
z-index: 5;
background-color: var(--color-accent-wash);
color: var(--color-accent);
box-shadow:
0 14px 36px color-mix(in oklch, var(--color-accent) 22%, transparent),
0 2px 8px color-mix(in oklch, var(--color-accent) 30%, transparent);
/* JS owns transform / transition during drag */
transition: none !important;
}
.toolset__list li.is-dragging::before {
color: var(--color-accent);
}
/* Suppress hover styles on other chips while a drag is in flight, so
* they don't flash as the dragged chip sweeps over them. */
.toolset__list.is-dragging-list li:not(.is-dragging):hover {
background-color: transparent;
color: var(--color-ink-soft);
transform: none;
}
.toolset__list.is-dragging-list li:not(.is-dragging):hover::before {
color: var(--color-faint);
transform: none;
}
body.is-toolset-dragging,
body.is-toolset-dragging * {
cursor: grabbing !important;
}
.toolset__list li::before {
content: "#";
color: var(--color-faint);
margin-right: 0.06em;
display: inline-block;
transform-origin: 50% 65%;
transition:
color var(--dur-fast) var(--ease-out-quint),
transform var(--dur-base) var(--ease-out-quint);
}
.toolset__list li:hover {
color: var(--color-accent);
background-color: var(--color-accent-wash);
transform: translateY(-1px);
}
.toolset__list li:hover::before {
color: var(--color-accent);
transform: rotate(-14deg) translate(-1px, -1px);
}
/* ============================================================
* Motto (closing reflection — marginalia grid)
* ============================================================ */
.motto {
display: grid;
grid-template-columns: var(--col-meta) 1fr;
gap: var(--gap-meta);
margin-top: clamp(2.75rem, 5.5vw, 4rem);
}
.motto__meta {
font-family: var(--font-mono);
font-size: var(--fs-meta);
color: var(--color-muted);
letter-spacing: var(--tracking-mono);
padding-top: 0.4rem;
user-select: none;
}
.motto__text {
font-family: var(--font-sans);
font-style: italic;
font-size: var(--fs-statement);
color: var(--color-ink-soft);
line-height: var(--leading-statement);
letter-spacing: var(--tracking-tight);
max-width: 56ch;
text-wrap: balance;
font-variation-settings:
"wght" 420,
"slnt" -10;
}
/* ============================================================
* Footer
* ============================================================ */
.foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
font-family: var(--font-mono);
font-size: var(--fs-meta);
color: var(--color-muted);
letter-spacing: var(--tracking-mono);
}
.foot__build {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.foot__build::before {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--color-rule-strong);
}
.foot__home {
font-variant-numeric: tabular-nums;
}
.foot__handle {
color: var(--color-muted);
}
/* ============================================================
* Motion: live dot pulse + theme transitions
* ============================================================ */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0
color-mix(in oklch, var(--color-accent) 50%, transparent);
}
70% {
box-shadow: 0 0 0 7px
color-mix(in oklch, var(--color-accent) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0
color-mix(in oklch, var(--color-accent) 0%, transparent);
}
}
/* ============================================================
* Responsive: stack the marginalia grid on narrow screens
* ============================================================ */
@media (max-width: 640px) {
.role,
.area,
.toolset,
.motto {
grid-template-columns: 1fr;
gap: 0.875rem;
}
.motto__meta {
display: none;
}
.role__meta {
flex-direction: row;
align-items: center;
gap: 0.875rem;
padding-top: 0;
flex-wrap: wrap;
}
.role--current .role__meta {
gap: 0.3rem;
}
.area__meta:empty {
display: none;
}
.area__meta,
.toolset__meta {
padding-top: 0;
}
.hero__statement {
max-width: 100%;
}
}
@media (min-width: 1024px) {
:root {
--container-max: 800px;
--col-meta: 132px;
}
}
/* ============================================================
* Reduced motion
* ============================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.role__dot {
box-shadow: 0 0 0 3px
color-mix(in oklch, var(--color-accent) 25%, transparent);
}
.toolset__list li:hover,
.toolset__list li:hover::before {
transform: none;
}
}
/* ============================================================
* Print
* ============================================================ */
@media print {
:root {
--color-surface: #ffffff;
--color-ink: #111111;
--color-muted: #444444;
--color-faint: #777777;
--color-accent: #6b2d7a;
--color-rule: #cccccc;
}
.theme-toggle {
display: none;
}
.role__dot {
animation: none;
}
body {
background: #fff;
color: #111;
}
}
/* ============================================================
* Selection
* ============================================================ */
::selection {
background: var(--color-accent-wash);
color: var(--color-ink);
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}