rework to 2000's-ish style site

This commit is contained in:
2026-08-22 11:59:33 -05:00
parent 5f7d8d03df
commit ce7ed0fd37
20 changed files with 1052 additions and 1175 deletions
-3
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import '~/assets/css/main.css';
import { import {
DEFAULT_DESCRIPTION, DEFAULT_DESCRIPTION,
DEFAULT_IMAGE, DEFAULT_IMAGE,
@@ -41,8 +40,6 @@ useHead({
}, },
], ],
}); });
onMounted(() => {});
</script> </script>
<template> <template>
+98 -133
View File
@@ -1,13 +1,22 @@
/* Sohne Buch */
/*@font-face {
font-family: 'SohneBuch';
src:
url('~/assets/fonts/Sohne/Sohne-Buch.woff') format('woff'),
url('~/assets/fonts/Sohne/Sohne-Buch.ttf') format('truetype');
font-display: swap;
}*/
/* Plantin MT Pro */ /* Plantin MT Pro */
@font-face {
font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProLight.TTF')
format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProLightIt.TTF')
format('truetype');
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face { @font-face {
font-family: 'Plantin MT Pro'; font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProRg.TTF') src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProRg.TTF')
@@ -46,18 +55,73 @@
@font-face { @font-face {
font-family: 'Plantin MT Pro'; font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProLight.TTF') src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProSmBd.TTF')
format('truetype'); format('truetype');
font-weight: 300; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Plantin MT Pro'; font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProLightIt.TTF') src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProSemiBdIt.TTF')
format('truetype'); format('truetype');
font-weight: 300; font-weight: 600;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Plantin MT Pro';
src: url('~/assets/fonts/Plantin MT Pro/PlantinMTProCnBd.TTF')
format('truetype');
font-weight: 700;
font-style: normal;
font-stretch: condensed;
font-display: swap;
}
/* Sohne Buch */
@font-face {
font-family: 'SohneBuch';
src:
url('~/assets/fonts/Sohne/Sohne-Buch.woff') format('woff'),
url('~/assets/fonts/Sohne/Sohne-Buch.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Alegreya */
@font-face {
font-family: 'Alegreya';
src: url('~/assets/fonts/Alegreya HT/Alegreya-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Alegreya';
src: url('~/assets/fonts/Alegreya HT/Alegreya-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Alegreya';
src: url('~/assets/fonts/Alegreya HT/Alegreya-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Alegreya';
src: url('~/assets/fonts/Alegreya HT/Alegreya-BoldItalic.woff2')
format('woff2');
font-weight: 700;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
@@ -70,13 +134,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Redaction';
src: url('~/assets/fonts/Redaction/Redaction-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face { @font-face {
font-family: 'Redaction'; font-family: 'Redaction';
src: url('~/assets/fonts/Redaction/Redaction-Italic.woff2') format('woff2'); src: url('~/assets/fonts/Redaction/Redaction-Italic.woff2') format('woff2');
@@ -85,57 +143,14 @@
font-display: swap; font-display: swap;
} }
/* Redaction 10 */
@font-face { @font-face {
font-family: 'Redaction 10'; font-family: 'Redaction';
src: url('~/assets/fonts/Redaction/Redaction_10-Regular.woff2') src: url('~/assets/fonts/Redaction/Redaction-Bold.woff2') format('woff2');
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 10';
src: url('~/assets/fonts/Redaction/Redaction_10-Bold.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Redaction 10';
src: url('~/assets/fonts/Redaction/Redaction_10-Italic.woff2')
format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Redaction 20 */
@font-face {
font-family: 'Redaction 20';
src: url('~/assets/fonts/Redaction/Redaction_20-Regular.woff2')
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 20';
src: url('~/assets/fonts/Redaction/Redaction_20-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 20';
src: url('~/assets/fonts/Redaction/Redaction_20-Italic.woff2')
format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Redaction 35 */
@font-face { @font-face {
font-family: 'Redaction 35'; font-family: 'Redaction 35';
src: url('~/assets/fonts/Redaction/Redaction_35-Regular.woff2') src: url('~/assets/fonts/Redaction/Redaction_35-Regular.woff2')
@@ -144,13 +159,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Redaction 35';
src: url('~/assets/fonts/Redaction/Redaction_35-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face { @font-face {
font-family: 'Redaction 35'; font-family: 'Redaction 35';
src: url('~/assets/fonts/Redaction/Redaction_35-Italic.woff2') src: url('~/assets/fonts/Redaction/Redaction_35-Italic.woff2')
@@ -160,80 +169,36 @@
font-display: swap; font-display: swap;
} }
/* Redaction 50 */
@font-face { @font-face {
font-family: 'Redaction 50'; font-family: 'Redaction 35';
src: url('~/assets/fonts/Redaction/Redaction_50-Regular.woff2') src: url('~/assets/fonts/Redaction/Redaction_35-Bold.woff2') format('woff2');
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 50';
src: url('~/assets/fonts/Redaction/Redaction_50-Bold.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
/* Space Mono */
@font-face { @font-face {
font-family: 'Redaction 50'; font-family: 'Space Mono';
src: url('~/assets/fonts/Redaction/Redaction_50-Italic.woff2') src: url('~/assets/fonts/Space Mono/SpaceMono-Regular.ttf')
format('woff2'); format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Space Mono';
src: url('~/assets/fonts/Space Mono/SpaceMono-Italic.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
/* Redaction 70 */
@font-face { @font-face {
font-family: 'Redaction 70'; font-family: 'Space Mono';
src: url('~/assets/fonts/Redaction/Redaction_70-Regular.woff2') src: url('~/assets/fonts/Space Mono/SpaceMono-Bold.ttf') format('truetype');
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 70';
src: url('~/assets/fonts/Redaction/Redaction_70-Bold.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Redaction 70';
src: url('~/assets/fonts/Redaction/Redaction_70-Italic.woff2')
format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Redaction 100 */
@font-face {
font-family: 'Redaction 100';
src: url('~/assets/fonts/Redaction/Redaction_100-Regular.woff2')
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 100';
src: url('~/assets/fonts/Redaction/Redaction_100-Bold.woff2')
format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Redaction 100';
src: url('~/assets/fonts/Redaction/Redaction_100-Italic.woff2')
format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/*@import url('https://fonts.googleapis.com/css2?family=Young+Serif&display=swap');*/
+310 -95
View File
@@ -1,163 +1,378 @@
@import './fonts.css'; @import './fonts.css';
:root { :root {
/*--font: 'Baskervville', serif;*/ --font: 'Plantin MT Pro', Georgia, serif;
/*--font: 'EB Garamond', serif;*/ --font-plain: 'SohneBuch', -apple-system, BlinkMacSystemFont, sans-serif;
--font: 'Plantin MT Pro', serif; --font-mono: 'Space Mono', ui-monospace, 'SF Mono', Menlo, monospace;
/*--font: 'Young Serif', serif;*/
/*--font: 'SohneBuch', sans-serif;*/ /* paper + ink from the old site; accent/navy sampled from blend.jpeg */
/*--font-plain: 'SohneBuch', sans-serif;*/
/*--font: 'Redaction', serif;*/
/*--font-hover: 'Redaction 70', serif;*/
--font-plain: var(--font);
--font-hover: var(--font);
--text: #333;
--muted: #666;
--bg: #f6f7f9; --bg: #f6f7f9;
--bg-secondary: #e9ebef; --bg-secondary: #e9ebef;
--dot-color: #666; --text: #2e3140;
--muted: #6a6d7a;
--color-white-500: #f6f7f9; --accent: #c45d52;
--accent-soft: #e78c88;
--navy: #2a2c61;
--hairline: #d8dbe2;
--code-bg: #f3e4e2;
--img-matte: #e9ebef;
--body-weight: 300;
--measure: 38rem;
--note-width: 13rem;
--note-gap: 2rem;
--page-pad: 1.25rem;
--col: min(var(--measure), calc(100vw - var(--page-pad) * 2));
--cluster: calc(var(--col) + var(--note-gap) + var(--note-width));
--col-left: max(var(--page-pad), calc((100vw - var(--cluster)) / 2));
--gap: 1rem; --gap: 1rem;
--radius: 4px; --radius: 4px;
} }
::selection { html.font-plantin {
background: var(--text); --font: 'Plantin MT Pro', Georgia, serif;
color: var(--bg); --body-weight: 300;
} }
.dark-mode { html.font-alegreya {
--font: 'Alegreya', Georgia, serif;
--body-weight: 400;
}
html.font-redaction {
--font: 'Redaction', Georgia, serif;
--body-weight: 400;
}
html.font-redaction-35 {
--font: 'Redaction 35', Georgia, serif;
--body-weight: 400;
}
html.font-sohne {
--font: 'SohneBuch', -apple-system, BlinkMacSystemFont, sans-serif;
--body-weight: 400;
}
html.font-space {
--font: 'Space Mono', ui-monospace, Menlo, monospace;
--body-weight: 400;
}
html.font-georgia {
--font: Georgia, 'Times New Roman', Times, serif;
--body-weight: 400;
}
html.font-palatino {
--font: Palatino, 'Palatino Linotype', 'Book Antiqua', serif;
--body-weight: 400;
}
html.font-iowan {
--font: 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;
--body-weight: 400;
}
html.font-system {
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--body-weight: 400;
}
html.font-courier {
--font: 'Courier New', Courier, ui-monospace, monospace;
--body-weight: 400;
}
.dark-mode,
html.theme-navy,
html.theme-dusk,
html.theme-midnight,
html.theme-indigo,
html.theme-storm,
html.theme-slate,
html.theme-ink,
html.theme-pine,
html.theme-wine,
html.theme-cocoa {
--text: #fbfbf7; --text: #fbfbf7;
--muted: #9aa4b8;
--accent: #e78c88;
--accent-soft: #c45d52;
--navy: #3c48a8;
}
html.theme-paper {
--bg: #f6f7f9;
--bg-secondary: #e9ebef;
--text: #2e3140;
--muted: #6a6d7a;
--accent: #c45d52;
--accent-soft: #e78c88;
--navy: #2a2c61;
--hairline: #d8dbe2;
--code-bg: #f3e4e2;
--img-matte: #e9ebef;
}
html.theme-navy {
--bg: #12192c;
--bg-secondary: #1a2238;
--hairline: #2a334c;
--code-bg: #221e34;
--img-matte: #1a2238;
}
html.theme-dusk {
--bg: #1a2236;
--bg-secondary: #252d44;
--hairline: #343e58;
--code-bg: #2c2840;
--img-matte: #252d44;
}
html.theme-midnight {
--bg: #0e1424;
--bg-secondary: #161d30;
--hairline: #242c42;
--code-bg: #1c1a2c;
--img-matte: #161d30;
}
html.theme-indigo {
--bg: #16142a;
--bg-secondary: #1f1c38;
--hairline: #2e2a4a;
--code-bg: #241e38;
--img-matte: #1f1c38;
}
html.theme-storm {
--bg: #1c2438;
--bg-secondary: #283044;
--hairline: #3a4458;
--code-bg: #2c2840;
--img-matte: #283044;
}
html.theme-slate {
--bg: #1e212b;
--bg-secondary: #2a2d3a;
--hairline: #3a3e4c;
--code-bg: #322833;
--img-matte: #2a2d3a;
}
html.theme-ink {
--bg: #0c0c0c; --bg: #0c0c0c;
--bg-secondary: #1c1c1c; --bg-secondary: #1c1c1c;
--dot-color: #666; --hairline: #2a2a2a;
--muted: #a3a3a3; --code-bg: #221c20;
--img-matte: #1c1c1c;
--color-white-500: #fbfbf7;
} }
.flex { html.theme-pine {
display: flex; --bg: #121c22;
--bg-secondary: #1a262c;
--hairline: #2a3840;
--code-bg: #1c2428;
--img-matte: #1a262c;
} }
.flex-center { html.theme-wine {
display: flex; --bg: #1a1520;
justify-content: center; --bg-secondary: #261c28;
align-items: center; --hairline: #3a2c38;
--code-bg: #2a1e28;
--img-matte: #261c28;
} }
.flex-ff { html.theme-cocoa {
flex: 1; --bg: #1c1814;
} --bg-secondary: #2a241c;
--hairline: #3c3428;
.row { --code-bg: #2c241c;
flex-direction: row; --img-matte: #2a241c;
}
.col {
flex-direction: column;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
::selection {
background: var(--accent);
color: #fff;
}
html, html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--bg); background: var(--bg);
overscroll-behavior-y: none; color: var(--text);
overscroll-behavior-x: auto; overflow-x: clip;
} }
body { body {
font-family: var(--font); font-family: var(--font);
color: var(--text);
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.65;
font-weight: var(--body-weight);
}
strong,
b {
font-weight: 700;
} }
/* full-page film grain */
body::after { body::after {
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
opacity: 0.06; opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: multiply; mix-blend-mode: multiply;
} }
html:not(.theme-paper) body::after,
.dark-mode body::after { .dark-mode body::after {
mix-blend-mode: screen; mix-blend-mode: screen;
opacity: 0.045; opacity: 0.04;
} }
/* quick page transition */ a {
.page-enter-active, color: var(--accent);
.page-leave-active { text-decoration: none;
transition: opacity 0.18s ease; text-underline-offset: 3px;
} }
.page-enter-from, a:hover {
.page-leave-to { text-decoration: underline;
opacity: 0;
} }
.basic-link { article {
color: var(--text);
transition: opacity 0.2s ease;
text-underline-offset: 4px;
}
.basic-link:hover {
opacity: 0.5;
}
.punch-link {
display: inline-block;
vertical-align: bottom;
position: relative; position: relative;
color: var(--color-white-500); width: var(--col);
text-decoration: none; margin-left: var(--col-left);
transition: all 200ms ease; margin-right: auto;
padding: 0 4px; overflow-wrap: break-word;
margin: 0 -4px;
background-image: linear-gradient(
var(--color-white-500),
var(--color-white-500)
);
background-size: 100% 1px;
background-position: bottom;
background-repeat: no-repeat;
mix-blend-mode: screen;
} }
.punch-link:hover { article h2,
background-size: 100% 100%; article h3 {
background-image: linear-gradient(#fff, #fff); font-weight: 400;
color: #000; font-style: italic;
color: var(--muted);
font-size: 1rem;
margin: 2.25rem 0 0.75rem;
line-height: 1.3;
} }
.back-link { article > h3:first-child {
display: flex; margin-top: 0;
align-items: center; }
gap: 0.5rem;
font-size: small; article p {
margin: 0 0 1em;
}
article ul,
article ol {
margin: 0 0 1em;
padding-left: 1.2em;
}
article li {
margin: 0 0 0.35em;
}
article li p {
margin: 0;
}
article hr {
margin: 2.5rem 0;
border: none;
border-top: 1px solid var(--hairline);
}
article blockquote {
--hand-w: 2.35rem;
--hand-h: calc(var(--hand-w) * 44 / 100);
position: relative;
margin: 1.5rem 0;
padding: 0.15rem 0 0.15rem 3.15rem;
border: 0;
font-style: italic;
font-weight: 400;
font-size: 1.08em;
line-height: 1.55;
color: var(--text); color: var(--text);
text-decoration: none;
transition: opacity 0.2s ease;
} }
.back-link .arrow { article blockquote p:last-child {
transition: transform 0.2s ease; margin-bottom: 0;
} }
.back-link:hover .arrow { article blockquote::before {
transform: translateX(-4px); content: '';
position: absolute;
top: 0.35em;
left: 0;
width: var(--hand-w);
height: var(--hand-h);
background: url('~/assets/images/L.png') center / contain no-repeat;
pointer-events: none;
}
html:not(.theme-paper) article blockquote::before,
.dark-mode article blockquote::before {
filter: invert(1);
}
article img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
background: var(--img-matte);
}
article p:has(> img) {
margin: 1.25rem 0;
}
article .article-body > :first-child {
margin-top: 0;
}
code {
font-family: var(--font-mono);
font-size: 0.86em;
background: var(--code-bg);
padding: 0.12em 0.35em;
border-radius: 3px;
}
pre {
margin: 0 0 1em;
padding: 0.9rem 1rem;
background: var(--bg-secondary);
border-radius: var(--radius);
overflow-x: auto;
line-height: 1.55;
}
pre code {
padding: 0;
background: none;
font-size: 0.8rem;
}
@media (max-width: 640px) {
body {
font-size: 17px;
}
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<template>
<span class="margin-note"><slot /></span>
</template>
<style scoped>
.margin-note {
display: block;
margin: 0.4rem 0 1rem;
font-size: 0.85rem;
font-style: italic;
color: var(--muted);
line-height: 1.45;
}
@media (min-width: 1200px) {
.margin-note {
position: absolute;
left: calc(100% + 2rem);
width: var(--note-width);
margin: 0;
font-size: 0.8rem;
}
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
const props = defineProps<{
bleed?: boolean | string;
}>();
const isBleed = computed(
() =>
props.bleed === true ||
props.bleed === '' ||
props.bleed === 'true',
);
</script>
<template>
<div class="notice" :class="{ 'notice--bleed': isBleed }">
<slot />
</div>
</template>
<style scoped>
.notice {
background: var(--navy);
color: #f6f7f9;
padding: 1rem 1.15rem;
margin: 1.5rem 0;
border-radius: var(--radius);
}
.notice :deep(a) {
color: var(--accent-soft);
}
.notice :deep(a:hover) {
color: #fff;
}
.notice :deep(p:last-child) {
margin-bottom: 0;
}
.notice--bleed {
box-sizing: border-box;
width: 100vw;
max-width: 100vw;
margin: 1.5rem 0 1.5rem calc(-1 * var(--col-left));
padding: 1.15rem var(--col-left);
padding-right: calc(100vw - var(--col-left) - var(--col));
border-radius: 0;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
import {
BG_THEMES,
BG_THEME_STORAGE_KEY,
DEFAULT_BG_THEME,
isBgThemeId,
type BgThemeId,
} from '~/utils/themes';
export function useBgTheme() {
const id = useState<BgThemeId>('bg-theme', () => DEFAULT_BG_THEME);
const colorMode = useColorMode();
const current = computed(
() => BG_THEMES.find((theme) => theme.id === id.value) ?? BG_THEMES[0],
);
const applyClass = (next: BgThemeId) => {
if (!import.meta.client) return;
for (const theme of BG_THEMES) {
document.documentElement.classList.remove(`theme-${theme.id}`);
}
document.documentElement.classList.add(`theme-${next}`);
colorMode.preference = next === 'paper' ? 'light' : 'dark';
};
const setTheme = (next: BgThemeId) => {
id.value = next;
applyClass(next);
if (import.meta.client) {
localStorage.setItem(BG_THEME_STORAGE_KEY, next);
}
};
onMounted(() => {
const saved = localStorage.getItem(BG_THEME_STORAGE_KEY);
const next = isBgThemeId(saved) ? saved : id.value;
setTheme(next);
});
return { id, current, setTheme, themes: BG_THEMES };
}
+35
View File
@@ -0,0 +1,35 @@
import {
BODY_FONTS,
BODY_FONT_STORAGE_KEY,
DEFAULT_BODY_FONT,
isBodyFontId,
type BodyFontId,
} from '~/utils/fonts';
export function useBodyFont() {
const id = useState<BodyFontId>('body-font', () => DEFAULT_BODY_FONT);
const applyClass = (next: BodyFontId) => {
if (!import.meta.client) return;
for (const font of BODY_FONTS) {
document.documentElement.classList.remove(`font-${font.id}`);
}
document.documentElement.classList.add(`font-${next}`);
};
const setFont = (next: BodyFontId) => {
id.value = next;
applyClass(next);
if (import.meta.client) {
localStorage.setItem(BODY_FONT_STORAGE_KEY, next);
}
};
onMounted(() => {
const saved = localStorage.getItem(BODY_FONT_STORAGE_KEY);
const next = isBodyFontId(saved) ? saved : id.value;
setFont(next);
});
return { id, setFont, fonts: BODY_FONTS };
}
+9 -62
View File
@@ -9,68 +9,15 @@ useHead({
</script> </script>
<template> <template>
<div class="error-page"> <NuxtLayout
<svg width="0" height="0" aria-hidden="true" style="position: absolute"> name="main"
<filter id="grainy" x="0" y="0" width="100%" height="100%"> :title="String(props.error.statusCode ?? 500)"
<feTurbulence :description="props.error.message ?? 'Sorry, something went wrong.'"
type="fractalNoise"
baseFrequency=".85"
numOctaves="3"
></feTurbulence>
<feColorMatrix type="saturate" values="0"></feColorMatrix>
<feBlend mode="multiply" in="SourceGraphic"></feBlend>
</filter>
</svg>
<div class="error-card">
<div class="error-content">
<h1>{{ error.status }}</h1>
<p>
{{ error.message ?? 'Sorry, something went wrong.' }}
</p>
<p>
<NuxtLink to="/" class="punch-link"
>Back to the homepage.</NuxtLink
> >
<article>
<p>
<NuxtLink to="/">Back to the homepage.</NuxtLink>
</p> </p>
</div> </article>
</div> </NuxtLayout>
</div>
</template> </template>
<style scoped>
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.error-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 16rem;
color: var(--bg);
isolation: isolate;
}
.error-card::before {
content: '';
position: absolute;
inset: 0;
background: url('~/assets/images/blend.jpeg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: url(#grainy);
z-index: -1;
}
.error-content {
color: var(--text);
}
</style>
+242 -273
View File
@@ -1,332 +1,301 @@
<script lang="ts">
// module-scoped so it survives client-side navigation —
// the intro should only ever play once per visit
let introPlayed = false;
</script>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ withDefaults(
defineProps<{
title?: string; title?: string;
description?: string; description?: string;
}>(); }>(),
{
title: "Hey, I'm Jaron.",
description: 'Software engineer by trade — builder by nature.',
},
);
const theme = useColorMode(); const { id, themes, setTheme } = useBgTheme();
const { id: fontId, fonts, setFont } = useBodyFont();
const toggleTheme = () => { const onThemeChange = (event: Event) => {
theme.preference = theme.preference === 'light' ? 'dark' : 'light'; const value = (event.target as HTMLSelectElement).value;
const next = themes.find((theme) => theme.id === value);
if (next) setTheme(next.id);
}; };
const { $gsap } = useNuxtApp(); const onFontChange = (event: Event) => {
const pageEl = useTemplateRef('pageEl'); const value = (event.target as HTMLSelectElement).value;
const initialized = ref(false); const next = fonts.find((font) => font.id === value);
if (next) setFont(next.id);
onMounted(() => { };
initialized.value = true;
if (!$gsap || !pageEl.value || introPlayed) return;
introPlayed = true;
const reduced = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const ctx = $gsap.context(() => {
if (reduced) return;
const tl = $gsap.timeline({
defaults: { ease: 'power3.out' },
});
tl.from('.header-lower', {
autoAlpha: 0,
y: 12,
duration: 0.9,
ease: 'power2.out',
})
.from('.name', { yPercent: 110, duration: 0.9 }, 0.2)
.from(
'.title',
{ yPercent: 120, autoAlpha: 0, duration: 0.8 },
0.35,
)
.from(
['.footer-upper', '.footer-lower'],
{ autoAlpha: 0, duration: 0.7, ease: 'power2.out' },
0.55,
)
.from('.header-upper', { autoAlpha: 0, duration: 0.7 }, 0.55);
tl.from(
'main',
{ autoAlpha: 0, y: 16, duration: 0.8, ease: 'power2.out' },
0.4,
);
}, pageEl.value);
onBeforeUnmount(() => ctx.revert());
});
</script> </script>
<template> <template>
<div v-show="initialized"> <div class="site">
<svg width="0" height="0" aria-hidden="true" style="position: absolute"> <header class="hero">
<filter id="grainy" x="0" y="0" width="100%" height="100%"> <div class="hero__inner">
<feTurbulence <div class="hero__nav">
type="fractalNoise" <NuxtLink to="/" class="wordmark" aria-label="Home">
baseFrequency=".85" @jaronpate
numOctaves="3"
></feTurbulence>
<feColorMatrix type="saturate" values="0"></feColorMatrix>
<feBlend mode="multiply" in="SourceGraphic"></feBlend>
</filter>
</svg>
<div class="page-wrapper" ref="pageEl">
<header class="header">
<div class="header-upper">
<slot name="header-upper">
<div class="flex-center header-upper-nav">
<button
class="theme-toggle"
@click="toggleTheme"
v-if="!theme.unknown"
>
{{ theme.value === 'dark' ? '🌚' : '🌝' }}
</button>
<div class="flex-ff"></div>
<a
class="basic-link"
href="https://github.com/jaronpate"
>
GitHub
</a>
<NuxtLink to="/writing" class="basic-link">
Writing
</NuxtLink> </NuxtLink>
<nav class="site-nav">
<NuxtLink to="/writing">Writing</NuxtLink>
<a href="https://github.com/jaronpate">GitHub</a>
<label class="theme-picker">
<span class="visually-hidden">Background</span>
<select
class="theme-select"
:value="id"
@change="onThemeChange"
>
<option
v-for="theme in themes"
:key="theme.id"
:value="theme.id"
>
{{ theme.label }}
</option>
</select>
</label>
<label class="theme-picker">
<span class="visually-hidden">Font</span>
<select
class="theme-select"
:value="fontId"
@change="onFontChange"
>
<option
v-for="font in fonts"
:key="font.id"
:value="font.id"
>
{{ font.label }}
</option>
</select>
</label>
</nav>
</div> </div>
</slot> <h1 class="hero__title">{{ title }}</h1>
</div> <p v-if="description" class="hero__sub">{{ description }}</p>
<div class="header-lower">
<slot name="header-lower">
<div class="mask">
<h1 class="name">
{{ title || "Hey, I'm Jaron" }}
</h1>
</div>
<div class="mask">
<p class="title">
{{
description ||
'Software Engineer by trade — builder by nature.'
}}
</p>
</div>
</slot>
</div> </div>
</header> </header>
<main> <div class="page-body">
<slot /> <slot />
<div style="flex: 1"></div> </div>
</main>
<footer class="footer"> <footer class="site-footer">
<div class="footer-upper flex-center"> <a href="mailto:yo@jp.wtf">yo@jp.wtf</a>
<p>
<a class="punch-link" href="mailto:yo@jp.wtf"
>Say hi.</a
>
</p>
<div class="flex-ff"></div> <div class="flex-ff"></div>
<p style="text-align: right">Let's make something cool.</p> <span>Est. 2003</span>
</div> <span>Austin, TX</span>
<div class="footer-lower flex-center" style="gap: var(--gap)">
<p style="text-align: right">Est. 2003</p>
<!-- <p style="font-family: monospace">🐟🎩.dev</p> -->
<div class="flex-ff"></div>
<p style="text-align: right">Austin, TX</p>
</div>
</footer> </footer>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.page-wrapper { .site {
--main-width: 50vw; width: 100%;
width: var(--main-width);
margin: 0 auto;
display: flex;
flex-direction: column;
/*gap: 1.5rem;*/
min-height: 100vh; min-height: 100vh;
position: relative; display: flex;
flex-direction: column;
} }
main { .page-body {
flex: 1; flex: 1;
padding-top: 2.25rem;
}
.hero {
--hero-text: #fbfbf7;
position: relative;
isolation: isolate;
color: var(--hero-text);
padding: 1.25rem 0 3rem;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background:
linear-gradient(rgba(12, 12, 18, 0.1), rgba(12, 12, 18, 0.18)),
url('~/assets/images/blend.jpeg') center / cover no-repeat;
}
.hero__inner {
width: var(--col);
margin-left: var(--col-left);
}
.hero__nav {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 1.25rem;
margin-bottom: 2.75rem;
font-family: var(--font-plain);
font-size: 0.8rem;
} }
svg[height='0'] { .wordmark,
position: fixed; .site-nav a,
.theme-select {
color: var(--hero-text);
} }
.mask { .wordmark {
overflow: hidden; display: flex;
} align-items: center;
gap: 0.7rem;
.name { text-decoration: none;
font-size: 3rem; font-family: var(--font-mono);
font-size: 1.15rem;
font-weight: 400; font-weight: 400;
line-height: 1.2; line-height: 1;
margin: 0;
} }
.title { .wordmark:hover,
font-weight: 300; .wordmark.router-link-active {
margin: 0; color: var(--hero-text);
font-style: italic; text-decoration: none;
color: var(--color-white-500); opacity: 0.75;
} }
.header { .site-nav {
display: flex; display: flex;
flex-direction: column; align-items: center;
}
.footer {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.header-upper {
height: 8vh;
min-height: 3.5rem;
}
.header-upper-nav {
padding: 0 2rem;
height: 100%;
font-size: small;
gap: 1rem; gap: 1rem;
margin-left: auto;
} }
.header-lower { .site-nav a:hover,
display: flex; .site-nav a.router-link-active {
flex-direction: column; color: var(--hero-text);
align-items: flex-start; opacity: 0.7;
justify-content: center;
gap: 16px;
padding: 3rem 2.5rem;
color: var(--color-white-500);
border-radius: 16px;
overflow: hidden;
position: relative;
isolation: isolate;
} }
.header-lower::before { .theme-picker {
content: ''; display: inline-flex;
position: absolute; align-items: center;
inset: 0; }
background: url('~/assets/images/blend.jpeg');
background-size: cover; .theme-select {
background-position: left top; appearance: none;
background-color: rgba(255, 255, 255, 0.14);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath fill='%23fbfbf7' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
filter: url(#grainy); background-position: right 0.55em center;
border-radius: 16px; background-size: 0.5em;
z-index: -1; border: 1px solid rgba(251, 251, 247, 0.35);
} border-radius: 4px;
.footer-upper {
padding: 3.25rem 2.5rem;
color: var(--color-white-500);
border-radius: 16px;
overflow: hidden;
position: relative;
isolation: isolate;
}
.footer-upper::before {
content: '';
position: absolute;
inset: 0;
background: url('~/assets/images/blend.jpeg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: url(#grainy);
border-radius: 16px;
z-index: -1;
}
.footer-lower {
padding: 0 2rem;
height: 8vh;
min-height: 3.5rem;
font-size: small;
gap: 1rem;
}
.theme-toggle {
background: transparent;
border: none;
cursor: pointer; cursor: pointer;
font-size: large; font-family: var(--font-plain);
font-weight: bold; font-size: 0.8rem;
color: var(--text); line-height: 1;
transition: opacity 0.2s ease; padding: 0.35em 1.55em 0.35em 0.65em;
}
.theme-select:hover {
background-color: rgba(255, 255, 255, 0.22);
}
.theme-select option {
color: #1a1a1a;
background: #fff;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0; padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
} }
.theme-toggle:hover { .hero__title {
opacity: 0.5;
}
.footer p {
margin: 0; margin: 0;
font-size: 2.75rem;
font-weight: 400;
line-height: 1.15;
} }
@media (max-width: 1024px) { .hero__sub {
.page-wrapper { margin: 0.6rem 0 0;
--main-width: 80vw; font-weight: var(--body-weight);
} font-style: italic;
font-size: 1.05rem;
color: rgba(251, 251, 247, 0.88);
} }
@media (max-width: 768px) { .site-footer {
.header-upper-nav { --flare-length: 1.125rem;
padding: 0 1.5rem; --flare-spread: 1.125rem;
--flare-overhang: 1.5rem;
position: relative;
display: flex;
align-items: baseline;
gap: 1rem;
width: var(--col);
margin-top: 2rem;
margin-left: var(--col-left);
margin-right: auto;
padding: calc(var(--flare-spread) + 0.45rem) 0 0.85rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--muted);
}
.site-footer::before {
content: '';
position: absolute;
top: 0;
left: calc(-1 * var(--flare-overhang));
right: calc(-1 * var(--flare-overhang));
height: var(--flare-spread);
background-color: var(--muted);
pointer-events: none;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M20 8H10M10 8C5 8 2.5 4.5 1 2M10 8C5 8 2.5 11.5 1 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
left center / var(--flare-length) 100% no-repeat,
linear-gradient(#000, #000) center /
calc(100% - var(--flare-length) * 1.55) 1px no-repeat,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M0 8H10M10 8C15 8 17.5 4.5 19 2M10 8C15 8 17.5 11.5 19 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
right center / var(--flare-length) 100% no-repeat;
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M20 8H10M10 8C5 8 2.5 4.5 1 2M10 8C5 8 2.5 11.5 1 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
left center / var(--flare-length) 100% no-repeat,
linear-gradient(#000, #000) center /
calc(100% - var(--flare-length) * 1.55) 1px no-repeat,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M0 8H10M10 8C15 8 17.5 4.5 19 2M10 8C15 8 17.5 11.5 19 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
right center / var(--flare-length) 100% no-repeat;
}
.site-footer a {
color: var(--muted);
}
.site-footer a:hover {
color: var(--accent);
}
.flex-ff {
flex: 1;
}
@media (max-width: 640px) {
.hero {
padding-bottom: 2.25rem;
} }
.name { .hero__nav {
flex-wrap: wrap;
margin-bottom: 1.75rem;
}
.hero__title {
font-size: 2rem; font-size: 2rem;
} }
.title {
font-size: 1rem;
}
.page-wrapper {
--main-width: 100vw;
padding: 0 1rem;
}
.header-lower,
.footer-upper {
padding: 1.5rem 1.5rem;
border-radius: 12px;
}
.footer-lower {
padding: 1.5rem 1.5rem;
}
.header-lower::before,
.footer-upper::before {
border-radius: 12px;
}
} }
</style> </style>
+46 -417
View File
@@ -1,10 +1,4 @@
<script lang="ts">
// module-scoped so the reveal only plays once per visit
let sectionsRevealed = false;
</script>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { import {
DEFAULT_DESCRIPTION, DEFAULT_DESCRIPTION,
DEFAULT_IMAGE, DEFAULT_IMAGE,
@@ -36,14 +30,6 @@ useHead({
link: [{ rel: 'canonical', href: SITE_URL }], link: [{ rel: 'canonical', href: SITE_URL }],
}); });
const titles = [
"Hey, I'm Jaron.",
'Nice to meet you.',
'Welcome.',
'Willkommen.',
'Bienvenue.',
];
const selectedWorks = [ const selectedWorks = [
{ {
title: 'Edlink', title: 'Edlink',
@@ -186,118 +172,21 @@ useHead({
], ],
}); });
const photos = [
{
src: '/photography/athens_red_scooter.jpg',
alt: 'Red scooter in Athens',
location: 'Athens',
year: '2026',
},
{
src: '/photography/athens_subway.jpg',
alt: 'Athens metro station',
location: 'Athens',
year: '2026',
},
{
src: '/photography/athens_sunset.jpg',
alt: 'Athens at sunset',
location: 'Athens',
year: '2026',
},
{
src: '/photography/london_porsche.jpg',
alt: 'Porsche shop in London',
location: 'London',
year: '2025',
},
{
src: '/photography/underground.jpg',
alt: 'Entrace to the London underground',
location: 'London',
year: '2026',
},
{
src: '/photography/austin_parking.jpg',
alt: 'Parking garage in Austin',
location: 'Austin',
year: '2024',
},
];
const pageTitle = ref(titles[0]);
const rotateTitle = () => {
setTimeout(() => {
const i = titles.findIndex((t) => t === pageTitle.value);
pageTitle.value = titles[i + 1] ?? titles[0] ?? 'Oops!';
rotateTitle();
}, 2000);
};
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
const { $gsap } = useNuxtApp();
const pageRoot = ref<HTMLElement | null>(null);
onMounted(() => {
rotateTitle();
useHead({
title: pageTitle,
});
if (!$gsap || !pageRoot.value || sectionsRevealed) return;
sectionsRevealed = true;
const reduced = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const ctx = $gsap.context(() => {
if (reduced) return;
// sections in view on load are revealed by the layout's intro;
// below-the-fold sections rise in as they enter the viewport
$gsap.utils.toArray<HTMLElement>('main section').forEach((el) => {
const inView = el.getBoundingClientRect().top < window.innerHeight;
if (inView) return;
$gsap.from(el, {
autoAlpha: 0,
y: 24,
duration: 0.6,
ease: 'power2.out',
scrollTrigger: {
trigger: el,
start: 'top 92%',
},
});
});
}, pageRoot.value);
onBeforeUnmount(() => ctx.revert());
});
</script> </script>
<template> <template>
<div ref="pageRoot">
<NuxtLayout name="main"> <NuxtLayout name="main">
<section class="rows"> <article>
<h5>Selected Work</h5> <h3>Selected Work</h3>
<div class="section__content"> <template v-for="work in selectedWorks" :key="work.title">
<a <p>
v-for="work in selectedWorks" <a :href="work.link" target="_blank" rel="noopener">{{
:key="work.title" work.title
class="work" }}</a>
:href="work.link" — {{ work.description }}
target="_blank"
>
<h3 class="work__title">{{ work.title }}</h3>
<p class="work__description">
{{ work.description }}
</p> </p>
<div class="work__images"> <div class="work__images">
<figure <figure
@@ -308,34 +197,26 @@ onMounted(() => {
<img :src="image.src" :alt="image.alt" /> <img :src="image.src" :alt="image.alt" />
</figure> </figure>
</div> </div>
</a> </template>
</div>
</section> <h3>Projects</h3>
<section class="rows"> <ul>
<h5>Projects</h5> <li v-for="project in projects" :key="project.href">
<div class="section__content">
<a
v-for="project in projects"
:key="project.href"
:href="project.href"
target="_blank"
class="project"
>
<h3 class="project__title">{{ project.title }}</h3>
<p class="project__description">
{{ project.description }}
</p>
</a>
</div>
</section>
<section class="about">
<h5>About</h5>
<div class="section__content">
<p> <p>
I left school, my job, and moved to Austin in 2022 to write <a :href="project.href" target="_blank" rel="noopener">{{
code full time. That was definitely one of the best and project.title
most life changing decisions I've ever made. I've learned }}</a>
a lot since then and <em>still</em> have much more to learn. — {{ project.description }}
</p>
</li>
</ul>
<h3>About</h3>
<p>
I left school, my job, and moved to Austin in 2022 to write code
full time. That was definitely one of the best and most life
changing decisions I've ever made. I've learned a lot since then
and <em>still</em> have much more to learn.
</p> </p>
<p> <p>
These days I build things because I find joy in making something These days I build things because I find joy in making something
@@ -343,155 +224,64 @@ onMounted(() => {
level of polish that users don't even notice. level of polish that users don't even notice.
</p> </p>
<p> <p>
In a time where code is cheap I pride myself in taste and execution. In a time where code is cheap I pride myself in taste and
The best sticking point — in my limited opinion so far — is the execution. The best sticking point — in my limited opinion so far
way using your product <em>feels</em>. So I want everything I make to — is the way using your product <em>feels</em>. So I want
simply <em>feel</em> the best to use. everything I make to simply <em>feel</em> the best to use.
</p> </p>
<p> <p>
If you want to build something cool or connect If you want to build something cool or connect
<a class="basic-link" href="mailto:yo@jp.wtf">hit me up</a> <a href="mailto:yo@jp.wtf">hit me up</a> — we'll grab a coffee or
— we'll grab a coffee or beer if you're in Austin! I beer if you're in Austin! I still dont know enough people to form
still dont know enough people to form a band yet ;) a band yet ;)
</p> </p>
</div> </article>
</section>
<!-- <section class="photos">
<h5>Photos</h5>
<div class="section__content">
<div class="photo-grid">
<div
v-for="photo in photos"
:key="photo.src"
class="photo-grid__item"
>
<div class="photo__image">
<img :src="photo.src" :alt="photo.alt" />
</div>
<div class="photo__overlay">
<p>{{ photo.location }}</p>
<p>{{ photo.year }}</p>
</div>
</div>
</div>
</div>
</section> -->
</NuxtLayout> </NuxtLayout>
</div>
</template> </template>
<style scoped> <style scoped>
section {
margin-top: calc(var(--gap) * 2);
}
section > .section__content {
padding: 0 calc(var(--gap) * 2);
}
/* section:not(:has(h5)) > .section__content {
padding: 2rem;
} */
section > h5 {
--flare-length: 1.125rem; /* how far the flare extends horizontally */
--flare-spread: 1.125rem; /* how far the arms open up/down */
font-weight: 400;
font-style: italic;
color: var(--muted);
display: flex;
align-items: center;
gap: 1rem;
margin: 0 0 var(--gap) 0;
line-height: 1;
}
section > h5::before {
content: '';
width: var(--flare-length);
height: var(--flare-spread);
flex-shrink: 0;
background-color: var(--muted);
/* Optically center */
transform: translateY(-0.12em);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M20 8H10M10 8C5 8 2.5 4.5 1 2M10 8C5 8 2.5 11.5 1 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
center / 100% 100% no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M20 8H10M10 8C5 8 2.5 4.5 1 2M10 8C5 8 2.5 11.5 1 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
center / 100% 100% no-repeat;
}
section > h5::after {
content: '';
height: var(--flare-spread);
flex-grow: 1;
background-color: var(--muted);
/* Optically center */
transform: translateY(-0.12em);
-webkit-mask:
linear-gradient(#000, #000) left center /
calc(100% - var(--flare-length) * 0.45) 1px no-repeat,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M0 8H10M10 8C15 8 17.5 4.5 19 2M10 8C15 8 17.5 11.5 19 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
right center / var(--flare-length) 100% no-repeat;
mask:
linear-gradient(#000, #000) left center /
calc(100% - var(--flare-length) * 0.45) 1px no-repeat,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 16' preserveAspectRatio='none' fill='none'%3E%3Cpath d='M0 8H10M10 8C15 8 17.5 4.5 19 2M10 8C15 8 17.5 11.5 19 14' stroke='black' stroke-width='1' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E")
right center / var(--flare-length) 100% no-repeat;
}
section:last-of-type {
margin-bottom: calc(var(--gap) * 2);
}
.work {
}
.work__images { .work__images {
/*display: flex; --dot-color: #666;
flex-wrap: no-wrap;
align-items: center;
justify-content: center;*/
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: max-content; grid-auto-columns: max-content;
gap: var(--gap); gap: var(--gap);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
margin-top: var(--gap); margin: 1.25rem 0 1.75rem;
padding-bottom: 0.35rem;
} }
.work__image { .work__image {
--dot-size: 6%; --dot-size: 6%;
margin: 0; margin: 0;
/*padding: calc(var(--gap) * 2) var(--gap) var(--gap);*/
padding: var(--gap); padding: var(--gap);
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--radius); border-radius: var(--radius);
background-color: var(--bg-secondary);
background-image: radial-gradient( background-image: radial-gradient(
circle at 4px 4px, circle at 4px 4px,
var(--dot-color) var(--dot-size), var(--dot-color) var(--dot-size),
transparent calc(var(--dot-size) + 1%) transparent calc(var(--dot-size) + 1%)
); );
background-position: 4px 4px; background-position: 4px 4px;
background-size: 12px 12px; background-size: 12px 12px;
background-repeat: repeat; background-repeat: repeat;
background-color: var(--bg-secondary);
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column;
gap: var(--gap);
} }
.work__image img { .work__image img {
border-radius: var(--radius); max-width: none;
object-fit: cover; width: auto;
height: auto;
max-height: 350px; max-height: 350px;
object-fit: cover;
border-radius: var(--radius);
background: none;
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 750px) {
@@ -499,165 +289,4 @@ section:last-of-type {
max-height: 300px; max-height: 300px;
} }
} }
.work__image figcaption {
font-size: 0.6rem;
line-height: 1;
font-family: var(--font-plain);
background: var(--bg-secondary);
border-radius: 2px;
padding: 3px 4px;
background: var(--dot-color);
/*border: 1px solid var(--dot-color);*/
display: flex;
align-items: center;
justify-content: center;
}
.rows > .section__content {
display: flex;
flex-direction: column;
gap: var(--gap);
}
.project,
.work {
/*border-bottom: 1px solid var(--muted);*/
/*padding: var(--gap) 0;*/
display: block;
text-decoration: none;
color: inherit;
}
.work {
border: 0;
}
.project:first-of-type,
.work:first-of-type {
padding-top: 0;
}
.project:last-of-type,
.work:last-of-type {
padding-bottom: 0;
}
.project:last-child {
border-bottom: none;
}
.project:hover > .project__title {
text-decoration: underline;
text-underline-offset: 4px;
font-style: italic;
font-family: var(--font-hover);
}
.project__title,
.work__title {
margin: 0;
font-weight: 400;
margin-bottom: calc(var(--gap) * 0.5);
}
.project__title::after,
.work__title::after {
content: '↗';
position: relative;
display: inline-block;
margin-left: 0.4em;
font-size: 0.75em;
opacity: 0;
vertical-align: center;
transform: translate(-6px, 6px);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.project:hover .project__title::after,
.work:hover .work__title::after {
opacity: 1;
transform: translate(0, 0);
}
.project__description,
.work__description {
margin: 0;
font-size: small;
font-weight: 400;
font-style: italic;
color: var(--muted);
}
.photo-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.photo-grid__item {
cursor: pointer;
position: relative;
aspect-ratio: 1 / 1;
}
.photo-grid__item:hover > .photo__overlay {
opacity: 1;
}
.photo__image {
width: 100%;
height: 100%;
}
.photo__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo__overlay {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
color: var(--color-white-500);
z-index: 2;
transition: opacity 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.photo__overlay p {
font-weight: 500;
margin: 0;
}
.photo__overlay p:nth-child(2) {
font-weight: 300;
font-style: italic;
font-size: small;
}
.about p:last-child {
margin: 0;
}
@media (max-width: 768px) {
section > .section__content {
padding: 0 var(--gap);
}
.photo-grid {
grid-template-columns: 1fr;
}
}
</style> </style>
+25 -40
View File
@@ -12,10 +12,14 @@ const articleUrl = `${SITE_URL}${route.path}`;
const webpageId = `${articleUrl}#webpage`; const webpageId = `${articleUrl}#webpage`;
const articleId = `${articleUrl}#article`; const articleId = `${articleUrl}#article`;
const blogId = `${SITE_URL}/writing#blog`; const blogId = `${SITE_URL}/writing#blog`;
const contentPath =
'/' +
(Array.isArray(route.params.slug)
? route.params.slug.join('/')
: String(route.params.slug ?? ''));
const { data: page } = await useAsyncData(route.path, () => { const { data: page } = await useAsyncData(route.path, () => {
return queryCollection('writing') return queryCollection('writing').path(contentPath).first();
.path(route.path.replace('/writing/', ''))
.first();
}); });
if (!page.value) { if (!page.value) {
@@ -134,52 +138,33 @@ useHead({
], ],
}); });
const formatDate = (date: Date | string) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
};
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
</script> </script>
<template> <template>
<div>
<NuxtLayout <NuxtLayout
name="main" name="main"
:title="page?.title" :title="page.title"
:description="page?.description" :description="formatDate(page.date)"
> >
<template #header-upper> <article v-if="page">
<div class="header-upper-inner"> <div class="article-body">
<NuxtLink <p v-if="page.description">
to="/writing" <strong>{{ page.description }}</strong>
class="back-link" </p>
aria-label="Back to writing" <ContentRenderer :value="page" />
>
<span class="arrow">←</span>
<span>Back</span>
</NuxtLink>
</div> </div>
</template> </article>
<ContentRenderer
class="writing__content"
v-if="page"
:value="page"
/>
</NuxtLayout> </NuxtLayout>
</div>
</template> </template>
<style scoped>
.header-upper-inner {
display: flex;
align-items: center;
height: 100%;
padding: 0 2rem;
}
.writing__content {
padding: 2rem;
}
.writing__content :deep(> :first-child) {
margin-top: 0;
}
</style>
+49 -88
View File
@@ -7,11 +7,9 @@ import {
websiteId, websiteId,
} from '~/utils/site'; } from '~/utils/site';
// const { data: pages } = await useAsyncData('writing-pages', () => const { data: pages } = await useAsyncData('writing-pages', () =>
// queryCollection('writing').order('date', 'DESC').all(), queryCollection('writing').order('date', 'DESC').all(),
// ); );
const pages = ref<any[]>([]);
const writingUrl = `${SITE_URL}/writing`; const writingUrl = `${SITE_URL}/writing`;
const collectionId = `${writingUrl}#collection`; const collectionId = `${writingUrl}#collection`;
@@ -74,7 +72,7 @@ useHead({
name: SITE_NAME, name: SITE_NAME,
}, },
blogPost: (pages.value ?? []).map((page) => ({ blogPost: (pages.value ?? []).map((page) => ({
'@id': `${writingUrl}/${page.path}#article`, '@id': `${writingUrl}${page.path}#article`,
})), })),
}, },
{ {
@@ -85,7 +83,7 @@ useHead({
(page, index) => ({ (page, index) => ({
'@type': 'ListItem', '@type': 'ListItem',
position: index + 1, position: index + 1,
url: `${writingUrl}/${page.path}`, url: `${writingUrl}${page.path}`,
name: page.title, name: page.title,
}), }),
), ),
@@ -114,10 +112,13 @@ useHead({
], ],
}); });
const formatDate = (dateString: string) => { const formatDate = (dateString: string | Date) => {
const date = new Date(dateString); return new Date(dateString).toLocaleDateString('en-US', {
// ex. May 5, 2026 month: 'short',
return `${date.toLocaleDateString('en-US', { month: 'short' })} ${date.getDate()}, ${date.getFullYear()}`; day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}; };
definePageMeta({ definePageMeta({
@@ -126,116 +127,76 @@ definePageMeta({
</script> </script>
<template> <template>
<div>
<NuxtLayout <NuxtLayout
name="main" name="main"
title="Writing" title="Writing"
description="Thoughts I've had here and there. Maybe you'll find something helpful or interesting here." description="Thoughts I've had here and there. Maybe you'll find something helpful or interesting here."
> >
<template #header-upper> <article>
<div class="header-upper-inner">
<NuxtLink
to="/"
class="back-link"
aria-label="Back to Home"
>
<span class="arrow">←</span>
<span>Home</span>
</NuxtLink>
</div>
</template>
<section>
<div class="section__content">
<template v-if="pages?.length"> <template v-if="pages?.length">
<NuxtLink <NuxtLink
v-for="page in pages" v-for="page in pages"
:to="'/writing/' + page.path"
:key="page.path" :key="page.path"
class="article" :to="'/writing' + page.path"
class="listing"
> >
<h3 class="article__title">{{ page.title }}</h3> <span class="listing__title">{{ page.title }}</span>
<!-- <p class="article__date"> <time :datetime="new Date(page.date).toISOString()">{{
{{ formatDate(page.date) }} formatDate(page.date)
</p> --> }}</time>
<p class="article__description">
{{ page.description }}
</p>
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else> <p v-else class="empty-state">No thoughts...head empty</p>
<div class="empty-state"> </article>
<p>No thoughts...head empty</p>
</div>
</template>
</div>
</section>
</NuxtLayout> </NuxtLayout>
</div>
</template> </template>
<style scoped> <style scoped>
.header-upper-inner { .listing {
display: flex; display: flex;
align-items: center; align-items: baseline;
height: 100%; gap: 1rem;
padding: 0 2rem; margin: 0 0 0.65rem;
}
section > .section__content {
padding: 0 2rem;
}
section:not(:has(h5)) > .section__content {
padding: 2rem;
}
.article {
border-bottom: 1px solid var(--muted);
padding: 1rem 0;
display: block;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
.article:first-of-type { .listing:hover {
padding-top: 0; text-decoration: none;
} }
.article:last-of-type { .listing:hover .listing__title {
padding-bottom: 0;
}
.article:last-child {
border-bottom: none;
}
.article:hover > .article__title {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 4px; text-underline-offset: 3px;
font-style: italic; font-style: italic;
font-family: var(--font-hover);
} }
.article__title { .listing__title {
margin: 0; color: var(--text);
font-weight: 400;
} }
.article__date { .listing time {
margin: 0; margin-left: auto;
font-weight: 400; font-family: var(--font-plain);
font-size: small; font-size: 0.75rem;
font-style: italic;
color: var(--muted); color: var(--muted);
} white-space: nowrap;
.article__description {
margin: 0;
font-size: small;
font-weight: 400;
font-style: italic;
} }
.empty-state { .empty-state {
color: var(--muted); color: var(--muted);
font-style: italic;
}
@media (max-width: 640px) {
.listing {
flex-direction: column;
gap: 0.15rem;
}
.listing time {
margin-left: 0;
}
} }
</style> </style>
-13
View File
@@ -1,13 +0,0 @@
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export default defineNuxtPlugin(() => {
gsap.registerPlugin(ScrollTrigger);
return {
provide: {
gsap,
ScrollTrigger,
},
};
});
+22
View File
@@ -0,0 +1,22 @@
export const BODY_FONTS = [
{ id: 'plantin', label: 'Plantin' },
{ id: 'alegreya', label: 'Alegreya' },
{ id: 'redaction', label: 'Redaction' },
{ id: 'redaction-35', label: 'Redaction 35' },
{ id: 'sohne', label: 'Sohne' },
{ id: 'space', label: 'Space Mono' },
{ id: 'georgia', label: 'Georgia' },
{ id: 'palatino', label: 'Palatino' },
{ id: 'iowan', label: 'Iowan' },
{ id: 'system', label: 'System Sans' },
{ id: 'courier', label: 'Courier' },
] as const;
export type BodyFontId = (typeof BODY_FONTS)[number]['id'];
export const DEFAULT_BODY_FONT: BodyFontId = 'plantin';
export const BODY_FONT_STORAGE_KEY = 'body-font';
export function isBodyFontId(value: string | null): value is BodyFontId {
return BODY_FONTS.some((font) => font.id === value);
}
+22
View File
@@ -0,0 +1,22 @@
export const BG_THEMES = [
{ id: 'dusk', label: 'Dusk' },
{ id: 'navy', label: 'Navy' },
{ id: 'midnight', label: 'Midnight' },
{ id: 'indigo', label: 'Indigo' },
{ id: 'storm', label: 'Storm' },
{ id: 'slate', label: 'Slate' },
{ id: 'ink', label: 'Ink' },
{ id: 'pine', label: 'Pine' },
{ id: 'wine', label: 'Wine' },
{ id: 'cocoa', label: 'Cocoa' },
{ id: 'paper', label: 'Paper' },
] as const;
export type BgThemeId = (typeof BG_THEMES)[number]['id'];
export const DEFAULT_BG_THEME: BgThemeId = 'dusk';
export const BG_THEME_STORAGE_KEY = 'bg-theme';
export function isBgThemeId(value: string | null): value is BgThemeId {
return BG_THEMES.some((theme) => theme.id === value);
}
+23
View File
@@ -0,0 +1,23 @@
---
title: Notes in the margin
date: 2026-08-22
description: A quick look at the two asides this site knows how to make.
---
Most of what I write should just be the thing itself. Sometimes a thought doesn't belong in the sentence, though, it belongs next to it. :margin-note[(like this — a margin note sits beside the column on a wide screen, and drops under the paragraph on a small one)] That's what margin notes are for. Parentheticals you can actually skip.
Notices are the opposite. They interrupt on purpose.
::notice
**A note you shouldn't skip.** Use a notice when the aside *is* the point for a minute. It stays in the column, navy, hard to miss.
::
And if it needs to break the column entirely:
::notice{bleed}
**Full-bleed.** Same idea, but it runs edge to edge. Save it for the one time in a piece you actually want to shout.
::
That's the whole vocabulary. Margin for a mutter. Band for a point. Everything else is just the writing.
> A blockquote gets the little pointing hand in the left margin. Use it when you're quoting someone, or when you want a line to feel pointed at.
+11 -6
View File
@@ -29,8 +29,9 @@ export default defineNuxtConfig({
}, },
}, },
css: ['~/assets/css/main.css'],
app: { app: {
pageTransition: { name: 'page', mode: 'out-in' },
head: { head: {
htmlAttrs: { lang: 'en' }, htmlAttrs: { lang: 'en' },
charset: 'utf-8', charset: 'utf-8',
@@ -44,17 +45,21 @@ export default defineNuxtConfig({
}, },
], ],
link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }], link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }],
script: [
{
innerHTML: `(function(){try{var t=localStorage.getItem('bg-theme')||'dusk';var f=localStorage.getItem('body-font')||'plantin';document.documentElement.classList.add('theme-'+t,'font-'+f);}catch(e){document.documentElement.classList.add('theme-dusk','font-plantin');}})();`,
tagPosition: 'head',
},
],
}, },
}, },
modules: ['@nuxt/content', '@nuxtjs/color-mode'], modules: ['@nuxt/content', '@nuxtjs/color-mode'],
colorMode: { colorMode: {
preference: 'system', // default value of $colorMode.preference preference: 'dark',
fallback: 'light', // fallback value if not system preference found fallback: 'dark',
globalName: '__NUXT_COLOR_MODE__',
componentName: 'ColorScheme',
classPrefix: '', classPrefix: '',
classSuffix: '-mode', // Results in .dark-mode or .light-mode classSuffix: '-mode',
storageKey: 'color-mode', storageKey: 'color-mode',
}, },
}); });