Add sticky sidebar TOC and widen the page for large screens

Writings detail pages now show the table of contents as a sticky
sidebar once there's room (>=74rem viewport), sharing its markup with
the existing inline TOC via a new TocList.astro so neither copy drifts.
Below that breakpoint it falls back to the original inline list.

Bumped .container--wide 64rem -> 76rem (home, bookshelf, and now
writings detail) so big monitors use more of the screen. The prose
column itself stays capped at 42rem for readability; the extra room is
what makes space for the TOC gutter. Verified at 390/900/1180/1920px.
This commit is contained in:
2026-09-01 09:27:10 +03:00
parent 27e53993d6
commit 0644dcf253
4 changed files with 164 additions and 64 deletions
+7 -3
View File
@@ -104,9 +104,13 @@ approved plan for full rationale on each decision referenced below.
- [x] Series-nav (multi-part post series) — `writings/[...slug].astro`,
uses the existing `extra.series`/`extra.series_index` fields
- [x] Table of contents — inline only, no separate sidebar layout yet (that's
a design-system concern); heading ids + `headings` metadata now flow
through `toml-content-loader.ts` so `render(entry)` exposes them
- [x] Table of contents — sticky sidebar on desktop (>=74rem), falls back to
the original inline list below the tags on narrower viewports; same
heading data rendered via a shared `TocList.astro` so the two aren't
duplicated markup by hand. `.container--wide` bumped 64rem → 76rem so
big monitors use more of the screen (prose column itself stays capped
at 42rem for readability — the extra width is the TOC gutter);
verified at 390/900/1180/1920px with no distortion at any width
- [x] Prev/next post navigation
- [x] Related posts (tag-based, tag-overlap ranked, limit 3)
- [x] Per-tag descriptions on tag archive pages — `/tags/` + `/tags/[tag]/`,
+24
View File
@@ -0,0 +1,24 @@
---
interface Heading {
depth: number;
slug: string;
text: string;
}
interface Props {
headings: Heading[];
}
const { headings } = Astro.props;
---
<p class="meta">Table of Contents</p>
<ul>
{
headings.map((h) => (
<li style={{ marginLeft: `${(h.depth - 2) * 1}rem` }}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))
}
</ul>
+98 -60
View File
@@ -1,5 +1,6 @@
---
import { getCollection, render } from "astro:content";
import TocList from "../../components/TocList.astro";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
@@ -51,68 +52,105 @@ const description =
const tocHeadings = headings.filter((h) => h.depth <= 3);
---
<Base title={entry.data.title} description={description} ogImage={`/images/og/writings/${entry.id}.png`}>
<h1>{entry.data.title}</h1>
<p class="meta">
{entry.data.date.toDateString()}
{entry.data.readingMinutes && <> · {entry.data.readingMinutes} min read</>}
{entry.data.updated && <> · updated {entry.data.updated.toDateString()}</>}
</p>
{tags.map((tag) => <a class="tag" href={`/tags/${tag}/`}>#{tag}</a>)}
<Base title={entry.data.title} description={description} wide ogImage={`/images/og/writings/${entry.id}.png`}>
<div class="article-layout">
<div class="article-main">
<h1>{entry.data.title}</h1>
<p class="meta">
{entry.data.date.toDateString()}
{entry.data.readingMinutes && <> · {entry.data.readingMinutes} min read</>}
{entry.data.updated && <> · updated {entry.data.updated.toDateString()}</>}
</p>
{tags.map((tag) => <a class="tag" href={`/tags/${tag}/`}>#{tag}</a>)}
{
series && (
<nav class="series-nav" aria-label={`Post series: ${series}`}>
<p class="meta">
Series: {series} ({(entry.data.extra?.series_index ?? 0)} / {seriesEntries.length})
</p>
<ol>
{seriesEntries.map((w) => (
<li aria-current={w.id === entry.id ? "page" : undefined}>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</li>
))}
</ol>
{
series && (
<nav class="series-nav" aria-label={`Post series: ${series}`}>
<p class="meta">
Series: {series} ({(entry.data.extra?.series_index ?? 0)} / {seriesEntries.length})
</p>
<ol>
{seriesEntries.map((w) => (
<li aria-current={w.id === entry.id ? "page" : undefined}>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</li>
))}
</ol>
</nav>
)
}
{
tocHeadings.length > 0 && (
<nav class="toc toc-inline" aria-label="Table of contents">
<TocList headings={tocHeadings} />
</nav>
)
}
<article>
<Content />
</article>
<nav class="post-nav" aria-label="Post navigation">
{prev && <a href={`/writings/${prev.id}/`}>← {prev.data.title}</a>}
{next && <a href={`/writings/${next.id}/`}>{next.data.title} →</a>}
</nav>
)
}
{
tocHeadings.length > 0 && (
<nav class="toc" aria-label="Table of contents">
<p class="meta">Table of Contents</p>
<ul>
{tocHeadings.map((h) => (
<li style={{ marginLeft: `${(h.depth - 2) * 1}rem` }}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ul>
</nav>
)
}
{
related.length > 0 && (
<section>
<h2>Related Posts</h2>
{related.map((w) => (
<div class="card">
<h3>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</h3>
</div>
))}
</section>
)
}
</div>
<article>
<Content />
</article>
<nav class="post-nav" aria-label="Post navigation">
{prev && <a href={`/writings/${prev.id}/`}>← {prev.data.title}</a>}
{next && <a href={`/writings/${next.id}/`}>{next.data.title} →</a>}
</nav>
{
related.length > 0 && (
<section>
<h2>Related Posts</h2>
{related.map((w) => (
<div class="card">
<h3>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</h3>
</div>
))}
</section>
)
}
{
tocHeadings.length > 0 && (
<nav class="toc toc-sidebar" aria-label="Table of contents">
<TocList headings={tocHeadings} />
</nav>
)
}
</div>
</Base>
<style>
.article-layout {
display: grid;
grid-template-columns: minmax(0, 42rem);
justify-content: center;
align-items: start;
gap: var(--space-7);
}
.toc-sidebar {
display: none;
}
@media (min-width: 74rem) {
.article-layout {
grid-template-columns: minmax(0, 42rem) 16rem;
}
.toc-inline {
display: none;
}
.toc-sidebar {
display: block;
position: sticky;
top: var(--space-6);
max-height: calc(100vh - var(--space-7));
overflow-y: auto;
}
}
</style>
+35 -1
View File
@@ -152,7 +152,41 @@ pre {
}
.container--wide {
max-width: 64rem;
max-width: 76rem;
}
.toc {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
margin-bottom: var(--space-5);
}
.toc p.meta {
margin-bottom: var(--space-2);
}
.toc ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toc a {
text-decoration: none;
color: var(--text-muted);
}
.toc a:hover {
color: var(--accent);
}
.toc-sidebar {
padding-top: var(--space-1);
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
.eyebrow {