Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
www
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
PLN
www
Commits
12387c68
Commit
12387c68
authored
Oct 07, 2026
by
PLN (Algolia)
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
blog: article page anatomy — TOC rail, margin notes, code bars, lightbox, pager
parent
4fa6d331
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
871 additions
and
119 deletions
+871
-119
ArticleEnhance.jsx
app/blog/ArticleEnhance.jsx
+108
-0
PostArticle.jsx
app/blog/PostArticle.jsx
+125
-32
article-fonts.js
app/blog/article-fonts.js
+2
-0
blog.module.css
app/blog/blog.module.css
+627
-83
dev-fonts.js
app/blog/dev-fonts.js
+1
-0
next.config.js
next.config.js
+8
-4
No files found.
app/blog/ArticleEnhance.jsx
0 → 100644
View file @
12387c68
'use client'
;
import
{
useEffect
,
useRef
}
from
'react'
;
/**
* Progressive-enhancement island for the article body. The page reads
* completely without it — every job here decorates DOM the static HTML
* already shipped, and degrades to plain content when JS is off:
*
* 1. Code blocks: a language label + copy button above every shiki <pre>.
* No-JS readers get the plain pre; the button simply never exists.
* 2. Figures: click-to-zoom lightbox on a native <dialog> (Esc and the
* focus trap come free; click anywhere closes). No-JS readers see the
* inline image.
* 3. Language memory: stores the last switcher choice and flags the
* reader's usual language on later posts — a nudge, never an
* auto-redirect: the reader always gets the page they asked for.
*
* Rendered as an invisible anchor inside <article>; it scopes all its DOM
* work to the closest article so unrelated pages stay untouched.
*/
export
default
function
ArticleEnhance
({
lang
,
languages
})
{
const
anchorRef
=
useRef
(
null
);
useEffect
(()
=>
{
const
article
=
anchorRef
.
current
?.
closest
(
'article'
);
if
(
!
article
)
return
undefined
;
const
cleanups
=
[];
// --- code blocks: label + copy -----------------------------------
const
copyLabel
=
lang
===
'en'
?
'Copy'
:
'Copier'
;
const
copiedLabel
=
lang
===
'en'
?
'Copied ✓'
:
'Copié ✓'
;
article
.
querySelectorAll
(
'pre[data-language]'
).
forEach
((
pre
)
=>
{
if
(
pre
.
dataset
.
codebar
)
return
;
pre
.
dataset
.
codebar
=
'true'
;
const
holder
=
pre
.
closest
(
'[data-rehype-pretty-code-figure]'
)
??
pre
.
parentElement
;
if
(
!
holder
)
return
;
const
bar
=
document
.
createElement
(
'div'
);
bar
.
className
=
'prose-codebar'
;
const
label
=
document
.
createElement
(
'span'
);
label
.
className
=
'prose-codelang'
;
label
.
textContent
=
pre
.
dataset
.
language
||
''
;
const
button
=
document
.
createElement
(
'button'
);
button
.
type
=
'button'
;
button
.
className
=
'prose-copy'
;
button
.
textContent
=
copyLabel
;
button
.
setAttribute
(
'aria-label'
,
`
${
copyLabel
}
(
${
pre
.
dataset
.
language
}
)`
);
button
.
addEventListener
(
'click'
,
async
()
=>
{
try
{
await
navigator
.
clipboard
.
writeText
(
pre
.
innerText
);
button
.
textContent
=
copiedLabel
;
}
catch
{
return
;
// clipboard refused — leave the button as it was
}
setTimeout
(()
=>
{
button
.
textContent
=
copyLabel
;
},
1600
);
});
bar
.
append
(
label
,
button
);
holder
.
prepend
(
bar
);
cleanups
.
push
(()
=>
bar
.
remove
());
});
// --- figures: lightbox --------------------------------------------
const
dialog
=
document
.
createElement
(
'dialog'
);
dialog
.
className
=
'prose-lightbox'
;
dialog
.
addEventListener
(
'click'
,
()
=>
dialog
.
close
());
article
.
append
(
dialog
);
cleanups
.
push
(()
=>
dialog
.
remove
());
article
.
querySelectorAll
(
'.prose-figure img'
).
forEach
((
img
)
=>
{
const
open
=
()
=>
{
dialog
.
replaceChildren
(
img
.
cloneNode
(
true
));
dialog
.
showModal
();
};
img
.
addEventListener
(
'click'
,
open
);
cleanups
.
push
(()
=>
img
.
removeEventListener
(
'click'
,
open
));
});
// --- language switch memory ---------------------------------------
let
stored
=
null
;
try
{
stored
=
window
.
localStorage
.
getItem
(
'pln:blog-lang'
);
}
catch
{
// storage refused (private mode) — the switcher still works, unaided
}
article
.
querySelectorAll
(
'[data-lang-switch] a[data-lang]'
).
forEach
((
link
)
=>
{
const
target
=
link
.
dataset
.
lang
;
const
remember
=
()
=>
{
try
{
window
.
localStorage
.
setItem
(
'pln:blog-lang'
,
target
);
}
catch
{
// as above — best effort
}
};
link
.
addEventListener
(
'click'
,
remember
);
cleanups
.
push
(()
=>
link
.
removeEventListener
(
'click'
,
remember
));
if
(
stored
&&
target
===
stored
&&
languages
?.
includes
(
stored
))
{
link
.
classList
.
add
(
'langPreferred'
);
link
.
title
=
lang
===
'en'
?
'Your usual language'
:
'Votre langue habituelle'
;
}
});
return
()
=>
cleanups
.
forEach
((
fn
)
=>
fn
());
},
[
lang
,
languages
]);
return
<
div
ref=
{
anchorRef
}
hidden
aria
-
hidden=
"true"
/>;
}
app/blog/PostArticle.jsx
View file @
12387c68
import
Link
from
"next/link"
;
import
Layout
from
"@/components/layout"
;
import
Date
from
"@/components/date"
;
import
utilStyles
from
"@/styles/utils.module.css"
;
import
styles
from
"./blog.module.css"
;
import
{
syne
,
serifBody
}
from
"@/app/blog/article-fonts"
;
import
{
sansBody
}
from
"@/app/blog/dev-fonts"
;
import
{
getBlogNeighbors
}
from
"@/lib/blog"
;
import
ArticleEnhance
from
"./ArticleEnhance"
;
import
FontSwitcher
from
"./FontSwitcher"
;
/**
* The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the
...
...
@@ -11,44 +15,133 @@ import styles from "./blog.module.css";
*
* `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the
* public routes; the draft route passes neither (it ships exactly as before).
* `post.toc` is minted at build time by the markdown chain; the pager is
* computed from the visible listing (drafts follow the prod/dev rule).
*
* Anatomy notes:
* - Layout is a grid of [TOC rail][core][margin band] at ≥80rem; below
* that it collapses to a single centered column (phone-first).
* - The TOC only ships for posts with ≥3 h2/h3; otherwise nothing.
* - Footnote margin clones come from the markdown chain; CSS decides
* whether they are margin notes, popovers or nothing (see blog.module.css).
* - Zero client JS is required to read: ArticleEnhance decorates code
* blocks, figures and the language switch; FontSwitcher is dev-only.
*/
const
LABELS
=
{
fr
:
{
toc
:
"Sommaire"
,
pager
:
"Articles"
,
older
:
"Plus ancien"
,
newer
:
"Plus récent"
},
en
:
{
toc
:
"Contents"
,
pager
:
"Posts"
,
older
:
"Older"
,
newer
:
"Newer"
},
};
function
TocList
({
toc
})
{
return
(
<
ol
className=
{
styles
.
tocList
}
>
{
toc
.
map
((
entry
)
=>
(
<
li
key=
{
entry
.
id
}
data
-
depth=
{
entry
.
depth
}
>
<
a
href=
{
`#${entry.id}`
}
>
{
entry
.
text
}
</
a
>
</
li
>
))
}
</
ol
>
);
}
export
default
function
PostArticle
({
post
,
switcher
,
banner
})
{
const
toc
=
post
.
toc
??
[];
const
hasToc
=
toc
.
length
>=
3
;
const
labels
=
LABELS
[
post
.
lang
]
??
LABELS
.
fr
;
const
{
older
,
newer
}
=
getBlogNeighbors
(
post
.
slug
,
post
.
lang
);
return
(
<
Layout
>
<
article
className=
{
styles
.
article
}
>
{
switcher
&&
switcher
.
length
>
1
&&
(
<
nav
className=
{
styles
.
langSwitch
}
aria
-
label=
"Language"
>
{
switcher
.
map
((
item
,
index
)
=>
(
<
span
key=
{
item
.
lang
}
>
{
index
>
0
&&
" / "
}
{
item
.
lang
===
post
.
lang
?
(
<
b
>
{
item
.
lang
.
toUpperCase
()
}
</
b
>
<
div
className=
{
styles
.
progress
}
aria
-
hidden=
"true"
/>
{
/* next/font CSS variables: brand display face + default body serif.
The dev-only sans variable joins them when dev-fonts.js is real
(it is stubbed to null in production builds). */
}
<
article
className=
{
[
styles
.
article
,
syne
.
variable
,
serifBody
.
variable
,
sansBody
?.
variable
]
.
filter
(
Boolean
)
.
join
(
" "
)
}
>
<
div
className=
{
styles
.
shell
}
>
{
hasToc
&&
(
<
nav
className=
{
styles
.
tocRail
}
aria
-
label=
{
labels
.
toc
}
>
<
div
className=
{
styles
.
tocSticky
}
>
<
p
className=
{
styles
.
tocTitle
}
>
{
labels
.
toc
}
</
p
>
<
TocList
toc=
{
toc
}
/>
</
div
>
</
nav
>
)
}
<
div
className=
{
styles
.
core
}
>
{
switcher
&&
switcher
.
length
>
1
&&
(
<
nav
className=
{
styles
.
langSwitch
}
aria
-
label=
"Langue / Language"
data
-
lang
-
switch
>
{
switcher
.
map
((
item
,
index
)
=>
(
<
span
key=
{
item
.
lang
}
>
{
index
>
0
&&
" / "
}
{
item
.
lang
===
post
.
lang
?
(
<
b
data
-
lang=
{
item
.
lang
}
>
{
item
.
lang
.
toUpperCase
()
}
</
b
>
)
:
(
<
Link
href=
{
item
.
href
}
data
-
lang=
{
item
.
lang
}
>
{
item
.
lang
.
toUpperCase
()
}
</
Link
>
)
}
</
span
>
))
}
</
nav
>
)
}
<
h1
className=
{
styles
.
title
}
>
{
post
.
title
}
</
h1
>
<
div
className=
{
styles
.
meta
}
>
<
Date
dateString=
{
post
.
date
}
/>
{
post
.
tags
&&
post
.
tags
.
length
>
0
&&
(
<
ul
className=
{
styles
.
tags
}
>
{
post
.
tags
.
map
((
tag
)
=>
(
<
li
key=
{
tag
}
>
#
{
tag
}
</
li
>
))
}
</
ul
>
)
}
</
div
>
{
banner
&&
(
<
p
className=
{
styles
.
untranslated
}
>
{
banner
.
text
}{
" "
}
<
Link
href=
{
banner
.
href
}
>
{
banner
.
label
}
</
Link
>
</
p
>
)
}
{
post
.
description
&&
<
p
className=
{
styles
.
description
}
>
{
post
.
description
}
</
p
>
}
{
hasToc
&&
(
<
details
className=
{
styles
.
tocDetails
}
>
<
summary
>
{
labels
.
toc
}
</
summary
>
<
TocList
toc=
{
toc
}
/>
</
details
>
)
}
<
div
className=
{
styles
.
prose
}
dangerouslySetInnerHTML=
{
{
__html
:
post
.
contentHtml
}
}
/>
{
/* RELATED SEAM: post↔project threads slot in beside the pager
once /projects exists (DESIGN.md — the git-graph surface).
The pager keeps the date spine; related posts will hang off
the same nav. */
}
{
(
older
||
newer
)
&&
(
<
nav
className=
{
styles
.
pager
}
aria
-
label=
{
labels
.
pager
}
>
{
older
?
(
<
Link
href=
{
older
.
href
}
rel=
"prev"
className=
{
styles
.
pagerOlder
}
>
<
span
className=
{
styles
.
pagerDir
}
>
←
{
labels
.
older
}
</
span
>
{
older
.
title
}
</
Link
>
)
:
(
<
Link
href=
{
item
.
href
}
>
{
item
.
lang
.
toUpperCase
()
}
</
Link
>
<
span
/
>
)
}
</
span
>
))
}
</
nav
>
)
}
<
h1
className=
{
utilStyles
.
headingXl
}
>
{
post
.
title
}
</
h1
>
<
div
className=
{
`${styles.meta} ${utilStyles.lightText}`
}
>
<
Date
dateString=
{
post
.
date
}
/>
{
post
.
tags
&&
post
.
tags
.
length
>
0
&&
(
<
ul
className=
{
styles
.
tags
}
>
{
post
.
tags
.
map
((
tag
)
=>
(
<
li
key=
{
tag
}
>
#
{
tag
}
</
li
>
))
}
</
ul
>
)
}
{
newer
?
(
<
Link
href=
{
newer
.
href
}
rel=
"next"
className=
{
styles
.
pagerNewer
}
>
<
span
className=
{
styles
.
pagerDir
}
>
{
labels
.
newer
}
→
</
span
>
{
newer
.
title
}
</
Link
>
)
:
(
<
span
/>
)
}
</
nav
>
)
}
</
div
>
</
div
>
{
banner
&&
(
<
p
className=
{
styles
.
untranslated
}
>
{
banner
.
text
}{
" "
}
<
Link
href=
{
banner
.
href
}
>
{
banner
.
label
}
</
Link
>
</
p
>
)
}
{
post
.
description
&&
<
p
className=
{
styles
.
description
}
>
{
post
.
description
}
</
p
>
}
<
div
className=
{
styles
.
prose
}
dangerouslySetInnerHTML=
{
{
__html
:
post
.
contentHtml
}
}
/>
<
ArticleEnhance
lang=
{
post
.
lang
}
languages=
{
post
.
languages
}
/>
<
FontSwitcher
/>
</
article
>
</
Layout
>
);
...
...
app/blog/article-fonts.js
View file @
12387c68
...
...
@@ -20,6 +20,7 @@ export const syne = Syne({
subsets
:
[
'latin'
],
weight
:
[
'700'
,
'800'
],
display
:
'swap'
,
variable
:
'--font-syne'
,
});
export
const
serifBody
=
Source_Serif_4
({
...
...
@@ -27,4 +28,5 @@ export const serifBody = Source_Serif_4({
weight
:
[
'400'
,
'600'
,
'700'
],
style
:
[
'normal'
,
'italic'
],
display
:
'swap'
,
variable
:
'--font-article-serif'
,
});
app/blog/blog.module.css
View file @
12387c68
/* /blog styling. Two layers:
* - hashed classes (.article, .prose, …) for structure the pages control;
* - :global(.prose-*) / :global(.directive-note) for classes the markdown
* pipeline emits (lib/markdown.ts) — plain, stable names so the lib stays
* decoupled from any CSS module. Element selectors inside .prose style the
* ordinary markdown output (tables, lists, code) the same way.
* - :global(.prose-*) / :global(.directive-note) / :global(.prose-fn-*) for
* classes the markdown pipeline emits (lib/markdown.ts) — plain, stable
* names so the lib stays decoupled from any CSS module. Element selectors
* under .article style the ordinary markdown output (tables, lists, code).
*
* The article page is phone-first: one column, inline `<details>` TOC, plain
* endnote footnotes. Footnote popovers arrive at hover-capable mid widths;
* true Tufte margin notes + a sticky TOC rail arrive at ≥80rem, when the
* viewport has margins to spend.
*/
/* ------------------------------------------------------------------ *
* Shell — article grid: [toc rail] [core] [margin-note band]
* ------------------------------------------------------------------ */
.article
{
max-width
:
42rem
;
margin
:
0
auto
;
padding-bottom
:
4rem
;
word-wrap
:
break-word
;
position
:
relative
;
/* Default body face: the editorial serif (next/font var set on this
element). In prod the alternative sans pair never loads, so the
[data-article-body] branch below can only ever fire in dev. */
font-family
:
var
(
--font-article-serif
),
'Source Serif 4'
,
Georgia
,
'Times New Roman'
,
serif
;
}
:global
([
data-article-body
=
'sans'
])
.article
{
font-family
:
var
(
--font-article-sans
),
system-ui
,
-apple-system
,
'Segoe UI'
,
sans-serif
;
}
.shell
{
display
:
block
;
}
.core
{
max-width
:
68ch
;
margin-inline
:
auto
;
padding-inline
:
1.25rem
;
}
@media
(
min-width
:
80rem
)
{
.shell
{
display
:
grid
;
grid-template-columns
:
1
fr
min
(
68ch
,
100%
)
1
fr
;
column-gap
:
2rem
;
}
.core
{
grid-column
:
2
;
min-width
:
0
;
padding-inline
:
0
;
}
}
/* Reading progress — pure CSS scroll-driven animation; unsupported browsers
(and reduced-motion users) simply never see the bar. */
.progress
{
position
:
fixed
;
top
:
0
;
left
:
0
;
right
:
0
;
height
:
2px
;
background
:
var
(
--link
);
transform
:
scaleX
(
0
);
transform-origin
:
0
50%
;
z-index
:
50
;
pointer-events
:
none
;
}
@supports
(
animation-timeline
:
scroll
())
{
@media
(
prefers-reduced-motion
:
no-preference
)
{
.progress
{
animation
:
article-progress
linear
both
;
animation-timeline
:
scroll
(
root
);
}
}
}
@keyframes
article-progress
{
from
{
transform
:
scaleX
(
0
);
}
to
{
transform
:
scaleX
(
1
);
}
}
/* ------------------------------------------------------------------ *
* Header: language switch, title, meta, lede, untranslated banner
* ------------------------------------------------------------------ */
.langSwitch
{
font-size
:
0.8rem
;
letter-spacing
:
0.06em
;
color
:
var
(
--text-muted
);
}
.langSwitch
a
{
color
:
var
(
--link
);
text-decoration
:
none
;
}
.langSwitch
a
:hover
{
text-decoration
:
underline
;
}
.langSwitch
b
{
font-weight
:
700
;
color
:
var
(
--text-primary
);
}
/* The remembered language (ArticleEnhance reads localStorage) */
.langSwitch
:global
(
.langPreferred
)
{
text-decoration
:
underline
dotted
;
text-underline-offset
:
0.25em
;
}
.title
{
margin
:
0.75rem
0
0
;
font-family
:
var
(
--font-syne
),
system-ui
,
sans-serif
;
font-size
:
clamp
(
1.9rem
,
1.55rem
+
1.2vw
,
2.35rem
);
font-weight
:
800
;
line-height
:
1.12
;
letter-spacing
:
-0.02rem
;
text-wrap
:
balance
;
}
.meta
{
margin
:
0.
25
rem
0
0
;
margin
:
0.
6
rem
0
0
;
display
:
flex
;
align-items
:
baseline
;
flex-wrap
:
wrap
;
gap
:
0.75rem
;
color
:
var
(
--text-muted
);
font-size
:
0.9rem
;
}
.tags
{
list-style
:
none
;
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
0.5rem
;
margin
:
0
;
padding
:
0
;
...
...
@@ -34,45 +149,258 @@
}
.description
{
margin
:
1rem
0
0
;
font-size
:
1.1rem
;
margin
:
1.25rem
0
0
;
font-size
:
1.16rem
;
line-height
:
1.55
;
color
:
var
(
--text-muted
);
max-width
:
56ch
;
text-wrap
:
pretty
;
}
.untranslated
{
margin
:
1.25rem
0
0
;
padding
:
0.7rem
1rem
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
background
:
var
(
--surface-raised
);
color
:
var
(
--text-muted
);
font-size
:
0.92rem
;
}
.untranslated
a
{
color
:
var
(
--link
);
}
/* ------------------------------------------------------------------ *
* TOC — sticky rail (wide) / inline collapsible (narrow)
* ------------------------------------------------------------------ */
.tocDetails
{
margin
:
1.75rem
0
0
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
background
:
var
(
--surface-raised
);
font-size
:
0.92rem
;
}
.tocDetails
summary
{
display
:
flex
;
align-items
:
center
;
gap
:
0.5rem
;
padding
:
0.6rem
1rem
;
cursor
:
pointer
;
font-weight
:
600
;
list-style
:
none
;
color
:
var
(
--text-primary
);
}
.tocDetails
summary
::-webkit-details-marker
{
display
:
none
;
}
/* custom caret */
.tocDetails
summary
::after
{
content
:
''
;
margin-left
:
auto
;
width
:
0.45rem
;
height
:
0.45rem
;
border-right
:
1.5px
solid
var
(
--text-muted
);
border-bottom
:
1.5px
solid
var
(
--text-muted
);
transform
:
rotate
(
45deg
);
transition
:
transform
0.2s
ease
;
}
.tocDetails
[
open
]
summary
::after
{
transform
:
rotate
(
225deg
);
}
.tocDetails
[
open
]
summary
{
border-bottom
:
1px
solid
var
(
--border
);
}
.tocList
{
list-style
:
none
;
margin
:
0
;
padding
:
0.6rem
0
0.75rem
;
line-height
:
1.5
;
}
.tocList
li
[
data-depth
=
'3'
]
{
padding-left
:
1.1rem
;
font-size
:
0.9em
;
}
.tocList
a
{
color
:
var
(
--text-primary
);
text-decoration
:
none
;
}
.tocList
a
:hover
,
.tocList
a
:focus-visible
{
color
:
var
(
--link
);
}
.tocDetails
.tocList
{
padding-inline
:
1rem
;
}
@media
(
min-width
:
80rem
)
{
.tocDetails
{
display
:
none
;
}
.tocRail
{
grid-column
:
1
;
justify-self
:
end
;
width
:
13rem
;
}
.tocSticky
{
position
:
sticky
;
top
:
5.5rem
;
max-height
:
calc
(
100vh
-
7.5rem
);
overflow-y
:
auto
;
font-size
:
0.85rem
;
}
.tocTitle
{
margin
:
0
0
0.5rem
;
font-weight
:
600
;
color
:
var
(
--text-muted
);
font-size
:
0.78rem
;
letter-spacing
:
0.04em
;
}
}
@media
(
max-width
:
79.99rem
)
{
.tocRail
{
display
:
none
;
}
}
/* ------------------------------------------------------------------ *
* Prose body —
figures, tables, code, directive notes
* Prose body —
the reading ramp
* ------------------------------------------------------------------ */
.prose
{
margin-top
:
2rem
;
line-height
:
1.7
;
font-size
:
1.05rem
;
margin-top
:
2.25rem
;
font-size
:
1.08rem
;
line-height
:
1.65
;
overflow-wrap
:
break-word
;
}
.prose
h2
,
.prose
h3
{
margin
:
2rem
0
0.75rem
;
letter-spacing
:
-0.02rem
;
/* main.css's `.site-shell main h2` competes at (0,1,2) — the .article
prefix keeps the article's own ramp in charge. */
.article
.prose
h2
{
margin
:
2.75rem
0
0.75rem
;
font-size
:
clamp
(
1.5rem
,
1.32rem
+
0.6vw
,
1.7rem
);
font-weight
:
700
;
line-height
:
1.2
;
letter-spacing
:
-0.01em
;
text-wrap
:
balance
;
}
.article
.prose
h3
{
margin
:
2.25rem
0
0.6rem
;
font-size
:
1.35rem
;
font-weight
:
600
;
line-height
:
1.25
;
text-wrap
:
balance
;
}
.prose
a
{
.article
.prose
h4
{
margin
:
1.75rem
0
0.5rem
;
font-size
:
1.13rem
;
font-weight
:
600
;
line-height
:
1.3
;
}
.article
.prose
p
{
margin
:
0
0
1.1rem
;
text-wrap
:
pretty
;
}
.article
.prose
a
{
color
:
var
(
--link
);
text-decoration
:
underline
;
text-decoration-color
:
color-mix
(
in
srgb
,
var
(
--link
)
35%
,
transparent
);
text-underline-offset
:
0.18em
;
}
.article
.prose
a
:hover
{
text-decoration-color
:
var
(
--link
);
}
.prose
blockquote
{
.article
.prose
ul
,
.article
.prose
ol
{
margin
:
0
0
1.1rem
;
padding-left
:
1.6rem
;
}
.article
.prose
li
{
margin
:
0.3rem
0
;
}
/* Quiet quotation — indent and voice, no side-stripe furniture. */
.article
.prose
blockquote
{
margin
:
1.5rem
0
;
padding
:
0.25rem
1.25rem
;
border-left
:
3px
solid
var
(
--border
);
padding
:
0.1rem
0
0.1rem
1.4rem
;
color
:
var
(
--text-muted
);
font-size
:
0.97em
;
}
.article
.prose
hr
{
border
:
0
;
border-top
:
1px
solid
var
(
--border
);
margin
:
2.5rem
0
;
}
/* GFM tables — block+scroll so a wide table never pushes the measure at
phone widths */
.article
.prose
table
{
display
:
block
;
overflow-x
:
auto
;
border-collapse
:
collapse
;
width
:
100%
;
margin
:
1.5rem
0
;
font-size
:
0.92rem
;
font-variant-numeric
:
tabular-nums
;
}
.article
.prose
th
,
.article
.prose
td
{
border
:
1px
solid
var
(
--border
);
padding
:
0.5rem
0.75rem
;
text-align
:
left
;
}
.article
.prose
th
{
background
:
var
(
--surface-raised
);
}
/* ::note[...] / ::note directives (lib/markdown.ts directiveComponents) */
.article
.prose
:global
(
.directive-note
)
{
margin
:
1.5rem
0
;
padding
:
0.8rem
1rem
;
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
background
:
var
(
--surface-raised
);
}
.article
.prose
:global
(
.directive-note
p
:last-child
)
{
margin-bottom
:
0
;
}
/* Lone images wrapped by rehype-figure */
.prose
:global
(
.prose-figure
)
{
/* ------------------------------------------------------------------ *
* Figures + lightbox
* ------------------------------------------------------------------ */
.article
.prose
:global
(
.prose-figure
)
{
margin
:
2rem
0
;
text-align
:
center
;
}
.prose
:global
(
.prose-figure
img
)
{
.
article
.
prose
:global
(
.prose-figure
img
)
{
display
:
block
;
max-width
:
100%
;
height
:
auto
;
...
...
@@ -80,69 +408,315 @@
border-radius
:
8px
;
}
.prose
:global
(
.prose-figcaption
)
{
.
article
.
prose
:global
(
.prose-figcaption
)
{
margin-top
:
0.6rem
;
font-size
:
0.85rem
;
color
:
var
(
--text-muted
);
text-wrap
:
balance
;
}
/*
GFM tables */
.prose
table
{
border-collapse
:
collapse
;
width
:
100%
;
margin
:
1.5rem
0
;
font-size
:
0.95rem
;
/*
Click-to-zoom arrives with JS (ArticleEnhance); the affordance travels
with the cursor so no-JS readers are never promised a zoom. */
@media
(
hover
:
hover
)
{
.article
.prose
:global
(
.prose-figure
img
)
{
cursor
:
zoom-in
;
}
}
.prose
th
,
.prose
td
{
border
:
1px
solid
var
(
--border
);
padding
:
0.5rem
0.75rem
;
text-align
:
left
;
.article
:global
(
dialog
.prose-lightbox
)
{
border
:
none
;
padding
:
0
;
background
:
transparent
;
max-width
:
none
;
max-height
:
none
;
overscroll-behavior
:
contain
;
}
.
prose
th
{
background
:
var
(
--surface-raised
);
.
article
:global
(
dialog
.prose-lightbox
)
::backdrop
{
background
:
rgb
(
10
10
10
/
0.82
);
}
/* Code — shiki tokens are inline-styled by rehype-pretty-code; this supplies
the dark pre. Vesper's own background is #101010. */
.prose
pre
{
.article
:global
(
dialog
.prose-lightbox
img
)
{
display
:
block
;
width
:
auto
;
height
:
auto
;
max-width
:
92vw
;
max-height
:
88vh
;
margin
:
auto
;
border-radius
:
6px
;
cursor
:
zoom-out
;
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.article
:global
(
dialog
.prose-lightbox
),
.article
:global
(
dialog
.prose-lightbox
)
::backdrop
{
transition
:
none
;
animation
:
none
;
}
}
/* ------------------------------------------------------------------ *
* Code — shiki tokens are inline-styled by rehype-pretty-code; this
* supplies the dark pre (Vesper's own background is #101010). The label +
* copy bar is attached by ArticleEnhance; no JS, no bar.
* ------------------------------------------------------------------ */
.article
.prose
pre
{
background
:
#101010
;
color
:
#d5d5d5
;
margin
:
1.5rem
0
;
padding
:
1rem
1.25rem
;
border-radius
:
8px
;
overflow-x
:
auto
;
line-height
:
1.6
;
font-size
:
0.
9
rem
;
font-size
:
0.
88
rem
;
}
.prose
pre
code
{
.
article
.
prose
pre
code
{
display
:
block
;
padding
:
0
;
background
:
none
;
font-variant-ligatures
:
none
;
}
.prose
:not
(
pre
)
>
code
{
.
article
.
prose
:not
(
pre
)
>
code
{
background
:
var
(
--surface-raised
);
padding
:
0.15em
0.4em
;
border-radius
:
4px
;
font-size
:
0.88em
;
font-variant-ligatures
:
none
;
}
/* ::note[...] / ::note directives (lib/markdown.ts directiveComponents) */
.prose
:global
(
.directive-note
)
{
.article
.prose
:global
([
data-rehype-pretty-code-figure
])
{
margin
:
1.5rem
0
;
padding
:
0.75rem
1rem
;
border-left
:
3px
solid
var
(
--link
);
border-radius
:
0
8px
8px
0
;
background
:
var
(
--surface-raised
);
}
.prose
:global
(
.directive-note
p
:last-child
)
{
.article
:global
(
.prose-codebar
)
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
1rem
;
margin
:
1.5rem
0
0
;
padding
:
0.35rem
1rem
;
background
:
#191919
;
color
:
#9a9a9a
;
border-radius
:
8px
8px
0
0
;
font-size
:
0.75rem
;
letter-spacing
:
0.06em
;
}
/* the bar sits flush on its pre… */
.article
.prose
:global
(
.prose-codebar
)
+
pre
{
margin-top
:
0
;
border-radius
:
0
0
8px
8px
;
}
/* …and never doubles up inside the pretty-code figure wrapper */
.article
.prose
:global
([
data-rehype-pretty-code-figure
])
.article
:global
(
.prose-codebar
)
{
margin-top
:
0
;
}
.article
:global
(
.prose-codelang
)
{
text-transform
:
uppercase
;
}
.article
:global
(
.prose-copy
)
{
font
:
inherit
;
font-size
:
0.7rem
;
letter-spacing
:
0.05em
;
padding
:
0.15rem
0.55rem
;
background
:
transparent
;
border
:
1px
solid
#3a3a3a
;
border-radius
:
5px
;
color
:
#b5b5b5
;
cursor
:
pointer
;
}
.article
:global
(
.prose-copy
:hover
)
{
color
:
#ffffff
;
border-color
:
#666666
;
}
/* ------------------------------------------------------------------ *
* Footnotes — margin notes on wide screens, popovers in between,
* endnotes everywhere (the no-JS, mobile and screen-reader source).
* Markup from lib/markdown.ts rehypeMarginNotes:
* <span class="prose-fn"><sup>…ref…</sup><span class="prose-fn-body"
* aria-hidden="true">…clone…</span></span>
* ------------------------------------------------------------------ */
.article
.prose
sup
a
{
color
:
var
(
--link
);
text-decoration
:
none
;
font-weight
:
600
;
padding
:
0
0.1em
;
}
.article
.prose
:global
(
.prose-fn
)
{
position
:
relative
;
}
.article
.prose
:global
(
.prose-fn-body
)
{
display
:
none
;
}
/* inline-safe block shapes inside clones (popovers and margin notes) */
.article
.prose
:global
(
.prose-fn-block
)
{
display
:
block
;
margin
:
0
0
0.5rem
;
}
.article
.prose
:global
(
.prose-fn-block
:last-child
)
{
margin-bottom
:
0
;
}
.article
.prose
:global
(
.prose-fn-item
)
{
display
:
block
;
margin
:
0
0
0.3rem
;
}
.article
.prose
:global
(
.prose-fn-code
)
{
display
:
block
;
font-size
:
0.9em
;
white-space
:
pre-wrap
;
}
/* mid widths: hover/focus popover above the reference */
@media
(
min-width
:
48rem
)
and
(
max-width
:
79.99rem
)
and
(
hover
:
hover
)
{
.article
.prose
:global
(
.prose-fn
)
:hover
:global
(
.prose-fn-body
),
.article
.prose
:global
(
.prose-fn
)
:focus-within
:global
(
.prose-fn-body
)
{
display
:
block
;
position
:
absolute
;
bottom
:
1.5em
;
left
:
50%
;
transform
:
translateX
(
-50%
);
width
:
21rem
;
max-width
:
80vw
;
padding
:
0.8rem
1rem
;
background
:
var
(
--surface
);
border
:
1px
solid
var
(
--border
);
border-radius
:
10px
;
box-shadow
:
0
8px
24px
rgb
(
0
0
0
/
0.12
);
color
:
var
(
--text-primary
);
font-size
:
0.88rem
;
line-height
:
1.55
;
z-index
:
30
;
cursor
:
default
;
}
/* hover bridge: keeps the popover open while the pointer crosses the gap */
.article
.prose
:global
(
.prose-fn-body
)
::before
{
content
:
''
;
position
:
absolute
;
left
:
0
;
right
:
0
;
top
:
-0.9em
;
height
:
0.9em
;
}
}
/* wide screens: true Tufte margin notes — the clone floats into the right
band, clearing earlier notes, at the reference's line height */
@media
(
min-width
:
80rem
)
{
.article
.prose
:global
(
.prose-fn-body
)
{
display
:
block
;
float
:
right
;
clear
:
right
;
width
:
13rem
;
margin
:
0.1rem
-15rem
0.75rem
1.2rem
;
color
:
var
(
--text-muted
);
font-size
:
0.86rem
;
line-height
:
1.55
;
}
}
/* endnotes — always present, styled down from body text */
.article
.prose
:global
([
data-footnotes
])
{
margin-top
:
3rem
;
padding-top
:
1.25rem
;
border-top
:
1px
solid
var
(
--border
);
font-size
:
0.9rem
;
line-height
:
1.6
;
color
:
var
(
--text-muted
);
}
.article
.prose
:global
([
data-footnotes
])
ol
{
margin
:
0
;
padding-left
:
1.4rem
;
}
.article
.prose
:global
([
data-footnotes
])
li
{
margin
:
0.45rem
0
;
}
.article
.prose
:global
([
data-footnotes
])
a
{
color
:
var
(
--link
);
}
/* ------------------------------------------------------------------ *
* Pager (older / newer) + dev font badge
* ------------------------------------------------------------------ */
.pager
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
stretch
;
gap
:
1.5rem
;
margin-top
:
3.5rem
;
padding-top
:
1.25rem
;
border-top
:
1px
solid
var
(
--border
);
font-size
:
0.92rem
;
}
.pager
a
{
color
:
var
(
--text-primary
);
text-decoration
:
none
;
max-width
:
24ch
;
}
.pager
a
:hover
{
color
:
var
(
--link
);
}
.pagerDir
{
display
:
block
;
margin-bottom
:
0.2rem
;
color
:
var
(
--text-muted
);
font-size
:
0.75rem
;
letter-spacing
:
0.05em
;
}
.pagerNewer
{
margin-left
:
auto
;
text-align
:
right
;
}
.fontBadge
{
position
:
fixed
;
bottom
:
1rem
;
left
:
1rem
;
z-index
:
60
;
padding
:
0.25rem
0.7rem
;
border
:
1px
dashed
var
(
--border
);
border-radius
:
999px
;
background
:
var
(
--surface-raised
);
color
:
var
(
--text-muted
);
font-size
:
0.7rem
;
letter-spacing
:
0.04em
;
}
/* ------------------------------------------------------------------ *
* Focus + draft/lang badges (index page)
* ------------------------------------------------------------------ */
.article
:global
(
a
:focus-visible
),
.article
:global
(
button
:focus-visible
),
.tocDetails
summary
:focus-visible
{
outline
:
2px
solid
var
(
--link
);
outline-offset
:
2px
;
border-radius
:
2px
;
}
/* Draft badge (index, dev only) */
.draftBadge
{
margin-left
:
0.5rem
;
...
...
@@ -166,33 +740,3 @@
letter-spacing
:
0.08em
;
vertical-align
:
middle
;
}
/* Language switch on a post page (both languages available) */
.langSwitch
{
font-size
:
0.8rem
;
letter-spacing
:
0.06em
;
margin-bottom
:
0.75rem
;
}
.langSwitch
a
{
color
:
var
(
--link
);
}
.langSwitch
b
{
font-weight
:
600
;
}
/* Untranslated notice: the requested language has no content */
.untranslated
{
margin
:
1rem
0
0
;
padding
:
0.6rem
0.9rem
;
border-left
:
3px
solid
var
(
--border
);
border-radius
:
0
8px
8px
0
;
background
:
var
(
--surface-raised
);
color
:
var
(
--text-muted
);
font-size
:
0.9rem
;
}
.untranslated
a
{
color
:
var
(
--link
);
}
app/blog/dev-fonts.js
View file @
12387c68
...
...
@@ -20,4 +20,5 @@ export const sansBody = Atkinson_Hyperlegible({
weight
:
[
'400'
,
'700'
],
style
:
[
'normal'
,
'italic'
],
display
:
'swap'
,
variable
:
'--font-article-sans'
,
});
next.config.js
View file @
12387c68
const
path
=
require
(
'path'
);
const
webpack
=
require
(
'webpack'
);
module
.
exports
=
{
// Static export — the site ships as flat files (out/), served by self-hosted
...
...
@@ -29,11 +30,14 @@ module.exports = {
config
.
resolve
.
alias
[
'@'
]
=
path
.
resolve
(
__dirname
);
// The article body-face switcher (?fonts=) is a dev-only mockup surface.
// In production builds the alternative font module is swapped for a stub
// so no second body pair is ever emitted into the shipped bundle.
// (before alias resolution — an exact alias loses to the '@' prefix) so
// no second body pair is ever emitted into the shipped bundle.
if
(
!
dev
)
{
config
.
resolve
.
alias
[
'@/app/blog/dev-fonts$'
]
=
path
.
resolve
(
__dirname
,
'app/blog/dev-fonts.stub.js'
,
config
.
plugins
.
push
(
new
webpack
.
NormalModuleReplacementPlugin
(
/^@
\/
app
\/
blog
\/
dev-fonts$/
,
'@/app/blog/dev-fonts.stub.js'
,
),
);
}
return
config
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment