diff --git a/content/about.md b/content/about.md
@@ -33,5 +33,5 @@ What I write is under [writing](/writing/), and the tools I have published are u
generator that builds [sqzer.com](https://sqzer.com/). There is more on
[GitHub](https://github.com/sqzer-x), a service of mine at
[lacrymaria.com](https://lacrymaria.com), and I am on
-[LinkedIn](https://www.linkedin.com/in/devastator-x). Mail reaches me at
+[LinkedIn](https://www.linkedin.com/in/sqzer). Mail reaches me at
contact.extinct932@passmail.net.
diff --git a/content/writing/VTZero.md b/content/writing/VTZero.md
@@ -7,7 +7,7 @@ tags: []
# 개요
Virustotal의 vt-py를 활용하여 유의미한 데이터(국가, 소유자, 유해정도) 추철 및 대량 검색기능 제공
-https://github.com/devastator-x/VTZero
+https://github.com/sqzer-x/VTZero
# 필요조건
VirusTotal API 키 필요
@@ -16,7 +16,7 @@ API키는 회원가입 하면 자동으로 발급이 되며 키는 `https://www.
# 설정
1. Repo clone
```bash
-git clone https://github.com/devastator-x/VTZero.git
+git clone https://github.com/sqzer-x/VTZero.git
cd your-repo
```
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -70,6 +70,18 @@ const pageName = currentNav?.label ?? SITE_OWNER;
every other date on this site. Stamping it at build time rather than typing 2026 means the
notice cannot quietly go stale — and if a year passes with no push, it correctly keeps
showing the year of the last publication rather than a year nothing was published in. */
+/* Profiles, not a link list: rel="me" below only means anything for places that are
+ this person. GitHub and Instagram are the pair sqzer.com's own footer carries. The
+ LinkedIn handle is the author's own correction — what stood here came from the previous
+ about.md and was /in/devastator-x, the same handle as a GitHub account that now 404s.
+ No automated check would have caught it: LinkedIn answers robots with a 999 whatever
+ the URL, so a dead profile and a live one look identical from here. */
+const ELSEWHERE = [
+ { label: 'GitHub', href: 'https://github.com/sqzer-x' },
+ { label: 'LinkedIn', href: 'https://www.linkedin.com/in/sqzer' },
+ { label: 'Instagram', href: 'https://www.instagram.com/sqzer_x' },
+];
+
const SITE_YEAR_FROM = 2009;
const buildYear = new Date().getUTCFullYear();
---
@@ -107,6 +119,14 @@ const buildYear = new Date().getUTCFullYear();
<main id="main"><slot /></main>
<footer class="site-foot">
<p>Copyright © {SITE_YEAR_FROM}–{buildYear} {SITE_OWNER}</p>
+ {/* rel="me" because these are identity claims, not references — it is what lets a
+ profile link back and have the pair mean something. No target="_blank": the site
+ opens nothing in a new tab anywhere else, and the footer is not the place to
+ start. Text rather than the icons sqzer.com uses, because this design has no
+ icon vocabulary in its chrome and one pair of glyphs would be the whole of it. */}
+ <nav class="site-foot__social" aria-label="Elsewhere">
+ {ELSEWHERE.map((n) => <a href={n.href} rel="me">{n.label}</a>)}
+ </nav>
</footer>
</body>
</html>
diff --git a/src/pages/code/index.astro b/src/pages/code/index.astro
@@ -131,9 +131,47 @@ const ko = projects.some((p) => hasHangul(p.data.name, p.body));
reference sets its own outbound links at. The underline comes back on hover and on
keyboard focus, where a second signal is cheap and colour alone would be the only cue
that the pointer is over a target. */
- .project__name a { color: var(--crimson); text-decoration: none; }
- .project__name a:hover,
- .project__name a:focus-visible { color: var(--crimson-dark); text-decoration: underline; }
+ /* The rule that comes back on hover is the header nav's wipe, opening from the centre
+ over 0.3s -- see .hover-wipe in base.css. The name already wraps, which is why it
+ takes the per-fragment technique rather than an absolute bar: an absolute bar would
+ draw one rectangle under the last line at the width of the widest. Measured: at 375
+ the longest name fits by 6.45px (321.55px in a 328px column) and nothing wraps, but
+ the column is 313px at 360 and 273px at 320, and USFX takes two lines from 360 down,
+ UDFX from 320, OMMP from 280. Hovered at 320, USFX draws two rules of 144 and 171px,
+ one per line, each opening from its own centre.
+ The rule needs to sit one pixel lower than the padding-box bottom, not higher:
+ measured at 1700, the auto underline's top row was 148.88 and the unpadded background's
+ 147.88. Pushing down is `padding-block-end`, never --wipe-y -- the background is
+ clipped to the border box and a position past 100% is simply not painted. This link is
+ inline, so the padding costs no layout: A/B'd with it removed, .project__name,
+ .project__about and .projects and the document height are identical to three decimals
+ at 1700 and at 375, and the only number that moves is the link's own border box, 26 ->
+ 25. 0.05em is that pixel at 20px and 0.9px at the 18px floor.
+ The colour keeps stepping to --crimson-dark, and both halves move inside the pointer
+ gate: a bare :hover latches after a tap on a touch screen and would leave the project
+ marked until the next tap elsewhere. :focus-visible stays outside the gate -- it is
+ the keyboard path, and it had no underline at all before the gate went in. */
+ .project__name a { color: var(--crimson); text-decoration: none; padding-block-end: 0.05em; }
+ /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+ place only - the header nav - and every link in the body of a page, its writing index,
+ its code list and even its year rail, simply lifts from rgb(160,32,32) to
+ rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+ .project__name a:focus-visible { color: var(--crimson-lift); }
+ @media (hover: hover) {
+ .project__name a:hover { color: var(--crimson-lift); }
+ }
+
+ /* Forced colours drop background-image, so the wipe is not painted there at all and the
+ colour step is overridden too -- see the note beside .hover-wipe in base.css. The
+ underline this rule replaced is what that mode can still draw, so it comes back here
+ and only here. Written in this file rather than in base.css because Astro scopes a
+ page rule on every compound, which outranks the same selector written globally. */
+ @media (forced-colors: active) {
+ .project__name a:focus-visible { text-decoration: underline; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .project__name a:hover { text-decoration: underline; }
+ }
/* margin-top is 0.375rem, matching .entry__deck on /writing/ — a description belongs
to the name above it, so the gap has to read as smaller than the --sp-32 between
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -204,6 +204,13 @@ const ld = {
document: how-to-win's list is 25 English headings to 3 Korean, and
langOf() drops an agreeing value so a reader is not switched for them.
Same call the Writing index makes on the same kind of string. */}
+ {/* The <span> exists only so the hover rule has an inline box to draw on. The
+ link itself is a -webkit-box with a 2-line clamp, which Chrome blockifies,
+ so it fills the 265px rail column while its text measures 87px -- a rule on
+ it would be a 265px bar under 87px of heading. The span's fragments are the
+ text, per line (measured 257.98 / 132.86 on a wrapped entry), and the clamp,
+ the ellipsis, the hover clamp-lift and the scroll spy's
+ querySelectorAll on .toc-rail__link all read the <a> and are unaffected. */}
<a class="toc-rail__link" href={`#${h.slug}`} data-target={h.slug} lang={langOf(h.text, 'en')}>{h.text}</a>
</li>
))}
@@ -248,6 +255,9 @@ const ld = {
{older && (
<a class="adj__item" href={postUrl(older)} rel="prev">
<span class="adj__label">Older</span>
+ {/* Inline wrapper for the same reason as the rail entry above: .adj__title is
+ a grid item and so a block box filling its 832-928px column, while the
+ title measures 341px. The span is what the rule is drawn on. */}
<span class="adj__title">{older.data.title}</span>
</a>
)}
@@ -673,8 +683,41 @@ const ld = {
border-block-start: 4px solid var(--ppc-ui);
margin-block-end: var(--sp-40);
}
- .post__kicker a { color: inherit; text-decoration: none; }
- .post__kicker a:hover { text-decoration: underline; text-underline-offset: 0.3em; }
+ /* The header nav's wipe, via .hover-wipe in base.css. Neither of these two ever wraps
+ ("Writing" and a four-digit year), but they take the same shared rule as everything
+ else rather than a private copy of the absolute-bar technique.
+ This is the one site whose rule sits lower than the padding-box bottom, and the way to
+ do that is padding, not --wipe-y: the background is clipped to the border box, so a
+ position past 100% paints nothing. Measured, when this was written as
+ `calc(100% + 0.14em)`: the rule disappeared completely. `padding-block-end` moves the
+ box the background is clipped to, and on an inline box it changes no line box and no
+ layout: A/B'd with it removed, .post__kicker, .post__title, .post__byline and .prose
+ and the document height are identical to three decimals at 1700 and at 375, and the
+ only number that moves is this link's own border box, 19.97 -> 18.00.
+ 0.14em is the 2px that puts the rule back where `text-underline-offset: 0.3em` had it,
+ 4px under the baseline -- the deepest offset on the site, because this line is
+ uppercase with 1.68px of tracking and the rule has to clear the caps, not an
+ x-height. */
+ .post__kicker a { color: inherit; text-decoration: none; padding-block-end: 0.14em; }
+ /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+ place only - the header nav - and every link in the body of a page, its writing index,
+ its code list and even its year rail, simply lifts from rgb(160,32,32) to
+ rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+ .post__kicker a:focus-visible { color: var(--crimson); }
+ @media (hover: hover) {
+ .post__kicker a:hover { color: var(--crimson); }
+ }
+
+ /* Forced colours drop background-image, so the wipe is not painted there and the colour
+ step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+ this rule replaced is what that mode can still draw, so it comes back here, with the
+ offset it carried before. */
+ @media (forced-colors: active) {
+ .post__kicker a:focus-visible { text-decoration: underline; text-underline-offset: 0.3em; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .post__kicker a:hover { text-decoration: underline; text-underline-offset: 0.3em; }
+ }
.post__sep { margin-inline: 0.35em; color: var(--page-hairline); }
/* The date sits in the byline now, not out in the rail.
@@ -800,8 +843,35 @@ const ld = {
sysmon's seven and journalctl's four with them. */
.toc__item[data-level="1"] { padding-inline-start: var(--sp-40); }
.toc__item[data-level="2"] { padding-inline-start: calc(2 * var(--sp-40)); }
- .toc__item a { font-weight: 400; text-decoration: none; color: var(--page-fg); }
- .toc__item a:hover { text-decoration: underline; }
+ /* Headings here wrap from 768 down -- counted over all 136 contents entries in the
+ corpus: 1 wraps at 768, 2 at 430, 5 at 375, 10 at 320, and at 320 three of them take a
+ third line. That is why the rule is .hover-wipe's per-fragment background and not an
+ absolute bar: each line gets its own rule opening from its own centre, measured at 3
+ rules of 221 / 231 / 156px on the worst of them. The 1px of padding puts the rule on
+ the row the auto underline
+ had at 1024 (top 401.53) and one row below it at 768, which is the right way to miss
+ by a pixel: these headings are the most Latin text on the page -- DoH(DNS over HTTPS),
+ journalctl, sysmon -- and the rule can no longer skip their descenders, so the pixel
+ is spent clearing them rather than crowding them. Same A/B as the kicker: .toc__item,
+ .toc__list, .prose and the document height unmoved, only the link's own box 19 -> 18.
+ Colour stays --page-fg at rest; the rule itself is crimson, like the header's. */
+ .toc__item a { font-weight: 400; text-decoration: none; color: var(--page-fg);
+ padding-block-end: 0.07em; }
+ .toc__item a:focus-visible { color: var(--crimson); }
+ @media (hover: hover) {
+ .toc__item a:hover { color: var(--crimson); }
+ }
+
+ /* Forced colours drop background-image, so the wipe is not painted there and the colour
+ step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+ this rule replaced is what that mode can still draw, so it comes back here, with the
+ offset it carried before. */
+ @media (forced-colors: active) {
+ .toc__item a:focus-visible { text-decoration: underline; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .toc__item a:hover { text-decoration: underline; }
+ }
/* ── the rail ────────────────────────────────────────────────────────────
The reference site floats a fixed #leftnav at `left: calc(50% - 31em)` and slides it
@@ -1097,7 +1167,40 @@ const ld = {
border-block-end: 0;
padding-block-end: 0;
}
- .toc-rail__link:hover { color: var(--page-fg); text-decoration: underline; text-underline-offset: 0.18em; }
+ /* Hover lives on the <a> and the rule is drawn on the <span> inside it -- see the
+ markup note beside the wrapper. The 1px of padding-block-end is what puts the rule
+ where `text-underline-offset: 0.18em` had it. It has to be padding: written as
+ `--wipe-y: calc(100% + 0.07em)` the rule came out 1px thick instead of 2px, clipped
+ by the border box, which is the kind of thing only a pixel scan catches. The span is
+ inline, so the padding changes no layout at all here -- A/B'd with it removed, even
+ .toc-rail__link's own box is unchanged, because the padding sits inside the clamped
+ box rather than around it -- and it is not eaten by the link's `overflow: hidden`
+ either: measured, the rule draws its full 2px inside the clamp, on every visible line.
+ Checked on the cognitive-dissonance post at 1440, the one article with entries past
+ two lines: rest clamps to 2 lines with the ellipsis intact, hover lifts to 4
+ (41.63 -> 83.25px) and all four lines carry their own rule.
+ The colour step to --page-fg stays and moves inside the pointer gate with the rule:
+ on a touch screen a bare :hover would leave one rail entry darkened until the next
+ tap elsewhere, which on a rail of up to 28 entries reads as a wrong current-item
+ mark. The
+ clamp-lift two rules down is deliberately left ungated -- it is a way to read a
+ clipped heading, not a way to mark one, and gating it would take that away from a
+ touch reader entirely. */
+ .toc-rail__link:focus-visible { color: var(--crimson); }
+ @media (hover: hover) {
+ .toc-rail__link:hover { color: var(--crimson); }
+ }
+
+ /* Forced colours drop background-image, so the wipe is not painted there and the colour
+ step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+ this rule replaced is what that mode can still draw, so it comes back here, with the
+ offset it carried before. */
+ @media (forced-colors: active) {
+ .toc-rail__link:focus-visible { text-decoration: underline; text-underline-offset: 0.18em; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .toc-rail__link:hover { text-decoration: underline; text-underline-offset: 0.18em; }
+ }
.toc-rail__link[aria-current] { color: var(--ppc-text); font-weight: 700; }
/* Reading the clipped half of a heading should not need a pointer. */
.toc-rail__link:hover,
@@ -1749,9 +1852,9 @@ const ld = {
@media (forced-colors: active) {
.prose .footnotes li:target { background: none; border-inline-start: 2px solid; }
}
- /* Both ends of the round trip. The global <a> rule is 700 and underlined, which on a bare
- numeral and a bare arrow is a heavy mark for a small target, so both take the accent
- colour instead and keep the body's weight. They stay real links with a real focus ring
+ /* Both ends of the round trip. These keep an explicit accent colour of their own rather
+ than inheriting the global link colour, because a bare numeral and a bare arrow are
+ small targets that want the darker end of the family. They stay real links with a real focus ring
-- the site's :focus-visible outline is untouched here. */
.prose [data-footnote-ref],
.prose .data-footnote-backref {
@@ -1759,6 +1862,14 @@ const ld = {
font-weight: 400;
color: var(--ppc-text);
}
+ /* The one hover underline on the site that does NOT take the header's wipe, and the
+ reason is that these two are not navigation labels. They are a bracketed numeral and
+ a return arrow, roughly 8px of ink each, set inside running prose where every other
+ link is underlined at rest. A 0.3s centre-out wipe across 8px is a flicker rather
+ than a gesture, and drawing it in --crimson would put an accent bar mid-paragraph
+ that reads as emphasis on the word beside it. They are also a deliberate pair -- same
+ colour, same weight, same rule -- so animating the backref alone would split them.
+ Nothing renders here today either way: there are no footnotes in content/writing/. */
.prose [data-footnote-ref]:hover,
.prose .data-footnote-backref:hover { text-decoration: underline; }
/* The backref is the last thing on a note's last line and reads as punctuation, not as a
@@ -1810,7 +1921,31 @@ const ld = {
word-break: keep-all;
text-wrap: balance;
}
- .adj__item:hover .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+ /* The card is the trigger, as before -- the whole .adj__item is the link, and the hit
+ area is unchanged. The rule is drawn on the inline span inside .adj__title so it stops
+ at the text instead of running the width of the column.
+ No --wipe-y: the padding-box bottom of a 20px line is 3px under the baseline, which is
+ exactly where `text-underline-offset: 0.14em` had it. The rule does not move; it gains
+ the motion and the crimson. :focus-visible is on the <a>, which is the card, so the
+ keyboard sees the same mark the pointer does.
+ Hit area re-measured after the wrapper went in: the card is still 1024 x 61 at 1700
+ and 328 x 82.66 on a wrapped title at 375, and all four corners and the centre still
+ hit-test to the link. */
+ .adj__item:focus-visible .adj__title { color: var(--crimson); }
+ @media (hover: hover) {
+ .adj__item:hover .adj__title { color: var(--crimson); }
+ }
+
+ /* Forced colours drop background-image, so the wipe is not painted there and the colour
+ step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+ this rule replaced is what that mode can still draw, so it comes back here, with the
+ offset it carried before. */
+ @media (forced-colors: active) {
+ .adj__item:focus-visible .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .adj__item:hover .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+ }
.post__back {
grid-column: 2;
margin-block-start: var(--sp-48);
diff --git a/src/pages/writing/index.astro b/src/pages/writing/index.astro
@@ -215,7 +215,6 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
}
.entry:last-child { border-bottom: none; }
- .entry:hover { background-color: var(--ppc-wash); }
.entry__date {
grid-column: 1;
@@ -255,10 +254,59 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
inset: 0;
}
- .entry:hover .entry__title a {
- text-decoration: underline;
- text-underline-offset: 0.14em;
- text-decoration-thickness: from-font;
+ /* The row is still the trigger, and the hit area above is untouched: the rule is drawn
+ by .hover-wipe (base.css) on the link's own background, not by a second pseudo-element,
+ because ::before is already spent on the full-row hit box.
+
+ This is the site that decided the technique. Korean titles wrap here and nowhere on
+ this page is the column wide enough to stop them: 5 entries wrap at 320, 2 at 375, 1
+ at 430, none from 768 up. An absolutely positioned bar -- the header's own method --
+ would draw one rectangle under the last line at the width of the widest, i.e. a bottom
+ border on the title block. `box-decoration-break: clone` gives each line fragment its
+ own rule, each opening from its own centre: the worst case at 375 is 235.44 x 48.33
+ over 2 fragments of 186.45 and 235.44, and it gets two rules of those widths.
+
+ `clone` is documented to disable `text-wrap: balance`, which these titles use, so it
+ was A/B'd rather than assumed: every entry's box, fragment count, fragment widths and
+ the document height are identical with `clone` and with `slice` at 320, 375, 430 and
+ 768. The balancer only diverges in columns narrower than this page ever gets.
+
+ No --wipe-y. The background's 100% is the padding-box bottom, which lands 3px under a
+ 20px baseline -- the same row `text-underline-offset: 0.14em` was putting it on, so
+ the rule does not move at all here, it only acquires the motion.
+
+ What does change is colour: from-font black becomes --crimson, which is what "like the
+ header" means and is the first accent on this index. A 2px rule is a non-text element
+ needing 3:1; crimson measures 7.25:1 on --page-bg. */
+ @media (hover: hover) {
+ /* The row wash used to sit outside this gate while the rule under the title sat inside
+ it. On a touch screen :hover latches after a tap, so a tapped row kept its wash until
+ the next tap elsewhere while the title's rule did not — one half of the row's hover
+ state stuck to it and the other half did not. Both halves are the same gesture and
+ now share the same gate. */
+ .entry:hover { background-color: var(--ppc-wash); }
+ .entry:hover .entry__title a { color: var(--crimson); }
+ }
+ /* Keyboard parity, which this row never had: the outline below marks the row, and this
+ marks the title the same way the pointer does. Outside the gate on purpose.
+ The row hit area is re-measured after the change and unmoved: 1024 x 61, with all
+ four corners and the centre hit-testing to the link through the ::before above. */
+ /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+ place only - the header nav - and every link in the body of a page, its writing index,
+ its code list and even its year rail, simply lifts from rgb(160,32,32) to
+ rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+ .entry__title a:focus-visible { color: var(--crimson); }
+
+ /* Forced colours drop background-image, so the wipe is not painted there at all and the
+ colour step is overridden too -- see the note beside .hover-wipe in base.css. The
+ underline this rule replaced is what that mode can still draw, so it comes back here
+ and only here. Written in this file rather than in base.css because Astro scopes a
+ page rule on every compound, which outranks the same selector written globally. */
+ @media (forced-colors: active) {
+ .entry__title a:focus-visible { text-decoration: underline; text-underline-offset: 0.14em; }
+ }
+ @media (forced-colors: active) and (hover: hover) {
+ .entry:hover .entry__title a { text-decoration: underline; text-underline-offset: 0.14em; }
}
.entry__title a:focus-visible { outline: 0; }
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -61,19 +61,48 @@ p { margin: 0; }
p + p { margin-block-start: var(--sp-para); }
/* Prose links carry no colour: weight and underline only. */
-a { color: inherit; font-weight: 700; text-decoration: underline;
- text-underline-offset: 0.18em; text-decoration-thickness: from-font; }
-
-/* A style choice, not a correctness guard - worth saying plainly, because the shape of
- the rule invites the other reading. It has never been about synthesis: the retired
- display face shipped its whole 200-800 axis and Lora ships 400-700, so `a
- { font-weight: 700 }` inside a heading always had a real instance to select.
- What the line does is keep every title in the Writing index, every adjacent-post link
- and the wordmark at the heading's own 500 instead of stepping to 700 merely for being
- a link. The rule above targets <a> and the heading rule targets <h1>-<h6>, different
- elements, so there is no specificity contest - the a simply applies unless this line
- overrides it. Delete it and linked headings get heavier; that is all that happens. */
-:is(h1, h2, h3, h4, h5, h6) a { font-weight: inherit; }
+/* Links are crimson at the weight of the text around them, and carry no rule at rest —
+ the reference's own treatment, measured on its article pages: rgb(160,32,32), weight
+ 400 against a 400 paragraph, text-decoration none. What stood here was weight 700 plus
+ an underline, which made a link a different piece of type from the sentence holding it.
+
+ One thing to know about this, stated rather than discovered later. Colour alone is a
+ weaker signal than an underline, and WCAG's technique for colour-only links wants 3:1
+ between the link and the text beside it. --crimson on this site's black body measures
+ 2.81:1 — under that line, though the same crimson is 7.25:1 against the page and so
+ never hard to read. The reference is further under it at 2.06:1. What closes the gap
+ There is deliberately no hover or focus rule here to close that gap, because the
+ reference has none either: driven over CDP on its own article page, its body links
+ compute text-decoration none at rest AND on hover, with no background, no border, and
+ zero `a:hover` rules anywhere in its stylesheets. An earlier version of this file added
+ an animated rule on hover on the assumption that it did; that was measured from the
+ rest state alone and was wrong.
+
+ Keyboard users are still served: the global :focus-visible ring near the top of this
+ file rings these links like every other control on the site. The 2.81:1 is the honest
+ cost of the change for a reader who only looks. */
+a { color: var(--crimson); font-weight: inherit; text-decoration: none; }
+
+/* The reference does change on hover, and the earlier note here said it did not. That was
+ wrong twice over: the first probe never hovered at all — the page sets
+ `scroll-behavior: smooth`, so the rect was read before the scroll finished and the
+ pointer landed on nothing, which is also why `document.querySelectorAll(':hover')` came
+ back empty and the reading looked like a null result. Driven properly, the chain ends in
+ the <a> and the colour moves rgb(160,32,32) -> rgb(192,48,48).
+
+ So: colour only, still no rule and no background, and no transition — every other colour
+ change on this site is instant. Gated on a real pointer, because a bare :hover latches
+ after a tap and would leave the last link touched sitting in the hover colour. Keyboard
+ users get the global :focus-visible ring rather than this, which is the stronger signal
+ of the two. */
+@media (hover: hover) {
+ :is(.prose, .about) a:hover,
+ .post__back a:hover { color: var(--crimson-lift); }
+}
+
+/* The rule that used to sit here held linked headings at their own 500 against the
+ global `a { font-weight: 700 }`. That global is `inherit` now, so a link in a heading
+ already takes the heading's weight and the override has nothing left to override. */
:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
@@ -200,6 +229,12 @@ main { padding-block: var(--sp-64) var(--sp-120); }
Padding is --sp-40 rather than --sp-48: one line of --fs-small needs air, not a band. */
.site-foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ column-gap: var(--sp-48);
+ row-gap: var(--sp-30);
padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
padding-block: var(--sp-40);
font-size: var(--fs-small);
@@ -208,6 +243,44 @@ main { padding-block: var(--sp-64) var(--sp-120); }
color: var(--page-meta);
}
+/* Copyright left, profiles right — the header's own arrangement, so the two bars that
+ bracket the page read as a pair rather than as two unrelated strips. The gap is the
+ header nav's, derived from the same token so the two rows of links stay in step. */
+.site-foot p { margin: 0; }
+.site-foot__social { display: flex; flex-wrap: wrap; column-gap: calc(var(--fs-nav) * 0.8); }
+/* Size and case come back from .site-foot, not from the global `nav a` two rules up.
+ That selector is unscoped — it reaches every <nav> on the site — so these links were
+ arriving at --fs-nav (19.2px) in small-caps beside a 14px copyright line. Overridden
+ here rather than by scoping `nav a` to the header, because .adj and .toc-rail both
+ read its `text-decoration: none`, and narrowing it would underline every adjacent-post
+ link at rest. small-caps is dropped with the size: at one shared font-size its
+ lowercase would still draw shorter than the line it is meant to match. */
+.site-foot__social a {
+ color: inherit;
+ font-size: inherit;
+ font-variant-caps: normal;
+ letter-spacing: inherit;
+ font-weight: 400;
+ text-decoration: none;
+}
+/* The rule under these is the header's wipe now, drawn by .hover-wipe further down. This
+ link is the one of the seven that cannot carry that class — it is written in
+ Base.astro — so the shared rule names the selector instead of the markup carrying it.
+ --wipe-y pulls the rule up to where the auto underline used to sit. Measured on the live
+ footer at 1700: the old 1px underline's top row was 801.20 and the unlifted background's
+ was 802.20, so the lift is one pixel, written as 0.07em so it tracks --fs-small's clamp
+ instead of drifting against it down to 11px. (0.21em was the first guess, from the
+ padding box being 4px under the baseline; it put the rule two pixels too high.)
+ The colour step stays, and moves inside the pointer gate together with the rule: this
+ is the header's "the item takes the accent" gesture, and a colour that latches after a
+ tap on a touch screen is exactly the defect the gate exists for. */
+.site-foot__social a { --wipe-y: calc(100% - 0.07em); }
+.site-foot__social a:focus-visible { color: var(--crimson-dark); background-size: 100% 2px; }
+
+@media (hover: hover) {
+ .site-foot__social a:hover { color: var(--crimson-dark); background-size: 100% 2px; }
+}
+
/* Page name and navigation.
One family, one weight, one small-caps texture — and TWO sizes, which is the half that
was wrong here. The note this replaces claimed the reference "sets its title and its nav
@@ -320,6 +393,137 @@ body > header nav a:focus-visible { color: var(--crimson-dark); }
body > header nav a:hover::before { transform: scaleX(1); }
}
+/* --- The same wipe, everywhere else a rule appears on hover --------------------
+ Seven more links grow an underline on hover and used to do it with
+ `text-decoration: underline`, which has no in-between state to animate: the rule is
+ either absent or whole. This gives them the header's gesture -- a 2px crimson rule
+ opening from the centre over 0.3s -- without giving them the header's technique.
+
+ The header draws its rule with an absolutely positioned ::before at `bottom: 0`. That
+ is right for a nav item and wrong for most of these, because an absolute bar is one
+ rectangle spanning the element's whole padding box. Five of the seven wrap in the corpus
+ as it stands -- entry titles on the Writing index (2 lines at 375), adjacent-post titles
+ (2 at 375), rail entries (2 at 1440, 4 once hover lifts the clamp), inline contents
+ headings (3 at 320), project names on /code/ (2 lines from 360 down: one name at 360,
+ two at 320, three at 280) -- and on a wrapped link that rectangle lands under the last
+ line only at the width of the widest, reading as a bottom border rather than an
+ underline. The project names are the narrow escape: at 375 the longest still fits, by
+ 6.45px -- 321.55px in a 328px column -- and it is the 360px column, 313px wide, that
+ takes it to two lines. Two are worse than wrapping: .toc-rail__link and .adj__title
+ are block boxes that fill their column, so the bar would be 265px and 832px wide under
+ 87px and 341px of text even on a single line.
+
+ A background gradient sized 0% -> 100% fails the same way by default, because the
+ background positioning area of an inline box is the union of its fragments. What fixes
+ it is `box-decoration-break: clone`, which hands every line fragment its own copy of
+ the background, each opening from its own centre. Measured on this site's own worst
+ cases: 2 rules of 186 and 235px on a two-line entry title, 3 of 221/231/156 on a
+ three-line contents heading, 4 on a rail entry lifted out of its clamp -- one per line,
+ each the width of its own line. The two blockified sites get one inline wrapper in the
+ markup so they have fragments to clone, and the wrappers cost nothing measurable --
+ clamp, ellipsis, the rail's clamp-lift, the scroll spy's querySelectorAll and every
+ layout figure come out unchanged.
+
+ On a single-line inline link the two techniques are pixel-identical, which is worth
+ knowing before anyone tries to unify them: the absolute bar's containing block and the
+ background positioning area are both the fragment's padding box. Both drawn on the same
+ project name and scanned out of the captured frames, they occupy rows 148.88 and 149.88,
+ x 330.50, width 104 -- the same pixels. So the header keeps its ::before rather than
+ being folded in here: converting it would trade a compositor-driven transform for a
+ repainting background animation to save one declaration, and the header is the reference
+ everything else is being matched to.
+
+ The cost, stated plainly: a rule you draw yourself cannot skip ink. Where
+ `text-decoration` used to break around a descender this runs straight through it --
+ the same look as `text-decoration-skip-ink: none`. On the 21 Korean titles nothing
+ changes; on the Latin ones (journalctl, AI Odyssey, ARP Spoofing) the rule crosses the
+ descender. That is the price of the gesture, and there is no third option: skip-ink
+ belongs to text-decoration, and text-decoration has nothing to animate.
+
+ Each site then puts the rule back where its own `text-underline-offset` had it, because
+ 100% -- the bottom of the padding box -- sits a different distance under the baseline at
+ every type size and family here. Those distances are not flattened to one number on
+ purpose: the header's own rule sits 0.254em under its baseline, deeper than six of these
+ sit today, because a flex item's bar hangs at the line-box bottom rather than the
+ glyph-box bottom. What is being copied is the motion, not the distance.
+
+ There are two knobs and they are not interchangeable, which is a fact about
+ `background-clip` rather than a matter of taste. --wipe-y moves the rule UP: the
+ background is clipped to the border box, so a position past 100% is not drawn at all.
+ Measured, before this was understood: `calc(100% + 0.14em)` on the kicker rendered
+ nothing, and `calc(100% + 0.07em)` on the rail rendered the rule 1px thick instead of
+ 2px, silently clipped. To sit LOWER, a site adds `padding-block-end`, which moves the
+ bottom of the box the background is clipped to.
+
+ On an inline box that is free, and "free" here is an A/B measurement rather than a
+ claim about the spec: with the padding removed from each of the four sites that use it,
+ every containing block, every sibling and document.scrollHeight come back byte-identical
+ to three decimals, and the single number that changes is the link's own border box
+ (project 26 -> 25, kicker 19.97 -> 18.00, contents 19 -> 18). Vertical padding on an
+ inline box does not enter its line box, so nothing around it moves; it grows only the
+ box the background is clipped to, and incidentally the link's own hit target by a pixel
+ or two downward. On a BLOCK box the same padding would move everything below it, which
+ is why the footer link -- the only block-level site of the seven -- is tuned with
+ --wipe-y instead.
+
+ Where the old and the new position disagree by a pixel and there is no exact answer, the
+ rule goes a pixel lower rather than a pixel higher: it no longer skips ink, so the extra
+ pixel is the one that keeps it off a descender.
+
+ No local reduced-motion query. The guard at the foot of this file already selects
+ *, *::before and *::after, the transition degrades to an instant appear, and a second
+ query here would be dead code. */
+/* The footer bar is the only place left that takes this; the body links that used to
+ carry .hover-wipe now step colour instead, the way the reference's body links do. */
+.site-foot__social a {
+ background-image: linear-gradient(var(--crimson), var(--crimson));
+ background-repeat: no-repeat;
+ background-position: 50% var(--wipe-y, 100%);
+ background-size: 0% 2px;
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
+ transition: background-size 0.3s ease;
+}
+
+/* The one mode the background rule cannot reach, handed back.
+
+ Forced colours drop `background-image` outright -- measured in every one of the seven
+ sites with forced-colors emulated, `background-image` computes to `none`, so the rule
+ that marks a hovered link is simply not painted. The colour step goes with it: `color`
+ is forced to LinkText at rest and on hover alike. What these seven had before this
+ change was `text-decoration: underline`, which the UA paints in the forced text colour
+ and survives the mode intact -- the prose links on this site still draw theirs there
+ today. Without a fallback a High Contrast reader gets no hover or focus affordance at
+ all on any of the seven, where they had one before.
+
+ Same trade [slug].astro already makes twice, for the task-list checkbox and the
+ :target footnote: what is a background elsewhere is a real border or a real decoration
+ here.
+
+ Only the footer pair lives in this file. The other six are written beside their own
+ trigger rules in the page that owns them, because an Astro page scopes a rule by adding
+ its attribute to EVERY compound -- `.project__name[cid] a[cid]` -- which ties with a
+ selector of the same shape written here and then wins on stylesheet order. Measured
+ before the move: .project__name and .entry__title computed `text-decoration-line: none`
+ on hover in this mode while the other five computed `underline`. Inside the page the
+ same scope lands on both rules and the pseudo-class settles it. .site-foot__social a
+ cannot move -- its markup is Base.astro's -- and does not need to: its rest rule is in
+ this file, above this block.
+
+ Two queries rather than one nested pair: the pointer half keeps the (hover: hover)
+ gate, because a latched underline after a tap is the same defect in this mode as in
+ any other.
+
+ Not applied to the header nav, which shares the blind spot and predates this work: its
+ bar is a pseudo-element background and disappears here too. Changing the reference is a
+ separate decision from matching it. */
+@media (forced-colors: active) {
+ .site-foot__social a:focus-visible { text-decoration: underline; }
+}
+@media (forced-colors: active) and (hover: hover) {
+ .site-foot__social a:hover { text-decoration: underline; }
+}
+
/* ── Code blocks ──────────────────────────────────────────────────────────────
DOM contract, emitted by the `sqzer:code-block` transformer in astro.config.mjs:
figure.code[data-language][data-wrap?]
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
@@ -14,6 +14,19 @@
All 24 pairs were contrast-checked: dark >= 4.5, base >= 3.0, bright >= 4.5 on black,
wash <= 1.25. */
--crimson-dark: #771120; --crimson: #a51c30; --crimson-bright: #faa2a2; --crimson-wash: #f7ebeb;
+
+ /* Hover step for a link in running text, and the one colour here that is not a rung of
+ the ladder above — it sits between --crimson and --crimson-bright, which is why it is
+ declared apart rather than pretending to be a fifth step.
+
+ Derived from the reference rather than picked: its body links go rgb(160,32,32) at
+ rest to rgb(192,48,48) on hover, measured over CDP on its own article page, which is
+ a luminance increase of x1.572. The same factor applied to --crimson lands here.
+ Contrast: 5.30:1 on --page-bg, so it stays over the 4.5:1 normal text needs while
+ spending the headroom the way the reference spends it. Against the black body text
+ beside it this reads 3.84:1, where --crimson at rest is 2.81:1 — so the hovered link
+ is the one state that clears the 3:1 a colour-only link is asked for. */
+ --crimson-lift: #cb233b;
--blue-dark: #0e455f; --blue: #186285; --blue-bright: #70cafa; --blue-wash: #e6f0f7;
--ochre-dark: #553a0c; --ochre: #775315; --ochre-bright: #f6ae30; --ochre-wash: #f7ede0;
--purple-dark: #5e127b; --purple: #831daa; --purple-bright: #dda3fa; --purple-wash: #f3ebf7;
|