Footer quotes + right-hand table of contents

- two static quotes above the name line in every footer
- Toc component renders h1-h3 from Astro's render() headings as a sticky
  right column on >=88rem screens (hidden below); scroll-position
  highlighting via a small passive listener; shown only when a page has
  2+ headings, so listings and short notes stay clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-07-15 15:34:20 -04:00
co-authored by Claude Opus 4.8
parent ec3aa15e66
commit a05e3f7661
4 changed files with 144 additions and 5 deletions
+47
View File
@@ -0,0 +1,47 @@
---
interface Props {
headings: { depth: number; slug: string; text: string }[];
}
const { headings } = Astro.props;
const shown = headings.filter((h) => h.depth <= 3);
const min = Math.min(...shown.map((h) => h.depth));
---
<nav class="toc">
<p class="toc-title">On this page</p>
<ul>
{
shown.map((h) => (
<li class={`toc-d${h.depth - min}`}>
<a href={'#' + h.slug}>{h.text}</a>
</li>
))
}
</ul>
</nav>
<script>
// highlight the section currently in view
const toc = document.querySelector('.toc');
if (toc) {
const links = [...toc.querySelectorAll('a[href^="#"]')] as HTMLAnchorElement[];
const heads = links
.map((a) => ({
a,
el: document.getElementById(decodeURIComponent(a.hash.slice(1))),
}))
.filter((x) => x.el);
const update = () => {
let cur = heads[0];
for (const h of heads) {
if (h.el!.getBoundingClientRect().top <= 110) cur = h;
else break;
}
heads.forEach((h) => h.a.classList.toggle('toc-active', h === cur));
};
if (heads.length) {
document.addEventListener('scroll', update, { passive: true });
update();
}
}
</script>
+83 -1
View File
@@ -3,14 +3,17 @@ import 'katex/dist/katex.min.css';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { buildNavTree } from '../lib/nav.mjs'; import { buildNavTree } from '../lib/nav.mjs';
import NavTree from '../components/NavTree.astro'; import NavTree from '../components/NavTree.astro';
import Toc from '../components/Toc.astro';
interface Props { interface Props {
title: string; title: string;
description?: string; description?: string;
headings?: { depth: number; slug: string; text: string }[];
} }
const { title, description } = Astro.props; const { title, description, headings = [] } = Astro.props;
const isHome = Astro.url.pathname === '/'; const isHome = Astro.url.pathname === '/';
const navTree = buildNavTree(await getCollection('garden')); const navTree = buildNavTree(await getCollection('garden'));
const showToc = headings.filter((h) => h.depth <= 3).length >= 2;
--- ---
<!doctype html> <!doctype html>
@@ -35,6 +38,13 @@ const navTree = buildNavTree(await getCollection('garden'));
<slot /> <slot />
</main> </main>
<footer> <footer>
<div class="footer-quotes">
<p>
Failure is unique among sin, in that we can turn it into a
virtue when it drives us to succeed.
</p>
<p>The living diminish, but the machine endures...</p>
</div>
<p> <p>
Wesley Ray · <a href="https://blog.c0smere.net">blog</a> · Wesley Ray · <a href="https://blog.c0smere.net">blog</a> ·
<a href="https://git.c0smere.net/wes">git</a> · <a href="https://git.c0smere.net/wes">git</a> ·
@@ -53,6 +63,13 @@ const navTree = buildNavTree(await getCollection('garden'));
<p class="sidebar-title"><a href="/">the garden</a></p> <p class="sidebar-title"><a href="/">the garden</a></p>
<NavTree nodes={navTree} currentPath={Astro.url.pathname} /> <NavTree nodes={navTree} currentPath={Astro.url.pathname} />
</aside> </aside>
{
showToc && (
<aside class="toc-col">
<Toc headings={headings} />
</aside>
)
}
</div> </div>
<script> <script>
@@ -164,6 +181,10 @@ const navTree = buildNavTree(await getCollection('garden'));
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--faint); border-top: 1px solid var(--faint);
} }
/* TOC only exists as a right column on wide screens */
.toc-col {
display: none;
}
@media (min-width: 72rem) { @media (min-width: 72rem) {
.layout { .layout {
display: grid; display: grid;
@@ -184,6 +205,60 @@ const navTree = buildNavTree(await getCollection('garden'));
padding: 1.5rem 0.5rem 1.5rem 0; padding: 1.5rem 0.5rem 1.5rem 0;
} }
} }
@media (min-width: 88rem) {
.layout {
grid-template-columns: 17rem minmax(0, 44rem) 13rem;
max-width: 80rem;
}
.toc-col {
display: block;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 1.5rem 0 1.5rem 0.5rem;
}
}
.toc-title {
margin: 0 0 0.5rem;
font-weight: 600;
font-size: 0.85rem;
}
.toc ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.8rem;
border-left: 1px solid var(--faint);
}
.toc li {
margin: 0.2rem 0;
}
.toc li a {
display: block;
padding-left: 0.75rem;
color: var(--muted);
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -1px;
}
.toc li.toc-d1 a {
padding-left: 1.5rem;
}
.toc li.toc-d2 a {
padding-left: 2.25rem;
}
.toc li a:hover {
color: var(--accent);
}
.toc li a.toc-active {
color: var(--accent);
border-left-color: var(--accent);
}
h1, h2, h3, h4, h5, h6 {
scroll-margin-top: 1rem;
}
.sidebar-title { .sidebar-title {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-weight: 600; font-weight: 600;
@@ -253,6 +328,13 @@ const navTree = buildNavTree(await getCollection('garden'));
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
} }
.footer-quotes {
font-style: italic;
margin-bottom: 0.75rem;
}
.footer-quotes p {
margin: 0.25rem 0;
}
.odo-display { .odo-display {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+7 -2
View File
@@ -59,10 +59,15 @@ const { entry, listing } = Astro.props;
const title = entry const title = entry
? (entry.data.title ?? entry.id.split('/').pop()) ? (entry.data.title ?? entry.id.split('/').pop())
: listing.name; : listing.name;
const Content = entry ? (await render(entry)).Content : null; const rendered = entry ? await render(entry) : null;
const Content = rendered?.Content ?? null;
--- ---
<Base title={title} description={entry?.data.description}> <Base
title={title}
description={entry?.data.description}
headings={rendered?.headings ?? []}
>
<h1>{title}</h1> <h1>{title}</h1>
{Content && <Content />} {Content && <Content />}
{ {
+7 -2
View File
@@ -4,10 +4,15 @@ import Base from '../layouts/Base.astro';
const entries = await getCollection('garden'); const entries = await getCollection('garden');
const home = entries.find((e) => e.id === 'index'); const home = entries.find((e) => e.id === 'index');
const Content = home ? (await render(home)).Content : null; const rendered = home ? await render(home) : null;
const Content = rendered?.Content ?? null;
const title = home?.data.title ?? 'Wesley Ray'; const title = home?.data.title ?? 'Wesley Ray';
--- ---
<Base title={title} description={home?.data.description}> <Base
title={title}
description={home?.data.description}
headings={rendered?.headings ?? []}
>
{Content && <Content />} {Content && <Content />}
</Base> </Base>