daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

commit 4ac8749e910f9ba4598dafeeb47d6b3ef6a0676e
parent 1db3229ac2d29fe44c96953a52ff09d52608e8de
Author: $: DAΞMON <zer0sec.xp@icloud.com>
Date:   Mon, 10 Aug 2026 01:57:07 +0100

🔧 Update [site-aesthetic] | 10 Aug 2026 01:57:07 BST

📊 Stats: +2520 / -755 across 30 file(s)
📁 Breakdown: ➕6  ✏️21  🗑️3  🔀0
👤 Author: $: DAΞMON

📝 Changes:
  ✏️  Modified  astro.config.mjs
  🗑️  Deleted   src/components/CategoryCard.astro
  ✏️  Modified  src/components/CategoryNav.astro
  ➕ Added     src/components/DomainPlate.astro
  ✏️  Modified  src/components/Footer.astro
  ✏️  Modified  src/components/Header.astro
  ➕ Added     src/components/Hero.astro
  ✏️  Modified  src/components/Icon.astro
  ➕ Added     src/components/Marquee.astro
  ➕ Added     src/components/RecordRow.astro
  ✏️  Modified  src/components/SearchModal.astro
  ➕ Added     src/components/SectionBanner.astro
  🗑️  Deleted   src/components/SheetCard.astro
  ✏️  Modified  src/layouts/Base.astro
  ✏️  Modified  src/pages/404.astro
  ✏️  Modified  src/pages/[category]/index.astro
  ✏️  Modified  src/pages/credits.astro
  ✏️  Modified  src/pages/index.astro
  ✏️  Modified  src/pages/payloads/[...slug].astro
  ✏️  Modified  src/pages/payloads/index.astro
  ✏️  Modified  src/pages/sheets/[...slug].astro
  ✏️  Modified  src/pages/tags/[tag].astro
  ✏️  Modified  src/pages/tags/index.astro
  ✏️  Modified  src/scripts/app.ts
  ✏️  Modified  src/styles/app.css
  ➕ Added     src/styles/chrome.css
  🗑️  Deleted   src/styles/glass.css
  ✏️  Modified  src/styles/global.css
  ✏️  Modified  src/styles/prose.css
  ✏️  Modified  src/styles/tokens.css

📈 Line changes per file:
  • astro.config.mjs                                   +69    -5
  • src/components/CategoryCard.astro                  +0     -21
  • src/components/CategoryNav.astro                   +4     -17
  • src/components/DomainPlate.astro                   +32    -0
  • src/components/Footer.astro                        +11    -5
  • src/components/Header.astro                        +48    -6
  • src/components/Hero.astro                          +243   -0
  • src/components/Icon.astro                          +8     -1
  • src/components/Marquee.astro                       +134   -0
  • src/components/RecordRow.astro                     +42    -0
  • src/components/SearchModal.astro                   +41    -16
  • src/components/SectionBanner.astro                 +272   -0
  • src/components/SheetCard.astro                     +0     -25
  • src/layouts/Base.astro                             +4     -2
  • src/pages/404.astro                                +27    -20
  • src/pages/[category]/index.astro                   +47    -27
  • src/pages/credits.astro                            +6     -7
  • src/pages/index.astro                              +67    -92
  • src/pages/payloads/[...slug].astro                 +2     -2
  • src/pages/payloads/index.astro                     +42    -32
  • src/pages/sheets/[...slug].astro                   +14    -10
  • src/pages/tags/[tag].astro                         +35    -9
  • src/pages/tags/index.astro                         +48    -13
  • src/scripts/app.ts                                 +34    -14
  • src/styles/app.css                                 +1     -1
  • src/styles/chrome.css                              +254   -0
  • src/styles/glass.css                               +0     -121
  • src/styles/global.css                              +776   -204
  • src/styles/prose.css                               +107   -76
  • src/styles/tokens.css                              +152   -29

Diffstat:
Mastro.config.mjs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Dsrc/components/CategoryCard.astro | 21---------------------
Msrc/components/CategoryNav.astro | 21++++-----------------
Asrc/components/DomainPlate.astro | 32++++++++++++++++++++++++++++++++
Msrc/components/Footer.astro | 16+++++++++++-----
Msrc/components/Header.astro | 54++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/components/Hero.astro | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/Icon.astro | 9++++++++-
Asrc/components/Marquee.astro | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/RecordRow.astro | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/components/SearchModal.astro | 57+++++++++++++++++++++++++++++++++++++++++----------------
Asrc/components/SectionBanner.astro | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/SheetCard.astro | 25-------------------------
Msrc/layouts/Base.astro | 6++++--
Msrc/pages/404.astro | 47+++++++++++++++++++++++++++--------------------
Msrc/pages/[category]/index.astro | 74+++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/pages/credits.astro | 13++++++-------
Msrc/pages/index.astro | 159+++++++++++++++++++++++++++++++++----------------------------------------------
Msrc/pages/payloads/[...slug].astro | 4++--
Msrc/pages/payloads/index.astro | 74++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/pages/sheets/[...slug].astro | 24++++++++++++++----------
Msrc/pages/tags/[tag].astro | 44+++++++++++++++++++++++++++++++++++---------
Msrc/pages/tags/index.astro | 61++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/scripts/app.ts | 48++++++++++++++++++++++++++++++++++--------------
Msrc/styles/app.css | 2+-
Asrc/styles/chrome.css | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/styles/glass.css | 121-------------------------------------------------------------------------------
Msrc/styles/global.css | 980++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/styles/prose.css | 183++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/styles/tokens.css | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
30 files changed, 2520 insertions(+), 755 deletions(-)

diff --git a/astro.config.mjs b/astro.config.mjs @@ -30,6 +30,70 @@ function rehypeBaseLinks() { return (tree) => walk(tree); } +/** + * The drop cap, as the main site sets it: the opening letter of a sheet + * floated three lines deep in EB Garamond. + * + * The letter is split out of the paragraph's first text node and wrapped + * in a `<span class="cap">` rather than left to `::first-letter`. That + * keeps it real text — selectable, searchable, and read aloud as part of + * the word it opens — and it is the only way to give it a different family + * and weight that renders the same across engines. + * + * Only the first top-level paragraph of the document takes one, and only + * if it is long enough to have three lines for the letter to sit in. + * Short openers — an image caption, a one-line note, a lede that is really + * a subtitle — are left alone, because a cap floated into a two-line + * paragraph pushes the text into a column beside it. + */ +const CAP_MIN_CHARS = 80; + +function rehypeDropCap() { + return (tree) => { + const paragraph = tree.children.find( + (n) => n.type === 'element' && n.tagName === 'p' && textLength(n) >= CAP_MIN_CHARS, + ); + if (!paragraph) return; + + // The first non-empty text node, wherever it sits — the paragraph may + // legitimately open with a <strong> or an <a>, and the cap belongs to + // the first *letter* rather than to the first direct child. + const lead = firstText(paragraph); + if (!lead) return; + const trimmed = lead.value.trimStart(); + const letter = trimmed[0]; + // Punctuation and quotes make poor caps: floated at 72px an opening + // quote reads as a stray mark rather than as a letter. + if (!letter || !/[A-Za-z0-9]/.test(letter)) return; + + lead.value = trimmed.slice(1); + paragraph.children.unshift({ + type: 'element', + tagName: 'span', + properties: { className: ['cap'] }, + children: [{ type: 'text', value: letter }], + }); + }; + + function textLength(node) { + if (node.type === 'text') return node.value.trim().length; + if (!node.children) return 0; + return node.children.reduce((n, c) => n + textLength(c), 0); + } + + function firstText(node) { + if (!node.children) return null; + for (const child of node.children) { + if (child.type === 'text' && child.value.trim()) return child; + if (child.type === 'element') { + const found = firstText(child); + if (found) return found; + } + } + return null; + } +} + // Project page: https://daemon-404.github.io/daemon-sec-cheatsheet export default defineConfig({ site: 'https://daemon-404.github.io', @@ -37,12 +101,12 @@ export default defineConfig({ trailingSlash: 'ignore', integrations: [mdx(), sitemap()], markdown: { - rehypePlugins: [rehypeBaseLinks], + rehypePlugins: [rehypeBaseLinks, rehypeDropCap], shikiConfig: { - // Dual theme -> CSS variables we flip via [data-theme]. defaultColor:false - // means Shiki writes both palettes as --shiki-light / --shiki-dark. - themes: { light: 'rose-pine-dawn', dark: 'rose-pine-moon' }, - defaultColor: false, + // One theme, not two. Code blocks are dark plates in both modes (see + // prose.css) — a listing that turns cream in light mode stops reading + // as terminal output — so there is no second palette to flip to. + theme: 'rose-pine-moon', wrap: false, }, }, diff --git a/src/components/CategoryCard.astro b/src/components/CategoryCard.astro @@ -1,21 +0,0 @@ ---- -import type { CategoryDef } from '../lib/taxonomy'; -import { url } from '../lib/url'; -import Icon from './Icon.astro'; -interface Props { cat: CategoryDef; count: number } -const { cat, count } = Astro.props; ---- -<a class="card pane glow" href={url(cat.slug)} style={`--acc: var(--${cat.accent});`}> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">[{cat.tag}]</span> - </div> - <div class="pane__body"> - <h3 class="card-title" style={`color: var(--${cat.accent});`}>{cat.title}</h3> - <p class="card-desc">{cat.blurb}</p> - <div class="card-meta"> - <span class="card-count">{count} {count === 1 ? 'sheet' : 'sheets'}</span> - <span class="card-arrow"><Icon name="arrow" /></span> - </div> - </div> -</a> diff --git a/src/components/CategoryNav.astro b/src/components/CategoryNav.astro @@ -4,6 +4,10 @@ import { url } from '../lib/url'; interface Props { active?: string } const { active } = Astro.props; --- +<!-- Twelve chips over a 1px ink ground, so the row reads as one ruled + strip rather than as twelve floating pills — the same construction as + the domain grid on the home page. Styling lives in global.css + (`.cat-nav` / `.cat-chip`) because the sheet pages use it too. --> <nav class="cat-nav" aria-label="Categories"> {CATEGORIES.map((c) => ( <a @@ -16,20 +20,3 @@ const { active } = Astro.props; </a> ))} </nav> - -<style> - .cat-nav { display: flex; flex-wrap: wrap; gap: 0.5rem; } - .cat-chip { - display: inline-flex; align-items: center; gap: 0.45rem; - padding: 0.4rem 0.75rem; border-radius: 999px; - border: 1px solid var(--glass-brd); background: var(--glass-bg); - font-size: 0.85rem; color: var(--fg-dim); - transition: color 0.18s, border-color 0.18s, transform 0.18s; - } - .cat-chip:hover { color: var(--fg); transform: translateY(-1px); border-color: color-mix(in oklab, var(--acc) 45%, var(--glass-brd)); } - .cat-chip__tag { font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.08em; color: var(--acc); } - .cat-chip[aria-current='page'] { - color: var(--fg); border-color: color-mix(in oklab, var(--acc) 60%, transparent); - box-shadow: 0 0 0 1px color-mix(in oklab, var(--acc) 25%, transparent), 0 4px 18px color-mix(in oklab, var(--acc) 15%, transparent); - } -</style> diff --git a/src/components/DomainPlate.astro b/src/components/DomainPlate.astro @@ -0,0 +1,32 @@ +--- +import type { CategoryDef } from '../lib/taxonomy'; +import { url } from '../lib/url'; +import Icon from './Icon.astro'; + +interface Props { cat: CategoryDef; count: number; n: string } +const { cat, count, n } = Astro.props; + +/** + * One of the twelve domains, as a tile. + * + * The hue lives on the top edge and nowhere else. Twelve fully-coloured + * cards would be a paint chart, and the colour is here to key the domain + * against the masthead and the marquee, not to fill the grid. + * + * The tiles carry no gutters — the grid draws 1px rules between them + * (`.grid-plates`) so the twelve read as one ruled table rather than as + * twelve floating objects. + */ +--- +<a class="plate-tile" href={url(cat.slug)} style={`--acc: var(--${cat.accent});`}> + <span class="plate-tile__head"> + <span class="plate-tile__tag">{cat.tag}</span> + <span class="plate-tile__n">{n}</span> + </span> + <h3 class="plate-tile__title">{cat.title}</h3> + <p class="plate-tile__blurb">{cat.blurb}</p> + <span class="plate-tile__meta"> + <span>{count} {count === 1 ? 'sheet' : 'sheets'}</span> + <span class="plate-tile__arrow"><Icon name="arrow" /></span> + </span> +</a> diff --git a/src/components/Footer.astro b/src/components/Footer.astro @@ -1,25 +1,31 @@ --- import { url } from '../lib/url'; const GITHUB = 'https://github.com/DAEMON-404/daemon-sec-cheatsheet'; +const SITE = 'https://daemon-404.github.io'; const year = 2026; --- +<!-- A dark band closing the cream page: the same object as the marquee and + the section banners, in its last position. `.plate` pins the night + palette for the whole subtree, so this stays dark in both modes. --> <footer class="site-footer plate"> + <span class="grain grain--local" aria-hidden="true"></span> <div class="wrap"> - <div class="stack" style="--gap:0.5rem;"> - <a class="brand" href={url('')}><b>DÆMON</b><span class="slash">//</span>SEC</a> + <div> + <a class="brand" href={url('')}>DÆMON<span class="slash">//</span>SEC</a> <p class="footer-note"> A curated vault of IT &amp; security cheatsheets. For authorized testing, CTFs, and education only. Know your scope; get permission first. </p> </div> - <div class="stack" style="--gap:0.4rem; text-align:right;"> - <a class="footer-mono" href={GITHUB} target="_blank" rel="noopener">github.com/DAEMON-404/daemon-sec-cheatsheet</a> + <div class="footer-col"> + <a class="footer-mono" href={SITE} rel="noopener">daemon-404.github.io ↗</a> + <a class="footer-mono" href={GITHUB} target="_blank" rel="noopener">github / daemon-sec-cheatsheet ↗</a> <span class="footer-mono muted"> Payloads mirrored from <a href="https://github.com/swisskyrepo/PayloadsAllTheThings" target="_blank" rel="noopener">PayloadsAllTheThings</a> (MIT) · <a href={url('credits')}>credits</a> </span> <span class="footer-mono muted">© {year} DÆMON//SEC · MIT</span> - <span class="footer-mono muted">Rose Pine · glass · built with Astro</span> + <span class="footer-mono muted">Rosé Pine · Astro · Pagefind</span> </div> </div> </footer> diff --git a/src/components/Header.astro b/src/components/Header.astro @@ -1,19 +1,57 @@ --- import Icon from './Icon.astro'; import { url } from '../lib/url'; + const GITHUB = 'https://github.com/DAEMON-404/daemon-sec-cheatsheet'; +const SITE = 'https://daemon-404.github.io'; + +/** + * The masthead, in the main site's grammar. + * + * Each entry owns a hue and its index wears it, so the bar reads as the + * colour legend the rest of the site keys back to rather than as five grey + * words. The index keeps its space at rest rather than appearing on hover + * — revealing it would reflow the whole bar every time the pointer crossed + * an item. + * + * The accents are written out as full custom-property names rather than + * built up from the slug, because the value is read straight into `--acc` + * and a half-built token silently resolves to nothing. + */ +const NAV = [ + { n: '01', label: 'Vault', href: url(''), acc: 'love', exact: true }, + { n: '02', label: 'Payloads', href: url('payloads'), acc: 'iris' }, + { n: '03', label: 'Tags', href: url('tags'), acc: 'foam' }, + { n: '04', label: 'Credits', href: url('credits'), acc: 'gold' }, +]; + +/* Trailing slashes are `ignore`d by the build, so both spellings of a path + reach here and an exact match would leave the bar saying nowhere. */ +const here = Astro.url.pathname.replace(/\/+$/, '') || '/'; +const isActive = (href: string, exact?: boolean) => { + const target = href.replace(/\/+$/, '') || '/'; + return exact ? here === target : here === target || here.startsWith(target + '/'); +}; --- <header class="site-header"> <div class="wrap"> <a class="brand" href={url('')} aria-label="DÆMON//SEC home"> - <b>DÆMON</b><span class="slash">//</span>SEC<span class="blink">_</span> + DÆMON<span class="slash">//</span>SEC<span class="blink">_</span> </a> - <nav class="header-nav" aria-label="Primary"> - <a href={url('')}>Vault</a> - <a href={url('payloads')}>Payloads</a> + + <nav class="mast-nav" aria-label="Primary"> + {NAV.map((item) => ( + <a + href={item.href} + style={`--acc: var(--${item.acc});`} + aria-current={isActive(item.href, item.exact) ? 'page' : undefined} + > + <span class="n">{item.n}</span><span class="lbl">{item.label}</span> + </a> + ))} </nav> - <div class="header-spacer"></div> - <div class="header-actions"> + + <div class="mast-actions"> <button class="search-trigger" data-search-open type="button" aria-label="Search cheatsheets"> <Icon name="search" /> <span class="st-label">Search</span> @@ -23,6 +61,9 @@ const GITHUB = 'https://github.com/DAEMON-404/daemon-sec-cheatsheet'; <span class="icon-sun"><Icon name="sun" /></span> <span class="icon-moon"><Icon name="moon" /></span> </button> + <a class="icon-btn" href={SITE} rel="noopener" aria-label="DÆMON main site"> + <Icon name="external" /> + </a> <a class="icon-btn" href={GITHUB} target="_blank" rel="noopener" aria-label="GitHub repository"> <Icon name="github" /> </a> @@ -36,4 +77,5 @@ const GITHUB = 'https://github.com/DAEMON-404/daemon-sec-cheatsheet'; .icon-moon { display: none; } :global(:root[data-theme='dark']) .icon-sun { display: none; } :global(:root[data-theme='dark']) .icon-moon { display: inline-flex; } + .search-trigger svg { width: 15px; height: 15px; } </style> diff --git a/src/components/Hero.astro b/src/components/Hero.astro @@ -0,0 +1,243 @@ +--- +import Icon from './Icon.astro'; +import { url } from '../lib/url'; + +interface Props { + sheets: number; + domains: number; + payloads: number; +} +const { sheets, domains, payloads } = Astro.props; + +/** + * The hero, carried over from the main site's retired paper hero. + * + * Two display lines over a bordered ledger card. Both lines sit at their + * font's narrowest usable width with a vertical scale on top — the comp's + * poster face is not licensed here — and `scaleY` overflows its own line + * box, so each line lives in a fixed-height `cqw` wrapper that clips it + * (`.clip` / `.stretch` in chrome.css). + * + * Sized in `cqw` against the text column rather than in `vw` against the + * window. In `vw` the lockup is sized against a viewport the column stops + * tracking at `--maxw`, and past that width the type drifts out of line + * with everything under it — which is a thing you only see at 1900px. + * + * ── The spelling ── + * The main site sets the wordmark in substitution glyphs (DΛΣMӨП, three + * scripts). The vault self-hosts EB Garamond's *latin* subset only, so Λ Σ + * Ө П would fall back per-character to a different face at a different + * width and the lockup would render as six mismatched letters. The Latin + * spelling is the site's own documented fallback for exactly this case, + * and it is what the six-glyph hover key is built against. + */ +const WORDMARK = 'DÆMON'; +const GLYPHS = [...WORDMARK]; + +/** Per-glyph entrance step, ms. Six glyphs at 54ms is what the ink rule's + * 780ms delay is timed against — change one and the rule lands wrong. */ +const STEP = 54; + +const STATS = [ + { value: sheets, label: 'Curated<br />cheatsheets', tone: 'iris' }, + { value: domains, label: 'Operator<br />domains', tone: 'love' }, + { value: payloads, label: 'Mirrored<br />payload pages', tone: 'gold' }, +]; +--- +<section class="hero" id="top"> + <div class="wrap hero__inner"> + <div class="hero__lockup" data-lockup> + <div class="clip hero__line"> + <h1 class="stretch hero__word" style="transform: scaleY(1.28);"> + {GLYPHS.map((g, i) => ( + <span class="hero__glyph" style={`animation-delay: ${i * STEP}ms;`}>{g}</span> + ))} + </h1> + </div> + + <span class="hero__rule" aria-hidden="true"></span> + + <div class="clip hero__subline"> + <p class="stretch hero__sub"><span>The cheatsheet vault</span></p> + </div> + </div> + + <div class="hero__ledger"> + <div class="hero__card"> + <div> + <div class="hero__eyebrow">Vault / 2026</div> + <h2 class="hero__h2">Zero fluff. Copy-ready.</h2> + <p class="hero__blurb"> + {sheets} hand-picked cheatsheets across {domains} domains — Active Directory, + enumeration, exploitation, priv-esc, web, DFIR — stripped of vault-specific + syntax and refreshed for current tool flags. Plus the full + PayloadsAllTheThings mirror, searchable offline. + </p> + <div class="hero__actions"> + <button class="btn" data-search-open type="button"> + <Icon name="search" /> Search the vault + </button> + <a class="btn btn--ghost" href={url('payloads')}> + <Icon name="layers" /> Payloads + </a> + </div> + </div> + + <div> + {/* The identity readout, carried over from the main site's hero. + Borderless on purpose: the card around it is the printed + spec, this is the terminal output inside it, so the two read + as different registers rather than as a box in a box. Every + value is a fact the vault can actually assert. */} + <div class="whoami"> + <div class="whoami__cmd"> + <span class="u">daemon@sec</span><span>~</span><span>%</span> + <span class="c">whoami --vault</span> + <span class="whoami__caret" aria-hidden="true"></span> + </div> + <dl class="whoami__rows"> + <dt>scope</dt><dd>Offensive security · red team · DFIR</dd> + <dt>format</dt><dd>Copy-ready commands, current tool flags</dd> + <dt>search</dt><dd>Pagefind · offline · zero tracking</dd> + <dt>license</dt><dd>MIT · payloads mirrored from swisskyrepo</dd> + </dl> + </div> + + <dl class="hero__stats" style="margin-top:1.6rem;"> + {STATS.map((s) => ( + <div class={`hero__stat hero__stat--${s.tone}`}> + <dt class="sr-only" set:html={s.label.replace('<br />', ' ')} /> + <dd> + {/* Padded to two digits and counted up from zero. The + initial text is the final value, so with JS off or + reduced motion on, the number is simply correct. */} + <b data-count={s.value}>{String(s.value).padStart(2, '0')}</b> + <span set:html={` ${s.label}`} /> + </dd> + </div> + ))} + </dl> + </div> + </div> + </div> + </div> +</section> + +<script> + /** + * Proximity ripple across the lockup. + * + * The letter under the pointer lifts most and its neighbours fall off + * from it, so the wordmark answers the cursor as one object rather than + * as six independent hover targets. + * + * Written straight to the nodes from a rAF. `scale` and `translate` are + * separate CSS properties from `transform`, which the entrance animation + * owns and fills `both` — so the three compose instead of overwriting + * each other. Using `transform` here would cancel the entrance. + */ + const LIFT = 0.075; // peak scale added to the letter under the pointer + const RISE = 0.022; // peak rise, in em, so it tracks the type size + const REACH = 2.1; // how far the falloff carries, in glyph widths + + function initLockup(): void { + const host = document.querySelector<HTMLElement>('[data-lockup]'); + if (!host || host.dataset.bound) return; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + host.dataset.bound = '1'; + + const glyphs = () => Array.from(host.querySelectorAll<HTMLElement>('.hero__glyph')); + let raf = 0; + let point: { x: number } | null = null; + + const paint = () => { + raf = 0; + for (const g of glyphs()) { + if (!point) { + g.style.scale = ''; + g.style.translate = ''; + continue; + } + const r = g.getBoundingClientRect(); + // Falloff measured in glyph widths, so it scales with the type + // rather than with a pixel constant that only suits one viewport. + const d = Math.abs(point.x - (r.left + r.width / 2)) / r.width; + const fade = Math.max(0, 1 - (d / REACH) ** 2); + g.style.scale = `${1 + LIFT * fade}`; + // Four places, not two: at 0.01em steps the rise quantises to + // roughly two pixels at this type size, and the falloff stepped + // between neighbours instead of easing across them. + g.style.translate = `0 ${(-RISE * fade).toFixed(4)}em`; + } + }; + + const queue = () => { if (!raf) raf = requestAnimationFrame(paint); }; + + const onMove = (e: PointerEvent) => { + // Coarse pointers have no hover to track; a tap would otherwise + // leave one letter stuck mid-lift. + if (e.pointerType !== 'mouse') return; + const r = host.getBoundingClientRect(); + // A band around the lockup rather than the box itself: the letters + // should start answering as the pointer approaches, not the instant + // it crosses an invisible edge. + const near = e.clientY > r.top - r.height * 0.6 && e.clientY < r.bottom + r.height * 0.6; + point = near ? { x: e.clientX } : null; + queue(); + }; + const onLeave = () => { point = null; queue(); }; + + addEventListener('pointermove', onMove, { passive: true }); + addEventListener('pointerleave', onLeave, { passive: true }); + addEventListener('blur', onLeave); + } + + /** + * The stats climb as their row lands rather than on page load, so the + * numbers are moving while they are being looked at rather than + * finishing behind the hero's entrance. + */ + function initCounters(): void { + const nodes = document.querySelectorAll<HTMLElement>('[data-count]:not([data-counted])'); + if (!nodes.length) return; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const run = (el: HTMLElement) => { + el.dataset.counted = '1'; + const target = Number(el.dataset.count ?? '0'); + const pad = (n: number) => String(n).padStart(2, '0'); + const t0 = performance.now(); + const DURATION = 1100; + const tick = (now: number) => { + const p = Math.min(1, (now - t0) / DURATION); + // Ease-out cubic: the figure should decelerate onto its value + // rather than stop dead on it. + const eased = 1 - (1 - p) ** 3; + el.textContent = pad(Math.round(target * eased)); + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }; + + const obs = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + run(e.target as HTMLElement); + obs.unobserve(e.target); + } + }, + { threshold: 0.6 }, + ); + nodes.forEach((n) => obs.observe(n)); + } + + function init(): void { + initLockup(); + initCounters(); + } + + document.addEventListener('astro:page-load', init); + if (document.readyState !== 'loading') init(); + else document.addEventListener('DOMContentLoaded', init); +</script> diff --git a/src/components/Icon.astro b/src/components/Icon.astro @@ -8,10 +8,17 @@ const paths: Record<string, string> = { github: '<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.9a3.4 3.4 0 0 0-.9-2.6c3-.3 6.2-1.5 6.2-6.7A5.2 5.2 0 0 0 20 4.8 4.9 4.9 0 0 0 19.9 1S18.7.6 16 2.5a13.4 13.4 0 0 0-7 0C6.3.6 5.1 1 5.1 1A4.9 4.9 0 0 0 5 4.8a5.2 5.2 0 0 0-1.4 3.7c0 5.2 3.2 6.4 6.2 6.7a3.4 3.4 0 0 0-.9 2.5V22"/>', arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>', chevron: '<path d="m9 6 6 6-6 6"/>', - terminal: '<path d="m7 9 3 3-3 3M13 15h4"/><rect x="3" y="4" width="18" height="16" rx="2"/>', + terminal: '<path d="m7 9 3 3-3 3M13 15h4"/><rect x="3" y="4" width="18" height="16"/>', hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>', wrench: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.2L3 18l3 3 6.5-6.3a4 4 0 0 0 5.2-5.4l-2.6 2.6-2.1-.5-.5-2.1z"/>', close: '<path d="M18 6 6 18M6 6l12 12"/>', + copy: '<rect x="9" y="9" width="12" height="12"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>', + check: '<path d="M20 6 9 17l-5-5"/>', + external: '<path d="M14 4h6v6M20 4 10 14M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>', + /* The kind mark carried by every record row and marquee entry. A sheet + is a document; a payload is a fragment of one. */ + doc: '<path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>', + layers: '<path d="m12 3 9 5-9 5-9-5 9-5z"/><path d="m3 13 9 5 9-5"/>', }; const d = paths[name] ?? ''; --- diff --git a/src/components/Marquee.astro b/src/components/Marquee.astro @@ -0,0 +1,134 @@ +--- +import Icon from './Icon.astro'; +import type { CategoryDef } from '../lib/taxonomy'; + +interface Props { + items: { cat: CategoryDef; count: number }[]; +} +const { items } = Astro.props; + +/** + * The domain band — the archive's index grammar, run sideways. + * + * ── Grammar ── + * The band could have opened each entry with the section eyebrow, but that + * makes it read as twelve small banners sliding past. The site's core + * object is the record row: number, kind mark, title, meta, in that order, + * 10px mono with an 11px kind icon. Each entry carries exactly that line + * horizontally — the index, the kind, the domain in display type, the + * sheet count trailing in the meta slot — so the band reads as the live + * index in motion rather than as decoration. Like the row, the trailing + * count drops on narrow viewports, and like the index, every entry sets at + * the same weight: a row is a row. + * + * The `^:` mark survives in exactly one place — the band's own tab. It is + * the site's self-naming mark, and the band names itself once; repeated on + * every entry it was furniture. The tab counts its entries too, because + * every index on this site counts itself, and a count derived from the + * data cannot go stale. + * + * ── Colour ── + * The ground is `--band`: iris walked down to a weight that carries text + * (see tokens.css). The accent carries the domain title and its kind icon + * while every small word stays cream — record-row grammar, not a contrast + * ruling. + * + * Two identical runs, translated as one unit, so the reset is invisible. + */ + +/* Rendered twice. The second run is `aria-hidden` — the band is one index, + not two, and a screen reader should not read twelve domains and then + read them again. */ +const RUN = items; +--- +<!-- `.plate` pins the night palette for the whole band. Without it `--acc` + resolves to the *dawn* accents, which are reading weights drawn for + cream — on this ground pine and rose land near 1:1 and the kind marks + disappear. The band is dark in both modes, so it takes the dark + palette in both modes. --> +<section class="marquee plate bleed" aria-label="Domains" data-marquee> + <span class="marquee__tab marquee__tab--l"> + <span class="dot" aria-hidden="true"><i></i><u></u></span> + ^: Index + </span> + + <div class="marquee__track" data-marquee-track> + {[0, 1].map((run) => ( + <div class="marquee__run" aria-hidden={run === 1 ? 'true' : undefined}> + {RUN.map((item, i) => ( + <span class="marquee__item" style={`--acc: var(--${item.cat.accent});`}> + <span class="marquee__n">{String(i + 1).padStart(2, '0')}</span> + <span class="marquee__kind"><Icon name="doc" /> {item.cat.tag}</span> + <span class="marquee__title">{item.cat.title}</span> + <span class="marquee__qual">{item.count} {item.count === 1 ? 'sheet' : 'sheets'}</span> + </span> + ))} + </div> + ))} + </div> + + <span class="marquee__tab marquee__tab--r">{String(items.length).padStart(2, '0')} domains</span> +</section> + +<script> + /** + * Driven from rAF rather than from a CSS keyframe, which is the whole + * reason this is script at all: a CSS animation cannot ease toward a + * fraction of its speed under the pointer, and a hard `paused` reads as + * a broken animation rather than as a deliberate slow-down. + */ + const SPEED = 70; // px/s + const HOVER = 0.22; // multiplier under the pointer — never zero + const EASE = 0.08; // how fast the current speed chases the target + + function initMarquee(): void { + const host = document.querySelector<HTMLElement>('[data-marquee]'); + const track = document.querySelector<HTMLElement>('[data-marquee-track]'); + if (!host || !track || host.dataset.bound) return; + host.dataset.bound = '1'; + + // Static under reduced motion: the first run is already the complete + // index, so it stays readable with nothing moving. + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const run = track.firstElementChild as HTMLElement | null; + if (!run) return; + + let offset = 0; + let speed = SPEED; + let target = SPEED; + let last = 0; + let raf = 0; + + host.addEventListener('pointerenter', () => { target = SPEED * HOVER; }); + host.addEventListener('pointerleave', () => { target = SPEED; }); + + const frame = (now: number) => { + const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; + last = now; + speed += (target - speed) * EASE; + offset += speed * dt; + + // One run's width is the wrap point: the second run lands exactly + // where the first started, so resetting there is invisible. Measured + // every frame rather than cached — the band reflows on resize and on + // font load, and a stale width shows up as a visible jump. + const width = run.offsetWidth; + if (width > 0 && offset >= width) offset -= width; + + track.style.transform = `translate3d(${-offset.toFixed(2)}px, 0, 0)`; + raf = requestAnimationFrame(frame); + }; + + raf = requestAnimationFrame(frame); + + // Astro's view transitions swap the document without unloading it, so + // an un-cancelled rAF would keep running against a detached node and + // stack a second loop on the next page that has a band. + document.addEventListener('astro:before-swap', () => cancelAnimationFrame(raf), { once: true }); + } + + document.addEventListener('astro:page-load', initMarquee); + if (document.readyState !== 'loading') initMarquee(); + else document.addEventListener('DOMContentLoaded', initMarquee); +</script> diff --git a/src/components/RecordRow.astro b/src/components/RecordRow.astro @@ -0,0 +1,42 @@ +--- +import Icon from './Icon.astro'; + +interface Props { + href: string; + /** Zero-padded index, supplied by the list so a row never has to know + * where it sits. */ + n: string; + title: string; + /** The kind mark — the domain tag for a sheet, the topic for a payload. */ + kind: string; + kindIcon?: string; + accent?: string; + /** The trailing slot: difficulty, date, page count. Dropped below md, + * where the row has no room for a fifth column. */ + meta?: string; +} +const { href, n, title, kind, kindIcon = 'doc', accent = 'foam', meta } = Astro.props; + +/** + * One record in an index — the site's core object. + * + * One line, not a card. The title is deliberately NOT set as a display + * heading: at heading weight every entry carries the weight of a section + * and a list of fifty becomes unscannable. The point of an index is the + * shape of the whole list, and that only reads if a row is a row. + * + * Hovering wipes ink across the row (`.wipe` in chrome.css). On that ink + * the dawn accents sit at roughly 1:1, so every coloured cell takes a + * cream weight instead of keeping a hue nobody could read — see the + * `.record:hover` rules in global.css. + */ +--- +<a class="record wipe dmn-t" href={href} style={`--acc: var(--${accent});`}> + <span class="record__grid"> + <span class="record__n">{n}</span> + <span class="record__kind"><Icon name={kindIcon} /> {kind}</span> + <span class="record__title">{title}</span> + {meta ? <span class="record__meta">{meta}</span> : <span class="record__meta" />} + <span class="record__arrow"><Icon name="chevron" /></span> + </span> +</a> diff --git a/src/components/SearchModal.astro b/src/components/SearchModal.astro @@ -2,7 +2,7 @@ import Icon from './Icon.astro'; --- <div class="search-overlay" data-search-overlay hidden> - <div class="search-box pane glow" role="dialog" aria-modal="true" aria-label="Search cheatsheets"> + <div class="search-box" role="dialog" aria-modal="true" aria-label="Search cheatsheets"> <div class="search-inputrow"> <Icon name="search" class="search-ico" /> <input @@ -20,31 +20,56 @@ import Icon from './Icon.astro'; </div> <style> + /* An opaque scrim rather than a frosted one. The page under it is as + often a dark band as it is cream, and a translucent overlay reads as + two different dialogs depending on where you happened to be. */ .search-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: clamp(3rem, 12vh, 8rem) 1rem 2rem; - background: color-mix(in oklab, var(--base) 55%, transparent); - -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + background: color-mix(in oklab, var(--base) 88%, transparent); } .search-overlay[hidden] { display: none; } - .search-box { width: min(640px, 100%); --acc: var(--iris); } - .search-inputrow { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--glass-brd); } - .search-ico { width: 20px; height: 20px; color: var(--fg-faint); flex: 0 0 auto; } + .search-box { + width: min(660px, 100%); + border: 1px solid var(--fg); + background: var(--base); + } + .search-inputrow { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--rule); } + .search-ico { width: 18px; height: 18px; color: var(--fg-faint); flex: 0 0 auto; } .search-input { flex: 1; background: none; border: none; outline: none; - color: var(--fg); font-size: 1.05rem; font-family: var(--font-body); + color: var(--fg); font-size: 1.02rem; font-family: var(--font-mono); } .search-input::placeholder { color: var(--fg-faint); } - .search-results { max-height: min(56vh, 520px); overflow-y: auto; padding: 0.5rem; } - .search-hint { color: var(--fg-faint); font-size: 0.82rem; padding: 0.8rem; } - .search-hint kbd { border: 1px solid var(--glass-brd); border-radius: 5px; padding: 0.05rem 0.3rem; } - .s-result { display: block; padding: 0.7rem 0.8rem; border-radius: 10px; border: 1px solid transparent; } - .s-result:hover, .s-result:focus-visible { background: var(--glass-bg); border-color: var(--glass-brd); } - .s-cat { font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); } - .s-title { font-family: var(--font-display); font-weight: 600; font-size: 1.02rem; margin-top: 0.15rem; } - .s-excerpt { color: var(--fg-dim); font-size: 0.85rem; margin-top: 0.2rem; line-height: 1.45; } - .s-excerpt :global(mark) { background: color-mix(in oklab, var(--gold) 30%, transparent); color: var(--fg); border-radius: 3px; padding: 0 2px; } + .search-results { max-height: min(56vh, 520px); overflow-y: auto; } + .search-hint { + color: var(--fg-faint); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + padding: 1rem 0.9rem; + } + .search-hint kbd { border: 1px solid var(--rule); padding: 0.06rem 0.3rem; } + /* Results are index rows, like every other list on the site — the ink + wipe is the same object, at the same timing. */ + .s-result { + display: block; padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--rule); + background-image: linear-gradient(90deg, var(--fg) 0 50%, transparent 50% 100%); + background-size: 200% 100%; + background-position: 100% 0; + background-repeat: no-repeat; + transition: background-position 380ms var(--ease-in), color 1ms; + } + .s-result:hover, .s-result:focus-visible { background-position: 0 0; color: var(--base); } + .s-cat { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; color: var(--accent); + } + .s-result:hover .s-cat, .s-result:focus-visible .s-cat { color: rgba(250, 244, 237, 0.78); } + .s-title { font-family: var(--font-display); font-weight: 600; font-size: 1.02rem; letter-spacing: -0.015em; margin-top: 0.2rem; } + .s-excerpt { color: var(--fg-dim); font-size: 0.85rem; margin-top: 0.25rem; line-height: 1.45; } + .s-result:hover .s-excerpt, .s-result:focus-visible .s-excerpt { color: rgba(250, 244, 237, 0.78); } + .s-excerpt :global(mark) { background: color-mix(in oklab, var(--gold) 34%, transparent); color: inherit; padding: 0 2px; } </style> <script> diff --git a/src/components/SectionBanner.astro b/src/components/SectionBanner.astro @@ -0,0 +1,272 @@ +--- +interface Stat { + label: string; + value: string | number; + /** Paint the value in the banner's accent — one per banner, the number + * worth reading first. */ + accent?: boolean; +} + +interface Props { + /** Mono lockup before the rule, e.g. `03 ^: ACTIVE DIRECTORY`. */ + n: string; + label: string; + title: string; + blurb?: string; + stats?: Stat[]; + accent?: 'iris' | 'foam' | 'love' | 'gold' | 'rose' | 'pine'; +} +const { n, label, title, blurb, stats = [], accent = 'foam' } = Astro.props; + +/** + * The page header every route below the home page lands on: dark plate, + * live signal field, left scrim, and a ledger on the right. + * + * Legibility on the plate is carried by two layers, not one — the canvas + * erases its own top and bottom bands, AND the scrim gradient sits over + * the noise and under the content. Either alone leaves the title sitting + * on a bright filament some fraction of the loop. + */ +--- +<header class="banner plate bleed" style={`--acc: var(--${accent});`}> + <canvas class="banner__field" data-fuzz aria-hidden="true"></canvas> + <span class="banner__scrim" aria-hidden="true"></span> + <span class="grain grain--local" aria-hidden="true"></span> + + <div class="wrap banner__body"> + <div> + <p class="eyebrow" style={`--acc: var(--${accent});`}> + <span class="eyebrow__n">{n}</span> + <span class="eyebrow__mark">^:</span> + <span>{label}</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> + <h1 class="banner__title">{title}</h1> + {blurb && <p class="banner__blurb">{blurb}</p>} + </div> + + {stats.length > 0 && ( + <dl class="banner__ledger"> + {stats.map((s) => ( + <div class={`banner__row${s.accent ? ' banner__row--acc' : ''}`}> + <dt>{s.label}</dt> + <dd><b>{s.value}</b></dd> + </div> + ))} + </dl> + )} + </div> +</header> + +<script> + /** + * The banners' signal field: six pixel-sorted streaks drifting on an + * additive blend, plus bright filaments, looping seamlessly every nine + * seconds. + * + * Ported from the main site's logic class. Four things are load-bearing + * and break the look if changed: the loop-global phase (so two plates on + * one page stay in step), the DPR backing (at 1× the streaks are 1–2px + * and upscaling blurs the pixel-sorted look), the erasure scrim, and the + * synchronous frame zero. + */ + + /** Band geometry — six streaks, three hues between them. `hue` is a + * *slot* (1 = iris, 2 = foam, 3 = love), not a colour: which colour a + * slot resolves to, and how it composites, is read from CSS at draw + * time so the field follows the mode along with everything else. */ + const BANDS = [ + { cy: 0.10, ch: 0.20, hue: 1, drift: 1, cyc: 1, dens: 12 }, + { cy: 0.34, ch: 0.24, hue: 2, drift: -1, cyc: 2, dens: 15 }, + { cy: 0.22, ch: 0.14, hue: 3, drift: 2, cyc: 1, dens: 9 }, + { cy: 0.60, ch: 0.22, hue: 2, drift: 1, cyc: 1, dens: 13 }, + { cy: 0.80, ch: 0.16, hue: 1, drift: -1, cyc: 2, dens: 10 }, + { cy: 0.92, ch: 0.14, hue: 3, drift: 1, cyc: 2, dens: 9 }, + ] as const; + + interface Palette { + blend: GlobalCompositeOperation; + bands: [string, string, string]; + filaments: [string, string, string]; + gain: number; + } + + const FALLBACK: Palette = { + blend: 'lighter', + bands: ['196,167,231', '156,207,216', '235,111,146'], + filaments: ['224,222,244', '156,207,216', '196,167,231'], + gain: 1, + }; + + /** + * Resolve the palette off the canvas itself, so a plate inside a scope + * that pins the night palette draws the dark field even while the page + * around it is on paper. Reading the computed style is what makes that + * work: the cascade has already answered the question, and the canvas + * only has to ask the right element. + * + * Channels come back space-separated from CSS and the 2D context wants + * them comma-separated, hence the normalise. + */ + function readPalette(cv: HTMLCanvasElement): Palette { + const cs = getComputedStyle(cv); + const channels = (name: string, fallback: string) => { + const v = cs.getPropertyValue(name).trim(); + return v ? v.replace(/\s+/g, ',') : fallback; + }; + const blend = cs.getPropertyValue('--fuzz-blend').trim(); + const gain = parseFloat(cs.getPropertyValue('--fuzz-gain')); + return { + blend: (blend || FALLBACK.blend) as GlobalCompositeOperation, + bands: [ + channels('--fuzz-band-1', FALLBACK.bands[0]), + channels('--fuzz-band-2', FALLBACK.bands[1]), + channels('--fuzz-band-3', FALLBACK.bands[2]), + ], + filaments: [ + channels('--fuzz-fil-1', FALLBACK.filaments[0]), + channels('--fuzz-fil-2', FALLBACK.filaments[1]), + channels('--fuzz-fil-3', FALLBACK.filaments[2]), + ], + gain: Number.isFinite(gain) ? gain : FALLBACK.gain, + }; + } + + /** One frame of the field at `phase` ∈ [0,1). Every band's drift and + * breathe is a whole number of cycles per loop, so the 9s loop seams + * cleanly and any two plates on the page stay in step. */ + function frame(cv: HTMLCanvasElement, phase: number, amt: number, pal: Palette): void { + // clientWidth is 0 for the first frames after insert; fall back to the + // host box so the field is not blank until the second layout. + const host = cv.parentElement; + const w = (cv.clientWidth || host?.clientWidth || 0) | 0; + const h = (cv.clientHeight || host?.clientHeight || 0) | 0; + if (!w || !h) return; + + const dpr = Math.min(2, devicePixelRatio || 1); + const bw = Math.round(w * dpr); + const bh = Math.round(h * dpr); + if (cv.width !== bw || cv.height !== bh) { cv.width = bw; cv.height = bh; } + + const ctx = cv.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const frac = (x: number) => x - Math.floor(x); + const rnd = (s: number) => frac(Math.sin(s * 127.1) * 43758.5453); + + ctx.clearRect(0, 0, w, h); + ctx.globalCompositeOperation = pal.blend; + + for (let bi = 0; bi < BANDS.length; bi++) { + const b = BANDS[bi]; + const cy = b.cy * h; + const ch = b.ch * h; + for (let y = Math.max(0, Math.round(cy - ch)); y < Math.min(h, cy + ch); y += 2) { + const fall = 1 - Math.abs(y - cy) / ch; + const breathe = 0.5 + 0.5 * Math.sin(2 * Math.PI * (b.cyc * phase + y * 0.004 + bi * 0.3)); + const env = fall * (0.4 + 0.6 * breathe); + if (env <= 0.02) continue; + const n = Math.round(env * b.dens * amt); + for (let i = 0; i < n; i++) { + const s = y * 7.3 + i * 13.7 + bi * 101.3; + const x = frac(rnd(s) + phase * b.drift) * (w + 420) - 210; + const len = (18 + rnd(s + 1) * 340) * (0.4 + env); + const hx = 0.22 + 0.78 * Math.pow(Math.sin(Math.PI * ((x / w) * (1 + (bi % 3)) + phase + rnd(bi * 9))), 2); + const a = (0.06 + rnd(s + 2) * 0.34) * env * hx * 1.7 * pal.gain; + ctx.fillStyle = `rgba(${pal.bands[b.hue - 1]},${a.toFixed(3)})`; + ctx.fillRect(x, y, len, rnd(s + 3) > 0.84 ? 2 : 1); + } + } + } + + for (let i = 0; i < 54; i++) { + const s = i * 37.1; + const y = Math.round((rnd(s) * h) / 2) * 2; + const cyc = 1 + (i % 2); + const x = frac(rnd(s + 5) + phase * cyc) * (w + 700) - 350; + const a = (0.1 + rnd(s + 6) * 0.28) * amt * pal.gain; + ctx.fillStyle = `rgba(${pal.filaments[i % 3]},${a.toFixed(3)})`; + ctx.fillRect(x, y, 120 + rnd(s + 7) * 520, 1); + } + + // Scrim by erasure: destination-out bands at top and bottom so type + // near the plate edges always sits on clean ink. The percentage clamp + // matters — a fixed 40px erased most of the short plates. + ctx.globalCompositeOperation = 'destination-out'; + const solid = Math.min(40, h * 0.14); + const ramp = Math.min(26, h * 0.1); + ctx.fillStyle = 'rgba(0,0,0,1)'; + ctx.fillRect(0, 0, w, solid); + ctx.fillRect(0, h - solid, w, solid); + let g = ctx.createLinearGradient(0, solid, 0, solid + ramp); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, solid, w, ramp); + g = ctx.createLinearGradient(0, h - solid - ramp, 0, h - solid); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(0,0,0,1)'); + ctx.fillStyle = g; + ctx.fillRect(0, h - solid - ramp, w, ramp); + ctx.globalCompositeOperation = 'source-over'; + } + + /** Density scale. The main site ships at 6; the constant exists for + * tuning, not for the UI. */ + const PRESS = 6; + /** The frame reduced motion keeps as the permanent texture — mid-loop, + * where the bands are at their fullest rather than at a seam. */ + const STILL = 0.3; + + function initFuzz(): void { + const canvases = document.querySelectorAll<HTMLCanvasElement>('[data-fuzz]:not([data-bound])'); + canvases.forEach((cv) => { + cv.dataset.bound = '1'; + const amt = Math.max(0.2, PRESS / 5); + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + + // Resolved once per mode rather than once per frame: getComputedStyle + // flushes style on a canvas repainting at 60fps, and there is nothing + // to re-read between mode changes. + let pal = readPalette(cv); + + // Draw frame zero synchronously. rAF never fires in a hidden tab, + // during print, or in a screenshot, and a schedule-only start leaves + // the canvas at its 300×150 default — a bare plate in every capture. + const still = () => frame(cv, STILL, amt, pal); + frame(cv, reduced ? STILL : (performance.now() / 9000) % 1, amt, pal); + + // The animated loop re-measures every frame, so it absorbs resizes on + // its own; the static frame needs the observer to stay crisp. + const ro = new ResizeObserver(() => { if (reduced) still(); }); + ro.observe(cv.parentElement ?? cv); + + // A bitmap does not inherit. The variables have already changed by + // the time this fires, so re-reading them is all it takes — but a + // still frame has to be redrawn by hand, or the plate keeps the old + // palette until something else resizes it. + const onMode = () => { pal = readPalette(cv); if (reduced) still(); }; + addEventListener('daemonmodechange', onMode); + + let raf = 0; + if (!reduced) { + const loop = (now: number) => { + frame(cv, (now / 9000) % 1, amt, pal); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + } + + document.addEventListener('astro:before-swap', () => { + cancelAnimationFrame(raf); + ro.disconnect(); + removeEventListener('daemonmodechange', onMode); + }, { once: true }); + }); + } + + document.addEventListener('astro:page-load', initFuzz); + if (document.readyState !== 'loading') initFuzz(); + else document.addEventListener('DOMContentLoaded', initFuzz); +</script> diff --git a/src/components/SheetCard.astro b/src/components/SheetCard.astro @@ -1,25 +0,0 @@ ---- -import type { Sheet } from '../lib/sheets'; -import { sheetHref } from '../lib/sheets'; -import { categoryOf } from '../lib/taxonomy'; -import { url } from '../lib/url'; -import Icon from './Icon.astro'; -interface Props { entry: Sheet } -const { entry } = Astro.props; -const cat = categoryOf(entry.data.category); -const d = entry.data; ---- -<a class="card pane glow" href={url(sheetHref(entry))} style={`--acc: var(--${cat.accent});`}> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">{cat.tag} / {entry.id.split('/').pop()}</span> - </div> - <div class="pane__body"> - <h3 class="card-title">{d.title}</h3> - <p class="card-desc">{d.description}</p> - <div class="card-meta"> - <span class="badge badge--{d.difficulty}">{d.difficulty}</span> - <span class="card-arrow"><Icon name="arrow" /></span> - </div> - </div> -</a> diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro @@ -50,8 +50,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href; <ClientRouter /> </head> <body> - <div class="fx-glow" aria-hidden="true"></div> - <div class="fx-grid" aria-hidden="true"></div> + <!-- The film over the whole page. It is what keeps a flat colour + reading as stock rather than as a swatch, and it stands in for the + texture the old glass build got from blur. --> + <span class="grain" aria-hidden="true"></span> <Header /> <main id="main"> <slot /> diff --git a/src/pages/404.astro b/src/pages/404.astro @@ -1,30 +1,37 @@ --- import Base from '../layouts/Base.astro'; +import SectionBanner from '../components/SectionBanner.astro'; import Icon from '../components/Icon.astro'; import { url } from '../lib/url'; --- <Base title="404 — signal lost" description="This path resolves to nothing."> + <SectionBanner + n="00" + label="stderr" + title="404 — signal lost" + blurb="That route resolves to nothing. The host may be down, or the path was never in scope." + accent="love" + stats={[{ label: 'Status', value: '404', accent: true }, { label: 'Scope', value: 'unknown' }]} + /> + <div class="wrap"> - <section class="hero pane" style="--acc: var(--love);"> - <div class="scanlines" aria-hidden="true"></div> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">stderr — 404</span> + <!-- The shell readout, in the hero's register: not a box, just output + sitting on the page under the band that named the error. --> + <div class="whoami" style="margin-top:2rem;"> + <div class="whoami__cmd"> + <span class="u">daemon@sec</span><span>~</span><span>%</span> + <span class="c">curl -sI $PATH</span> + <span class="whoami__caret" aria-hidden="true"></span> </div> - <div class="pane__body"> - <p class="eyebrow" style="color:var(--love)">// signal lost</p> - <h1 class="hero-title">4<span class="hl2">0</span>4</h1> - <p class="hero-sub">That route resolves to nothing. The host may be down, - or the path was never in scope.</p> - <div class="term glass" style="padding:1rem 1.1rem; margin-top:1.4rem; max-width:520px;"> - <div class="line"><span class="p">daemon@sec</span><span class="o">:</span><span class="k">~</span><span class="o">$</span> <span class="c">curl -sI {'<path>'}</span></div> - <div class="line" style="color:var(--love)">HTTP/1.1 404 Not Found</div> - </div> - <div class="hero-actions"> - <a class="btn" href={url('')}><Icon name="terminal" /> Return to vault</a> - <button class="btn btn--ghost" data-search-open type="button"><Icon name="search" /> Search instead</button> - </div> - </div> - </section> + <dl class="whoami__rows"> + <dt>status</dt><dd style="color: var(--love);">HTTP/1.1 404 Not Found</dd> + <dt>hint</dt><dd>the vault index is one level up</dd> + </dl> + </div> + + <div class="hero__actions" style="margin: 2rem 0 3.5rem;"> + <a class="btn" href={url('')}><Icon name="terminal" /> Return to vault</a> + <button class="btn btn--ghost" data-search-open type="button"><Icon name="search" /> Search instead</button> + </div> </div> </Base> diff --git a/src/pages/[category]/index.astro b/src/pages/[category]/index.astro @@ -1,54 +1,74 @@ --- import Base from '../../layouts/Base.astro'; -import SheetCard from '../../components/SheetCard.astro'; +import SectionBanner from '../../components/SectionBanner.astro'; +import RecordRow from '../../components/RecordRow.astro'; import CategoryNav from '../../components/CategoryNav.astro'; -import Icon from '../../components/Icon.astro'; import { CATEGORIES } from '../../lib/taxonomy'; import type { CategoryDef } from '../../lib/taxonomy'; -import { sheetsByCategory, type Sheet } from '../../lib/sheets'; +import { sheetsByCategory, sheetHref, type Sheet } from '../../lib/sheets'; import { url } from '../../lib/url'; export async function getStaticPaths() { const groups = await sheetsByCategory(); - return CATEGORIES.map((c) => ({ + return CATEGORIES.map((c, i) => ({ params: { category: c.slug }, - props: { cat: c, items: groups.find((g) => g.slug === c.slug)?.items ?? [] }, + props: { + cat: c, + items: groups.find((g) => g.slug === c.slug)?.items ?? [], + n: String(i + 1).padStart(2, '0'), + }, })); } -interface Props { cat: CategoryDef; items: Sheet[] } -const { cat, items } = Astro.props; +interface Props { cat: CategoryDef; items: Sheet[]; n: string } +const { cat, items, n } = Astro.props; + +/* The ledger counts what the page can actually count. Difficulty spread is + the one figure a reader uses to decide where to start in a domain. */ +const byDifficulty = (level: string) => items.filter((s) => s.data.difficulty === level).length; +const tools = new Set(items.flatMap((s) => s.data.tools)); --- <Base title={`${cat.title} — DÆMON//SEC`} description={cat.blurb}> + <SectionBanner + n={n} + label={cat.tag} + title={cat.title} + blurb={cat.blurb} + accent={cat.accent} + stats={[ + { label: 'Sheets', value: String(items.length).padStart(2, '0'), accent: true }, + { label: 'Tools covered', value: String(tools.size).padStart(2, '0') }, + { label: 'Beginner / adv', value: `${byDifficulty('beginner')} / ${byDifficulty('advanced')}` }, + ]} + /> + <div class="wrap"> - <nav class="breadcrumb" style="margin-top:1.5rem;"> + <nav class="breadcrumb" style="margin-top:1.6rem;"> <a href={url('')}>~</a><span class="sep">/</span> <span style={`color: var(--${cat.accent})`}>{cat.tag.toLowerCase()}</span> </nav> - <header class="cat-header"> - <p class="eyebrow">// [{cat.tag}] · {items.length} {items.length === 1 ? 'sheet' : 'sheets'}</p> - <h1 class="sheet-title" style={`color: var(--${cat.accent});`}>{cat.title}</h1> - <p class="sheet-desc">{cat.blurb}</p> - </header> - - <div class="cat-nav-wrap"><CategoryNav active={cat.slug} /></div> + <div style="margin:1.4rem 0 2rem;"><CategoryNav active={cat.slug} /></div> {items.length > 0 ? ( - <div class="grid-cards"> - {items.map((entry) => <SheetCard entry={entry} />)} + <div class="index" style="margin-bottom:3rem;"> + {items.map((entry, i) => ( + <RecordRow + href={url(sheetHref(entry))} + n={String(i + 1).padStart(2, '0')} + title={entry.data.title} + kind={entry.data.difficulty} + accent={cat.accent} + meta={entry.data.updated ?? entry.id.split('/').pop()} + /> + ))} </div> ) : ( - <div class="pane" style="margin-top:1.5rem;"> - <div class="pane__body"> - <p class="muted mono">No sheets in this domain yet. More landing soon — - <a class="accent-text" href={url('')}>back to the vault</a>.</p> - </div> + <div class="slab" style="margin:1.5rem 0 3rem; padding:1.2rem 1.3rem;"> + <p class="muted mono" style="font-size:0.85rem;"> + No sheets in this domain yet. More landing soon — + <a class="accent-text" href={url('')}>back to the vault</a>. + </p> </div> )} </div> </Base> - -<style> - .cat-header { margin-top: 1rem; } - .cat-nav-wrap { margin: 1.6rem 0 2rem; } -</style> diff --git a/src/pages/credits.astro b/src/pages/credits.astro @@ -59,13 +59,12 @@ SOFTWARE.`; </p> <h3>MIT License</h3> - <div class="code-pane" style="margin:1rem 0;"> - <div class="code-pane__bar"> - <span class="code-pane__dots"><i></i><i></i><i></i></span> + <figure class="code-pane" style="margin:1.2rem 0;"> + <figcaption class="code-pane__bar"> <span class="code-pane__lang">LICENSE · PayloadsAllTheThings</span> - </div> - <pre style="padding:1rem 1.1rem;overflow:auto;"><code>{LICENSE}</code></pre> - </div> + </figcaption> + <pre style="overflow:auto;font-size:13px;line-height:1.7;color:#e0def4;"><code>{LICENSE}</code></pre> + </figure> <h2>The DÆMON//SEC cheatsheets</h2> <p> @@ -82,7 +81,7 @@ SOFTWARE.`; <a href="https://github.com/DAEMON-404/daemon-sec-cheatsheet" target="_blank" rel="noopener">GitHub</a>. </p> - <p class="muted" style="margin-top:2rem;border-top:1px solid var(--glass-brd);padding-top:1rem;"> + <p class="muted" style="margin-top:2rem;border-top:1px solid var(--rule);padding-top:1rem;"> For authorized testing, CTFs, and education only. Know your scope. </p> </div> diff --git a/src/pages/index.astro b/src/pages/index.astro @@ -1,10 +1,12 @@ --- import Base from '../layouts/Base.astro'; -import CategoryCard from '../components/CategoryCard.astro'; -import SheetCard from '../components/SheetCard.astro'; +import Hero from '../components/Hero.astro'; +import Marquee from '../components/Marquee.astro'; +import DomainPlate from '../components/DomainPlate.astro'; +import RecordRow from '../components/RecordRow.astro'; import Icon from '../components/Icon.astro'; -import { CATEGORIES } from '../lib/taxonomy'; -import { allSheets, categoryCounts } from '../lib/sheets'; +import { CATEGORIES, categoryOf } from '../lib/taxonomy'; +import { allSheets, categoryCounts, sheetHref } from '../lib/sheets'; import { payloadsByTopic } from '../lib/payloads'; import { url } from '../lib/url'; @@ -16,117 +18,90 @@ const domains = CATEGORIES.filter((c) => (counts[c.slug] ?? 0) > 0).length; const payloadTopics = await payloadsByTopic(); const payloadPages = payloadTopics.reduce((n, g) => n + g.count, 0); +const marqueeItems = CATEGORIES.map((cat) => ({ cat, count: counts[cat.slug] ?? 0 })); + const featured = [...sheets] .sort((a, b) => (b.data.updated ?? '').localeCompare(a.data.updated ?? '')) - .slice(0, 6); + .slice(0, 8); --- <Base> - <div class="wrap"> - <!-- HERO --> - <section class="hero pane plate"> - <div class="scanlines" aria-hidden="true"></div> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">daemon-sec — /root/vault</span> - </div> - <div class="pane__body"> - <div class="hero-grid"> - <div> - <p class="eyebrow">// curated offensive-security reference</p> - <h1 class="hero-title" data-glitch> - The <span class="hl">cheatsheet</span><br />vault for - <span class="hl2">operators</span>. - </h1> - <p class="hero-sub"> - {total} hand-picked, modernized cheatsheets across {domains} domains — - Active Directory, enumeration, exploitation, priv-esc, and more. - Fast search, copy-ready commands, zero fluff. - </p> - <div class="hero-actions"> - <button class="btn" data-search-open type="button"> - <Icon name="search" /> Search the vault <kbd style="opacity:.6">/</kbd> - </button> - <a class="btn btn--ghost" href={url('active-directory')}> - <Icon name="terminal" /> Browse Active Directory - </a> - </div> - <div class="hero-stats"> - <div class="hero-stat"><b>{total}</b><span>Cheatsheets</span></div> - <div class="hero-stat"><b>{domains}</b><span>Domains</span></div> - <div class="hero-stat"><b>∞</b><span>Copy-paste ready</span></div> - </div> - </div> + <!-- The page's rhythm: paper hero, band, paper index, band, paper index. + The bands break the measure and run edge to edge; their contents + return to it. --> + <Hero sheets={total} domains={domains} payloads={payloadPages} /> - <div class="term glass" style="padding:1.1rem 1.2rem; align-self:stretch;"> - <div class="line"><span class="p">daemon@sec</span><span class="o">:</span><span class="k">~</span><span class="o">$</span> <span class="c">whoami</span></div> - <div class="line o">operator — authorized. scope loaded.</div> - <div class="line"><span class="p">daemon@sec</span><span class="o">:</span><span class="k">~</span><span class="o">$</span> <span class="c">grep -ri "kerberoast" ./vault</span></div> - <div class="line o">active-directory/kerberoasting.md</div> - <div class="line o">tools/rubeus.md</div> - <div class="line"><span class="p">daemon@sec</span><span class="o">:</span><span class="k">~</span><span class="o">$</span> <span class="c">cat ./vault/index | wc -l</span></div> - <div class="line o">{total}</div> - <div class="line"><span class="p">daemon@sec</span><span class="o">:</span><span class="k">~</span><span class="o">$</span> <span class="cursor"></span></div> - </div> - </div> - </div> - </section> + <Marquee items={marqueeItems} /> - <!-- DOMAINS --> + <div class="wrap"> <section class="section" id="domains"> <div class="section-head"> <div> - <p class="eyebrow">// 12 domains</p> + <p class="eyebrow" style="--acc: var(--foam);"> + <span class="eyebrow__n">01</span> + <span class="eyebrow__mark">^:</span> + <span>{CATEGORIES.length} domains</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> <h2>Pick your vector</h2> </div> </div> - <div class="grid-cards"> - {CATEGORIES.map((cat) => <CategoryCard cat={cat} count={counts[cat.slug] ?? 0} />)} + <div class="grid-plates"> + {CATEGORIES.map((cat, i) => ( + <DomainPlate cat={cat} count={counts[cat.slug] ?? 0} n={String(i + 1).padStart(2, '0')} /> + ))} </div> </section> + </div> - <!-- PAYLOADS --> - <section class="section" id="payloads-cta"> - <a class="pane glow payloads-cta plate" href={url('payloads')} style="--acc: var(--iris);"> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">[payloads_all_the_things]</span> - <span class="patt-badge" style="margin-left:auto;">mirror</span> - </div> - <div class="pane__body payloads-cta__body"> - <div> - <p class="eyebrow" style="color: var(--iris)">// full mirror · MIT</p> - <h2 style="color: var(--iris); margin:0.2rem 0 0.4rem;">PayloadsAllTheThings</h2> - <p class="card-desc" style="max-width:52ch;"> - The complete <strong>{payloadTopics.length}-topic</strong>, <strong>{payloadPages}-page</strong> payload &amp; - bypass reference by <strong>swisskyrepo</strong> — mirrored on-site, fully searchable, every page linked to source. - </p> - </div> - <span class="btn btn--ghost" style="flex:none;">Browse payloads <Icon name="arrow" /></span> - </div> - </a> - </section> + <!-- The ledger band. Far enough down the page from the marquee that the + two never share a sightline, which is why they are free to differ — + and being separate tokens, they do so on purpose. --> + <a class="cta-band bleed section" href={url('payloads')}> + <div class="wrap cta-band__body"> + <div> + <p class="cta-band__eyebrow">02 ^: Full mirror · MIT</p> + <h2>PayloadsAllTheThings</h2> + <p> + The complete {payloadTopics.length}-topic, {payloadPages}-page payload and bypass + reference by <strong>swisskyrepo</strong> — mirrored on-site, fully searchable, + every page linked back to source. + </p> + </div> + <span class="btn">Browse payloads <Icon name="arrow" /></span> + </div> + </a> - <!-- FEATURED --> - {featured.length > 0 && ( + {featured.length > 0 && ( + <div class="wrap"> <section class="section" id="featured"> <div class="section-head"> <div> - <p class="eyebrow">// freshly modernized</p> + <p class="eyebrow" style="--acc: var(--gold);"> + <span class="eyebrow__n">03</span> + <span class="eyebrow__mark">^:</span> + <span>Freshly modernized</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> <h2>Recently updated</h2> </div> <a class="btn btn--ghost" href={url('tags')}>All tags <Icon name="arrow" /></a> </div> - <div class="grid-cards"> - {featured.map((entry) => <SheetCard entry={entry} />)} + <div class="index"> + {featured.map((entry, i) => { + const cat = categoryOf(entry.data.category); + return ( + <RecordRow + href={url(sheetHref(entry))} + n={String(i + 1).padStart(2, '0')} + title={entry.data.title} + kind={cat.tag} + accent={cat.accent} + meta={entry.data.updated ?? entry.data.difficulty} + /> + ); + })} </div> </section> - )} - </div> + </div> + )} </Base> - -<style> - @media (prefers-reduced-motion: no-preference) { - [data-glitch] { animation: rise 0.7s var(--ease) both; } - @keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } - } -</style> diff --git a/src/pages/payloads/[...slug].astro b/src/pages/payloads/[...slug].astro @@ -62,12 +62,12 @@ const leaf = entry.id.split('/').pop()!.replace(/^index$/, d.topic); <nav class="prevnext" aria-label="Pagination"> {prev ? ( - <a class="pane glow" href={url(payloadHref(prev))} style="--acc: var(--iris)"> + <a href={url(payloadHref(prev))} style="--acc: var(--iris)"> <span class="dir">← Prev</span><strong>{prev.data.title}</strong> </a> ) : <span></span>} {next ? ( - <a class="pane glow nx" href={url(payloadHref(next))} style="--acc: var(--iris)"> + <a class="nx" href={url(payloadHref(next))} style="--acc: var(--iris)"> <span class="dir">Next →</span><strong>{next.data.title}</strong> </a> ) : <span></span>} diff --git a/src/pages/payloads/index.astro b/src/pages/payloads/index.astro @@ -1,5 +1,7 @@ --- import Base from '../../layouts/Base.astro'; +import SectionBanner from '../../components/SectionBanner.astro'; +import RecordRow from '../../components/RecordRow.astro'; import Icon from '../../components/Icon.astro'; import { payloadsByTopic } from '../../lib/payloads'; import { url } from '../../lib/url'; @@ -7,51 +9,59 @@ import { url } from '../../lib/url'; const groups = await payloadsByTopic(); const totalPages = groups.reduce((n, g) => n + g.count, 0); const sha = groups[0]?.readme?.data.sha ?? groups[0]?.items[0]?.data.sha ?? ''; -// Rotate accent colours so the grid reads varied, like the domain grid. +/* Rotated so the index reads varied down its kind column rather than as + sixty-six identical rows. */ const ACCENTS = ['iris', 'foam', 'gold', 'love', 'rose', 'pine']; --- -<Base title="Payloads — PayloadsAllTheThings mirror — DÆMON//SEC" description="A full, searchable mirror of PayloadsAllTheThings — 66 topics of web & infra exploitation payloads. Credit: swisskyrepo, MIT."> +<Base + title="Payloads — PayloadsAllTheThings mirror — DÆMON//SEC" + description="A full, searchable mirror of PayloadsAllTheThings — 66 topics of web & infra exploitation payloads. Credit: swisskyrepo, MIT." +> + <SectionBanner + n="02" + label="Full mirror · MIT" + title="PayloadsAllTheThings" + blurb="A complete on-site mirror of swisskyrepo's payload and bypass reference — searchable offline, every page linked back to source." + accent="iris" + stats={[ + { label: 'Topics', value: String(groups.length).padStart(2, '0'), accent: true }, + { label: 'Pages', value: totalPages }, + { label: 'License', value: 'MIT' }, + ]} + /> + <div class="wrap"> - <header class="sheet-head" style="margin-top:2rem;"> - <p class="eyebrow" style="color: var(--iris)">// Payloads</p> - <h1 class="sheet-title">PayloadsAllTheThings</h1> - <p class="sheet-desc"> - A full, on-site mirror of <a href="https://github.com/swisskyrepo/PayloadsAllTheThings" target="_blank" rel="noopener" style="color:var(--iris)">PayloadsAllTheThings</a> - — {groups.length} topics, {totalPages} pages of web &amp; infrastructure exploitation payloads and bypasses. - All credit to <strong>swisskyrepo</strong> and its contributors. Licensed <strong>MIT</strong>. - </p> - </header> + <nav class="breadcrumb" style="margin-top:1.6rem;"> + <a href={url('')}>~</a><span class="sep">/</span><span class="muted">payloads</span> + </nav> - <aside class="patt-credit" style="margin-bottom:1.6rem;"> + <aside class="patt-credit" style="margin-top:1.4rem;"> <span class="patt-badge">mirror</span> <span class="patt-credit__text"> Mirrored verbatim from the upstream repo. Images and raw payload files load from GitHub; see each page's <em>source</em> link. <span class="patt-lic">MIT · <a href={url('credits')}>full credits &amp; license</a>{sha && <> · <code>{sha.slice(0,7)}</code></>}</span> </span> <a class="patt-src" href="https://github.com/swisskyrepo/PayloadsAllTheThings" target="_blank" rel="noopener"> - <Icon name="github" style="width:15px;height:15px;" /> upstream + <Icon name="github" /> upstream </a> </aside> - <div class="grid-cards" style="margin-bottom:3rem;"> - {groups.map((g, i) => { - const acc = ACCENTS[i % ACCENTS.length]; - return ( - <a class="card pane glow" href={url(`payloads/${g.slug}`)} style={`--acc: var(--${acc});`}> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">[{g.slug.replace(/-/g, '_').slice(0, 22)}]</span> - </div> - <div class="pane__body"> - <h3 class="card-title" style={`color: var(--${acc});`}>{g.title}</h3> - <div class="card-meta"> - <span class="card-count">{g.count} {g.count === 1 ? 'page' : 'pages'}</span> - <span class="card-arrow"><Icon name="arrow" /></span> - </div> - </div> - </a> - ); - })} + <div class="index" style="margin-bottom:3rem;"> + {groups.map((g, i) => ( + <RecordRow + href={url(`payloads/${g.slug}`)} + n={String(i + 1).padStart(2, '0')} + title={g.title} + kind="payload" + kindIcon="layers" + accent={ACCENTS[i % ACCENTS.length]} + meta={`${g.count} ${g.count === 1 ? 'page' : 'pages'}`} + /> + ))} </div> </div> </Base> + +<style> + .patt-src svg { width: 14px; height: 14px; } +</style> diff --git a/src/pages/sheets/[...slug].astro b/src/pages/sheets/[...slug].astro @@ -36,7 +36,12 @@ const pdfHref = d.pdf ? url(`pdfs/${d.pdf}`) : null; </nav> <header class="sheet-head" data-pagefind-meta={`category:${cat.title}`}> - <p class="eyebrow" style={`color: var(--${cat.accent})`}>// {cat.title}</p> + <p class="eyebrow" style={`--acc: var(--${cat.accent});`}> + <span class="eyebrow__n">{cat.tag}</span> + <span class="eyebrow__mark">^:</span> + <span>{cat.title}</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> <h1 class="sheet-title" data-pagefind-meta="title">{d.title}</h1> {d.description && <p class="sheet-desc">{d.description}</p>} <div class="sheet-meta"> @@ -54,14 +59,13 @@ const pdfHref = d.pdf ? url(`pdfs/${d.pdf}`) : null; <div class="sheet-layout"> <article class="prose" data-pagefind-body> {pdfHref && ( - <div class="pane" style="margin-bottom:1.5rem;"> - <div class="pane__bar"> - <span class="pane__dots"><i></i><i></i><i></i></span> - <span class="pane__label">{d.pdf}</span> - <a class="code-copy" href={pdfHref} download style="margin-left:auto;text-decoration:none;">download</a> - </div> + <figure class="code-pane" style="margin-bottom:1.6rem;"> + <figcaption class="code-pane__bar"> + <span class="code-pane__lang">{d.pdf}</span> + <a class="code-copy" href={pdfHref} download>Download</a> + </figcaption> <iframe class="pdf-frame" src={pdfHref} title={`${d.title} (PDF)`} loading="lazy"></iframe> - </div> + </figure> )} <Content /> </article> @@ -80,12 +84,12 @@ const pdfHref = d.pdf ? url(`pdfs/${d.pdf}`) : null; <nav class="prevnext" aria-label="Pagination"> {prev ? ( - <a class="pane glow" href={url(sheetHref(prev))} style={`--acc: var(--${cat.accent})`}> + <a href={url(sheetHref(prev))} style={`--acc: var(--${cat.accent})`}> <span class="dir">← Prev</span><strong>{prev.data.title}</strong> </a> ) : <span></span>} {next ? ( - <a class="pane glow nx" href={url(sheetHref(next))} style={`--acc: var(--${cat.accent})`}> + <a class="nx" href={url(sheetHref(next))} style={`--acc: var(--${cat.accent})`}> <span class="dir">Next →</span><strong>{next.data.title}</strong> </a> ) : <span></span>} diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro @@ -1,7 +1,9 @@ --- import Base from '../../layouts/Base.astro'; -import SheetCard from '../../components/SheetCard.astro'; -import { allSheets, allTags, tagSlug, type Sheet } from '../../lib/sheets'; +import SectionBanner from '../../components/SectionBanner.astro'; +import RecordRow from '../../components/RecordRow.astro'; +import { categoryOf } from '../../lib/taxonomy'; +import { allSheets, allTags, tagSlug, sheetHref, type Sheet } from '../../lib/sheets'; import { url } from '../../lib/url'; export async function getStaticPaths() { @@ -14,20 +16,44 @@ export async function getStaticPaths() { } interface Props { tag: string; items: Sheet[] } const { tag, items } = Astro.props; + +/* How many domains the tag reaches across — the figure that says whether a + tag is a niche or a spine running through the vault. */ +const domains = new Set(items.map((s) => s.data.category)).size; --- <Base title={`#${tag} — DÆMON//SEC`} description={`Cheatsheets tagged ${tag}.`}> + <SectionBanner + n="04" + label="Tag" + title={`#${tag}`} + accent="iris" + stats={[ + { label: 'Sheets', value: String(items.length).padStart(2, '0'), accent: true }, + { label: 'Domains', value: String(domains).padStart(2, '0') }, + ]} + /> + <div class="wrap"> - <nav class="breadcrumb" style="margin-top:1.5rem;"> + <nav class="breadcrumb" style="margin-top:1.6rem;"> <a href={url('')}>~</a><span class="sep">/</span> <a href={url('tags')}>tags</a><span class="sep">/</span> <span class="accent-text">{tag}</span> </nav> - <header style="margin-top:1rem;"> - <p class="eyebrow">// {items.length} {items.length === 1 ? 'sheet' : 'sheets'}</p> - <h1 class="sheet-title">#{tag}</h1> - </header> - <div class="grid-cards" style="margin-top:1.8rem;"> - {items.map((entry) => <SheetCard entry={entry} />)} + + <div class="index" style="margin:1.6rem 0 3rem;"> + {items.map((entry, i) => { + const cat = categoryOf(entry.data.category); + return ( + <RecordRow + href={url(sheetHref(entry))} + n={String(i + 1).padStart(2, '0')} + title={entry.data.title} + kind={cat.tag} + accent={cat.accent} + meta={entry.data.updated ?? entry.data.difficulty} + /> + ); + })} </div> </div> </Base> diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro @@ -1,32 +1,60 @@ --- import Base from '../../layouts/Base.astro'; +import SectionBanner from '../../components/SectionBanner.astro'; import { allTags, allTools, tagSlug } from '../../lib/sheets'; import { url } from '../../lib/url'; const tags = await allTags(); const tools = await allTools(); --- <Base title="Tags & Tools — DÆMON//SEC" description="Browse every cheatsheet by tag or tool."> + <SectionBanner + n="04" + label="Cross-reference" + title="Tags & Tools" + blurb="Jump into the vault by topic or by the tool you're reaching for." + accent="foam" + stats={[ + { label: 'Tags', value: String(tags.length).padStart(2, '0'), accent: true }, + { label: 'Tools', value: String(tools.length).padStart(2, '0') }, + ]} + /> + <div class="wrap"> - <nav class="breadcrumb" style="margin-top:1.5rem;"> + <nav class="breadcrumb" style="margin-top:1.6rem;"> <a href={url('')}>~</a><span class="sep">/</span><span class="muted">index</span> </nav> - <header style="margin-top:1rem;"> - <p class="eyebrow">// cross-reference</p> - <h1 class="sheet-title">Tags &amp; Tools</h1> - <p class="sheet-desc">Jump into the vault by topic or by the tool you're reaching for.</p> - </header> - <section class="section" style="margin-top:2rem;"> - <div class="section-head"><div><p class="eyebrow">// {tags.length} tags</p><h2>By tag</h2></div></div> + <section class="section"> + <div class="section-head"> + <div> + <p class="eyebrow" style="--acc: var(--iris);"> + <span class="eyebrow__n">01</span> + <span class="eyebrow__mark">^:</span> + <span>{tags.length} tags</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> + <h2>By tag</h2> + </div> + </div> <div class="cloud"> {tags.map((t) => ( - <a class="tag-pill cloud-item" href={url(`tags/${tagSlug(t.tag)}`)}>#{t.tag}<span class="cnt">{t.count}</span></a> + <a class="tag-pill cloud-item" href={url(`tags/${tagSlug(t.tag)}`)}>{t.tag}<span class="cnt">{t.count}</span></a> ))} </div> </section> <section class="section"> - <div class="section-head"><div><p class="eyebrow">// {tools.length} tools</p><h2>By tool</h2></div></div> + <div class="section-head"> + <div> + <p class="eyebrow" style="--acc: var(--gold);"> + <span class="eyebrow__n">02</span> + <span class="eyebrow__mark">^:</span> + <span>{tools.length} tools</span> + <span class="eyebrow__rule" aria-hidden="true"></span> + </p> + <h2>By tool</h2> + </div> + </div> <div class="cloud"> {tools.map((t) => ( <span class="tag-pill cloud-item">{t.tool}<span class="cnt">{t.count}</span></span> @@ -37,7 +65,14 @@ const tools = await allTools(); </Base> <style> - .cloud { display: flex; flex-wrap: wrap; gap: 0.55rem; } - .cloud-item { display: inline-flex; align-items: center; gap: 0.45rem; } - .cloud-item .cnt { font-size: 0.66rem; color: var(--fg-faint); background: var(--glass-bg); padding: 0.05rem 0.35rem; border-radius: 999px; } + .cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; } + .cloud-item { display: inline-flex; align-items: center; gap: 0.5rem; } + /* The count is a second register inside the chip, not a badge on it — + square, hairline-separated, same 10px mono. */ + .cloud-item .cnt { + padding-left: 0.5rem; + border-left: 1px solid var(--rule); + color: var(--fg-faint); + } + .cloud-item:hover .cnt { color: inherit; border-left-color: currentColor; } </style> diff --git a/src/scripts/app.ts b/src/scripts/app.ts @@ -4,6 +4,9 @@ function applyTheme(next: 'light' | 'dark'): void { document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('theme', next); } catch {} document.documentElement.style.colorScheme = next; + // A canvas bitmap does not inherit the cascade, so the banners' signal + // field has no way to notice the mode changed. It listens for this. + dispatchEvent(new Event('daemonmodechange')); } function initTheme(): void { @@ -40,29 +43,46 @@ function initTheme(): void { }); } +/** + * Every code block becomes the archive's terminal block: the language at + * the left of a caption bar, the copy control at the right, both in the + * 9.5px mono the rest of the site labels things in. + * + * No window chrome and no traffic-light dots — this is a printed listing + * of terminal output, not a simulated window. The plate under it stays + * dark in both modes (see prose.css). + */ +const COPY_ICON = + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>'; +const CHECK_ICON = + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>'; + function enhanceCode(): void { const blocks = document.querySelectorAll<HTMLPreElement>('.prose pre.astro-code:not([data-enhanced])'); blocks.forEach((pre) => { pre.dataset.enhanced = '1'; - const lang = pre.getAttribute('data-language') || 'sh'; + const lang = pre.getAttribute('data-language') || 'text'; - const pane = document.createElement('div'); + const pane = document.createElement('figure'); pane.className = 'code-pane'; - const bar = document.createElement('div'); + const bar = document.createElement('figcaption'); bar.className = 'code-pane__bar'; - const dots = document.createElement('span'); - dots.className = 'code-pane__dots'; - dots.append(document.createElement('i'), document.createElement('i'), document.createElement('i')); const langEl = document.createElement('span'); langEl.className = 'code-pane__lang'; - langEl.textContent = lang.replace(/[^a-z0-9+#-]/gi, '').slice(0, 16) || 'sh'; - bar.append(dots, langEl); + langEl.textContent = lang.replace(/[^a-z0-9+#-]/gi, '').slice(0, 16) || 'text'; const copy = document.createElement('button'); copy.className = 'code-copy'; copy.type = 'button'; - copy.textContent = 'copy'; + const setLabel = (icon: string, word: string) => { + copy.innerHTML = icon; + copy.append(document.createTextNode(word)); + }; + setLabel(COPY_ICON, 'Copy'); + + // The feedback lives on the button for 1400ms rather than in a toast + // across the page — you copied this block, not the document. copy.addEventListener('click', async () => { const text = pre.innerText; try { @@ -73,14 +93,14 @@ function enhanceCode(): void { try { document.execCommand('copy'); } catch {} ta.remove(); } - copy.textContent = 'copied'; copy.classList.add('copied'); - setTimeout(() => { copy.textContent = 'copy'; copy.classList.remove('copied'); }, 1400); + setLabel(CHECK_ICON, 'Copied'); + copy.classList.add('copied'); + setTimeout(() => { setLabel(COPY_ICON, 'Copy'); copy.classList.remove('copied'); }, 1400); }); - bar.appendChild(copy); + bar.append(langEl, copy); pre.parentNode?.insertBefore(pane, pre); - pane.appendChild(bar); - pane.appendChild(pre); + pane.append(bar, pre); }); } diff --git a/src/styles/app.css b/src/styles/app.css @@ -1,4 +1,4 @@ @import './tokens.css'; -@import './glass.css'; +@import './chrome.css'; @import './global.css'; @import './prose.css'; diff --git a/src/styles/chrome.css b/src/styles/chrome.css @@ -0,0 +1,254 @@ +/* ============================================================================ + Chrome — the site's form language, ported from the main DÆMON build. + + This file replaces the old glass.css. Where that file made a container + out of translucency (blur, tint, inner highlight, 14px radius), this one + makes one out of a hairline and a value change. The objects below are + the whole vocabulary; every page is assembled from them. + + .slab flat panel — 1px rule, square, no shadow + .plate inverse (night) panel, defined in tokens.css + .grain the film over everything + .micro 10px mono label — the site's furniture type + .eyebrow `03 ^: LABEL` lockup with a keyed rule + .btn flat control + .wipe ink wipe for index rows + .clip / .stretch the lockup's scale rig + ========================================================================== */ + +/* ---- The film ------------------------------------------------------------ */ +/* Fractal noise laid over the page so a flat colour reads as stock rather + than as a swatch. It is the one thing standing in for the texture the + glass build got from blur, and it is why removing the blur does not + leave the page feeling like unpainted CSS. + + Multiply on paper, screen on ink (tokens.css) — noise has to be laid + *into* a light ground and *added* to a dark one; the same blend on both + is invisible on one of them. */ +.grain { + position: fixed; + inset: 0; + z-index: 1; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E"); + opacity: var(--grain-opacity); + mix-blend-mode: var(--grain-blend); +} + +/* A plate carries its own grain, because it pins the night palette for its + subtree and therefore needs the dark blend while the page around it is + still on the light one. */ +.grain--local { + position: absolute; + z-index: 2; +} + +/* ---- Panels -------------------------------------------------------------- */ +.slab { + border: 1px solid var(--rule); + background: var(--surface); +} +/* The heavier variant, for the one panel on a screen that is the point of + it — the site draws these at full ink rather than at rule weight. */ +.slab--ink { + border-color: var(--fg); + background: var(--base); +} + +/* ---- Type furniture ------------------------------------------------------ */ +/* 10px mono, uppercase, widely tracked. Every eyebrow, kind mark, stat + label, tab and caption on the site is this object. */ +.micro { + font-family: var(--font-mono); + font-size: var(--step-micro); + letter-spacing: var(--track-micro); + text-transform: uppercase; + line-height: 1.6; +} +.micro--wide { letter-spacing: var(--track-wide); } + +/* The section lockup: an index, the site's self-naming `^:` mark, a label, + then a rule that runs out to the end of the line in the section's own + hue. It is what tells you which of the twelve domains you are in + without a heading having to say it twice. */ +.eyebrow { + display: flex; + align-items: center; + gap: 0.9rem; + font-family: var(--font-mono); + font-size: var(--step-micro); + letter-spacing: var(--track-wide); + text-transform: uppercase; + color: var(--fg-faint); +} +.eyebrow__n { color: var(--acc, var(--accent)); } +.eyebrow__mark { color: var(--fg-faint); opacity: 0.7; } +.eyebrow__rule { + flex: 1; + height: 1px; + background: var(--acc, var(--accent)); + opacity: 0.5; +} + +/* Section heading — Archivo at its heaviest, condensed, tracked in tight. + Uppercase is load-bearing: at this weight and width the lowercase + letterforms lose their counters and the word turns into a block. */ +.display { + font-family: var(--font-display); + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.03em; + line-height: 0.94; + text-transform: uppercase; +} + +/* ---- Controls ------------------------------------------------------------ */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.72rem 1.1rem; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: var(--track-micro); + text-transform: uppercase; + /* The label is a single tracked line. Without this it wraps at the + button's intrinsic width and the control turns into a three-line + block, which at this letter-spacing is unreadable. */ + white-space: nowrap; + border: 1px solid transparent; + background: var(--fg); + color: var(--base); + transition: background-color 240ms ease, color 200ms ease, border-color 220ms ease; +} +/* An inline SVG with no intrinsic size resolves to 300×150 and detonates + the control. Every icon in a button is a 14px mark beside the label. */ +.btn svg { width: 14px; height: 14px; flex: none; } +.btn:hover { background: var(--accent-2); } +.btn--ghost { + background: transparent; + color: var(--fg); + border-color: var(--rule-hi); +} +.btn--ghost:hover { background: var(--fg); color: var(--base); border-color: var(--fg); } + +/* A chip is a bordered label, never a filled pill. Square, hairline, hue + from whatever it qualifies. */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.36rem 0.7rem; + font-family: var(--font-mono); + font-size: var(--step-micro); + letter-spacing: var(--track-micro); + text-transform: uppercase; + border: 1px solid color-mix(in oklab, var(--acc, var(--accent)) 55%, transparent); + color: var(--acc, var(--accent)); + transition: background-color 240ms ease, color 200ms ease; +} +a.chip:hover { background: var(--acc, var(--accent)); color: var(--base); } + +/* The live dot — a filled core with a ring expanding off it. Used for + anything the page wants to claim is current rather than static. */ +.dot { + position: relative; + display: block; + width: 6px; + height: 6px; + flex: none; +} +.dot i, +.dot u { + position: absolute; + inset: 0; + border-radius: 50%; + display: block; +} +.dot i { background: currentColor; } +.dot u { border: 1px solid currentColor; } +@media (prefers-reduced-motion: no-preference) { + .dot i { animation: dmn-pulse 2.4s ease-in-out infinite; } + .dot u { animation: dmn-ring 4.2s ease-out infinite; } +} + +/* ---- The index row's ink wipe -------------------------------------------- */ +/* Hovering a row fills it with ink from the left. Driven by + `background-position` over a hard-stop gradient rather than by + `background-color`, and the transition list deliberately has no `color` + in it — dragging a pointer down a fifty-row index is the one interaction + here that can drop frames, and a colour transition on every row it + crosses is what does it. The text colour flips instantly instead. */ +.wipe { + background-image: linear-gradient(90deg, var(--fg) 0 50%, transparent 50% 100%); + background-size: 200% 100%; + background-position: 100% 0; + background-repeat: no-repeat; + transition: background-position 380ms var(--ease-in), color 1ms; +} +.wipe:hover { background-position: 0 0; } + +/* ---- Shared timing ------------------------------------------------------- */ +.dmn-t { + transition: + background-color 240ms ease, + color 200ms ease, + border-color 220ms ease, + transform 220ms var(--ease-in), + opacity 200ms ease; +} + +/* ---- The lockup's scale rig ---------------------------------------------- */ +/* `scaleY` overflows its own line box, so a stretched display line has to + sit in a fixed-height wrapper that clips it — otherwise it collides with + whatever is under it and no amount of margin fixes it, because the + overflow is not in the layout. */ +.clip { overflow: hidden; } +.stretch { + transform-origin: 0 0; + white-space: nowrap; + user-select: none; +} + +/* ---- Keyframes ----------------------------------------------------------- */ +@keyframes dmn-up { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes dmn-track-in { + from { letter-spacing: 0.075em; transform: scaleX(0.93); } + to { letter-spacing: 0.012em; transform: scaleX(1); } +} +@keyframes dmn-draw { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +@keyframes dmn-wipe-in { + from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0 0 0); } +} +@keyframes dmn-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } +} +@keyframes dmn-ring { + 0% { transform: scale(1); opacity: 0.7; } + 70%, 100% { transform: scale(2.6); opacity: 0; } +} +@keyframes dmn-blink { + 50% { opacity: 0; } +} + +/* Under reduced motion every entrance collapses to a single 0.01ms pass, + which lands on the same rest state the animation would have reached — + so the markup needs no second code path. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/styles/glass.css b/src/styles/glass.css @@ -1,121 +0,0 @@ -/* ============================================================================ - Glass + the terminal-pane signature. - .glass -> frosted translucent panel - .pane -> glass panel with a monospace status bar + status dots - ========================================================================== */ - -.glass { - background: var(--glass-bg); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%); - backdrop-filter: blur(var(--glass-blur)) saturate(150%); - border: 1px solid var(--glass-brd); - border-radius: var(--radius); - box-shadow: var(--glass-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); -} - -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .glass { background: var(--glass-bg-strong); } -} - -/* Terminal pane: the universal container language --------------------------- */ -.pane { - position: relative; - background: var(--glass-bg); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%); - backdrop-filter: blur(var(--glass-blur)) saturate(150%); - border: 1px solid var(--glass-brd); - border-radius: var(--radius); - box-shadow: var(--glass-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); - overflow: hidden; -} - -.pane__bar { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.5rem 0.85rem; - border-bottom: 1px solid var(--glass-brd); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent); - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--fg-faint); -} - -.pane__dots { - display: inline-flex; - gap: 0.34rem; - flex: 0 0 auto; -} -.pane__dots i { - width: 9px; - height: 9px; - border-radius: 50%; - display: block; - opacity: 0.9; -} -.pane__dots i:nth-child(1) { background: var(--love); } -.pane__dots i:nth-child(2) { background: var(--gold); } -.pane__dots i:nth-child(3) { background: var(--foam); } - -.pane__label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pane__body { padding: 1rem 1.1rem; } - -/* Accent edge-glow, driven by --acc (a palette token) ----------------------- */ -.glow { - --acc: var(--foam); - transition: transform 0.35s var(--ease), box-shadow 0.35s var(--ease), - border-color 0.35s var(--ease); -} -.glow:hover, -.glow:focus-within { - transform: translateY(-4px); - border-color: color-mix(in oklab, var(--acc) 55%, var(--glass-brd-hi)); - box-shadow: - var(--glass-shadow), - 0 0 0 1px color-mix(in oklab, var(--acc) 35%, transparent), - 0 10px 40px color-mix(in oklab, var(--acc) 22%, transparent); -} - -/* Ambient background layers: grid + scanlines + drift ----------------------- */ -.fx-grid { - position: fixed; - inset: 0; - z-index: -2; - pointer-events: none; - background-image: - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: 46px 46px; - mask-image: radial-gradient(120% 90% at 50% -10%, #000 30%, transparent 78%); - -webkit-mask-image: radial-gradient(120% 90% at 50% -10%, #000 30%, transparent 78%); -} -.fx-glow { - position: fixed; - inset: 0; - z-index: -3; - pointer-events: none; - background: - radial-gradient(60% 45% at 12% 8%, color-mix(in oklab, var(--iris) 20%, transparent), transparent 60%), - radial-gradient(55% 45% at 88% 4%, color-mix(in oklab, var(--foam) 16%, transparent), transparent 60%); -} - -.scanlines { - position: absolute; - inset: 0; - pointer-events: none; - background: repeating-linear-gradient( - 0deg, transparent 0, transparent 2px, var(--scan) 3px, transparent 4px - ); - mix-blend-mode: screen; - opacity: 0.6; -} - -@media (prefers-reduced-motion: no-preference) { - .scanlines { animation: scan-drift 8s linear infinite; } - @keyframes scan-drift { - from { background-position-y: 0; } - to { background-position-y: 100px; } - } -} diff --git a/src/styles/global.css b/src/styles/global.css @@ -1,5 +1,8 @@ /* ============================================================================ Base elements, layout, chrome, and components. + Built out of the objects in chrome.css. Nothing here rounds a corner, + blurs a background, or casts a shadow — see the form-language note at + the top of tokens.css for why. ========================================================================== */ *, *::before, *::after { box-sizing: border-box; } @@ -10,7 +13,7 @@ html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } -:root[data-theme='dark'] html, html:has(:root[data-theme='dark']) { color-scheme: dark; } +:root[data-theme='dark'] { color-scheme: dark; } body { font-family: var(--font-body); @@ -30,236 +33,826 @@ a { color: inherit; text-decoration: none; } img, svg { display: block; max-width: 100%; } button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; } -h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 700; line-height: 1.1; letter-spacing: -0.01em; } +h1, h2, h3, h4 { + font-family: var(--font-display); + font-weight: 700; + line-height: 1.06; + letter-spacing: -0.02em; +} +/* Square, like everything else — a rounded focus ring on a square page + reads as a rendering artefact rather than as focus. */ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; - border-radius: 4px; } +/* Page content sits above the grain film. */ +main, .site-header, .site-footer { position: relative; z-index: 2; } + /* ---- Layout -------------------------------------------------------------- */ -.wrap { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: clamp(1rem, 3vw, 2rem); } -.stack > * + * { margin-top: var(--gap, 1rem); } -.eyebrow { - font-family: var(--font-mono); - font-size: 0.74rem; - letter-spacing: 0.22em; - text-transform: uppercase; - color: var(--fg-faint); +.wrap { + width: 100%; + max-width: var(--maxw); + margin-inline: auto; + padding-inline: clamp(1.15rem, 3.2vw, 2.75rem); } +/* Full-bleed: a band breaks the measure and runs edge to edge, but its + *contents* return to it. This is the page's basic rhythm — paper on the + measure, bands across the whole width. */ +.bleed { width: 100%; } +.stack > * + * { margin-top: var(--gap, 1rem); } .accent-text { color: var(--accent); } -/* ---- Header -------------------------------------------------------------- */ +/* ---- Masthead ------------------------------------------------------------ */ +/* Opaque, not frosted: the bar has to hold its own value against whatever + scrolls under it, and on this page that is as often a dark band as it is + cream. A translucent bar over a passing band inverts mid-scroll. */ .site-header { position: sticky; top: 0; - z-index: 50; - padding: 0.55rem 0; - border-bottom: 1px solid var(--glass-brd); - background: color-mix(in oklab, var(--bg) 62%, transparent); - -webkit-backdrop-filter: blur(14px) saturate(140%); - backdrop-filter: blur(14px) saturate(140%); -} -.site-header .wrap { display: flex; align-items: center; gap: 1rem; } + z-index: 60; + border-bottom: 1px solid var(--fg); + background: var(--base); +} +.site-header .wrap { + display: flex; + align-items: stretch; + gap: clamp(1rem, 3vw, 2.4rem); + min-height: 58px; +} + .brand { font-family: var(--font-lockup); font-weight: 600; - font-size: 1.32rem; + font-size: 1.34rem; letter-spacing: 0.015em; display: inline-flex; - align-items: baseline; - gap: 0.08rem; + align-items: center; + gap: 0.06rem; white-space: nowrap; + color: var(--fg); } -.brand b { color: var(--accent); font-weight: 700; } -.brand .slash { color: var(--accent-2); } +.brand .slash { color: var(--accent); } .brand .blink { color: var(--accent); } @media (prefers-reduced-motion: no-preference) { - .brand .blink { animation: blink 1.15s steps(1) infinite; } - @keyframes blink { 50% { opacity: 0; } } + .brand .blink { animation: dmn-blink 1.15s steps(1) infinite; } +} + +/* The numbered nav. Each entry owns a hue and its index wears it, so the + bar reads as the colour legend the rest of the site keys back to rather + than as six grey words. The index keeps its space at rest — revealing it + on hover would reflow the whole bar every time the pointer crossed an + item. */ +.mast-nav { + display: flex; + align-items: stretch; + margin-left: auto; +} +.mast-nav a { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 clamp(0.6rem, 1.4vw, 1.05rem); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: var(--track-micro); + text-transform: uppercase; + color: var(--fg-dim); + transition: color 200ms ease; +} +.mast-nav a:hover, +.mast-nav a[aria-current='page'] { color: var(--fg); } +.mast-nav .n { + font-size: var(--step-micro); + color: var(--fg-faint); + transition: color 200ms ease; +} +.mast-nav a:hover .n, +.mast-nav a[aria-current='page'] .n { color: var(--acc); } +/* The rule wipes in from the left on hover and stays put when the entry is + the current page. */ +.mast-nav a::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 2px; + background: var(--acc); + transform: scaleX(0); + transform-origin: 0 50%; + transition: transform 260ms var(--ease-in); } -.header-spacer { flex: 1; } -.header-actions { display: flex; align-items: center; gap: 0.5rem; } +.mast-nav a:hover::after, +.mast-nav a[aria-current='page']::after { transform: scaleX(1); } +@media (max-width: 900px) { .mast-nav .lbl { display: none; } } +@media (max-width: 640px) { .mast-nav { display: none; } } +.mast-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: clamp(0.5rem, 1.5vw, 1rem); + border-left: 1px solid var(--rule); +} .icon-btn { - width: 38px; height: 38px; + width: 34px; height: 34px; display: grid; place-items: center; - border-radius: 10px; - border: 1px solid var(--glass-brd); - background: var(--glass-bg); + border: 1px solid var(--rule); color: var(--fg-dim); - transition: color 0.2s, border-color 0.2s, background 0.2s; + transition: color 200ms ease, border-color 220ms ease, background-color 240ms ease; } -.icon-btn:hover { color: var(--accent); border-color: var(--glass-brd-hi); } -.icon-btn svg { width: 18px; height: 18px; } +.icon-btn:hover { color: var(--base); background: var(--fg); border-color: var(--fg); } +.icon-btn svg { width: 17px; height: 17px; } .search-trigger { - display: inline-flex; align-items: center; gap: 0.6rem; - height: 38px; padding: 0 0.7rem 0 0.8rem; - border-radius: 10px; - border: 1px solid var(--glass-brd); - background: var(--glass-bg); + display: inline-flex; align-items: center; gap: 0.55rem; + height: 34px; padding: 0 0.6rem 0 0.7rem; + border: 1px solid var(--rule); color: var(--fg-faint); - font-size: 0.85rem; - transition: color 0.2s, border-color 0.2s; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + transition: color 200ms ease, border-color 220ms ease; } -.search-trigger:hover { color: var(--fg-dim); border-color: var(--glass-brd-hi); } +.search-trigger:hover { color: var(--fg); border-color: var(--rule-hi); } .search-trigger kbd { - font-family: var(--font-mono); font-size: 0.72rem; - padding: 0.1rem 0.36rem; border-radius: 6px; - border: 1px solid var(--glass-brd); color: var(--fg-faint); + font-family: var(--font-mono); font-size: var(--step-micro); + padding: 0.08rem 0.32rem; + border: 1px solid var(--rule); color: var(--fg-faint); } .search-trigger .st-label { display: none; } @media (min-width: 560px) { .search-trigger .st-label { display: inline; } } /* ---- Nav rail (desktop) -------------------------------------------------- */ -.layout { display: grid; grid-template-columns: 1fr; gap: 2rem; } +.layout { display: grid; grid-template-columns: 1fr; gap: 2.5rem; } @media (min-width: 960px) { .layout.has-rail { grid-template-columns: var(--rail) minmax(0, 1fr); } } -.rail { position: sticky; top: 70px; align-self: start; max-height: calc(100vh - 90px); overflow-y: auto; display: none; } +.rail { position: sticky; top: 78px; align-self: start; max-height: calc(100vh - 100px); overflow-y: auto; display: none; } @media (min-width: 960px) { .rail { display: block; } } .rail h4 { - font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.16em; - text-transform: uppercase; color: var(--fg-faint); margin: 1.1rem 0 0.5rem; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-wide); + text-transform: uppercase; color: var(--fg-faint); + margin: 1.3rem 0 0.6rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--rule); } .rail a { display: flex; align-items: center; gap: 0.5rem; - padding: 0.34rem 0.6rem; border-radius: 8px; - color: var(--fg-dim); font-size: 0.9rem; - border-left: 2px solid transparent; - transition: color 0.18s, background 0.18s, border-color 0.18s; -} -.rail a:hover { color: var(--fg); background: var(--glass-bg); } -.rail a[aria-current='page'] { color: var(--accent); border-left-color: var(--accent); background: var(--glass-bg); } -.rail .rail-tag { font-family: var(--font-mono); font-size: 0.64rem; color: var(--fg-faint); margin-left: auto; } - -/* ---- Buttons + badges + tags -------------------------------------------- */ -.btn { - display: inline-flex; align-items: center; gap: 0.5rem; - padding: 0.6rem 1.05rem; border-radius: 11px; - font-family: var(--font-mono); font-size: 0.85rem; letter-spacing: 0.02em; - border: 1px solid var(--glass-brd-hi); - background: var(--glass-bg); color: var(--fg); - transition: transform 0.2s var(--ease), border-color 0.2s, box-shadow 0.2s; -} -.btn:hover { transform: translateY(-2px); border-color: color-mix(in oklab, var(--accent) 50%, transparent); box-shadow: 0 6px 24px color-mix(in oklab, var(--accent) 18%, transparent); } -.btn--ghost { background: transparent; } + padding: 0.34rem 0 0.34rem 0.7rem; + color: var(--fg-dim); font-size: 0.88rem; + border-left: 2px solid var(--rule); + transition: color 180ms ease, border-color 180ms ease, padding-left 260ms var(--ease-in); +} +.rail a:hover { color: var(--fg); border-left-color: var(--fg); padding-left: 1rem; } +.rail a[aria-current='page'] { color: var(--accent); border-left-color: var(--accent); } +.rail .rail-tag { font-family: var(--font-mono); font-size: var(--step-micro); color: var(--fg-faint); margin-left: auto; } +/* ---- Badges + tags ------------------------------------------------------- */ .badge { display: inline-flex; align-items: center; gap: 0.35rem; - font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.06em; - padding: 0.2rem 0.5rem; border-radius: 999px; - border: 1px solid var(--glass-brd); color: var(--fg-dim); + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); + padding: 0.22rem 0.5rem; + border: 1px solid var(--rule); color: var(--fg-dim); text-transform: uppercase; } -.badge--beginner { color: var(--foam); border-color: color-mix(in oklab, var(--foam) 45%, transparent); } -.badge--intermediate { color: var(--gold); border-color: color-mix(in oklab, var(--gold) 45%, transparent); } -.badge--advanced { color: var(--love); border-color: color-mix(in oklab, var(--love) 45%, transparent); } +.badge svg { width: 12px; height: 12px; } +.badge--beginner { color: var(--foam); border-color: color-mix(in oklab, var(--foam) 50%, transparent); } +.badge--intermediate { color: var(--gold); border-color: color-mix(in oklab, var(--gold) 50%, transparent); } +.badge--advanced { color: var(--love); border-color: color-mix(in oklab, var(--love) 50%, transparent); } .tag-pill { - font-family: var(--font-mono); font-size: 0.72rem; - padding: 0.18rem 0.55rem; border-radius: 999px; - border: 1px solid var(--glass-brd); color: var(--fg-dim); - transition: color 0.18s, border-color 0.18s, background 0.18s; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); + text-transform: uppercase; + padding: 0.24rem 0.55rem; + border: 1px solid var(--rule); color: var(--fg-dim); + transition: color 180ms ease, border-color 180ms ease, background-color 220ms ease; } -.tag-pill:hover { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 50%, transparent); } +.tag-pill:hover { color: var(--base); background: var(--fg); border-color: var(--fg); } .tags-row { display: flex; flex-wrap: wrap; gap: 0.4rem; } -/* ---- Category / sheet cards --------------------------------------------- */ -.grid-cards { +/* ---- Domain plates ------------------------------------------------------- */ +/* The twelve domains, as tiles. A tile is a slab with its hue on the top + edge and nowhere else: twelve fully-coloured cards would be a paint + chart, and the point of the colour is to key the domain, not to fill + the grid. */ +.grid-plates { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr)); - gap: 1.1rem; -} -.card { display: flex; flex-direction: column; min-height: 100%; } -.card .pane__body { display: flex; flex-direction: column; gap: 0.55rem; flex: 1; } -.card-title { font-family: var(--font-display); font-size: var(--step-1); font-weight: 700; } -.card-desc { color: var(--fg-dim); font-size: 0.9rem; line-height: 1.5; } -.card-meta { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding-top: 0.4rem; } -.card-count { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-faint); } -.card-arrow { color: var(--acc, var(--accent)); transition: transform 0.25s var(--ease); } -.card:hover .card-arrow { transform: translateX(4px); } + grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); + gap: 1px; + background: var(--rule); + border: 1px solid var(--rule); +} +/* One-pixel gaps over an ink ground rather than real gutters: the grid + draws its own rules, so the twelve tiles read as one ruled table + instead of as twelve floating objects. */ +.plate-tile { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.15rem 1.2rem 1.25rem; + background: var(--base); + border-top: 2px solid var(--acc); + transition: background-color 240ms ease; +} +.plate-tile:hover { background: var(--wash-strong); } +.plate-tile__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.6rem; +} +.plate-tile__tag { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-wide); text-transform: uppercase; + color: var(--acc); +} +.plate-tile__n { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); color: var(--fg-faint); +} +.plate-tile__title { + font-family: var(--font-display); + font-size: 1.32rem; + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.03em; + line-height: 0.98; + text-transform: uppercase; + color: var(--fg); +} +.plate-tile__blurb { color: var(--fg-dim); font-size: 0.875rem; line-height: 1.5; } +.plate-tile__meta { + margin-top: auto; + padding-top: 0.7rem; + display: flex; align-items: center; justify-content: space-between; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + color: var(--fg-faint); +} +.plate-tile__arrow { color: var(--acc); transition: transform 250ms var(--ease); } +.plate-tile:hover .plate-tile__arrow { transform: translateX(4px); } + +/* ---- Record rows --------------------------------------------------------- */ +/* The archive's core object: number, kind mark, title, meta — one line, + not a card. A title set as a display heading gives every entry the + weight of a section and makes a list of fifty unscannable; the point of + an index is the shape of the whole list, and that only reads if a row + is a row. */ +.index { border-top: 1px solid var(--rule); } +.record { + display: block; + border-bottom: 1px solid var(--rule); + padding: 1.05rem clamp(0.6rem, 1.6vw, 1.3rem); + color: var(--fg); +} +.record:hover { color: var(--base); } +/* Every cell placed explicitly rather than left to auto-flow. Narrow, the + row is two lines — number, title and chevron across the top, kind + underneath — and at md it collapses to the single line the index is + really for. Auto-placement cannot express both: the title has to take + the full middle on mobile, and the moment it does the chevron is pushed + onto a line of its own. */ +.record__grid { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + column-gap: 1.05rem; + row-gap: 0.35rem; +} +@media (min-width: 768px) { + .record__grid { grid-template-columns: 42px 112px minmax(0, 1fr) 108px 18px; row-gap: 0; } +} +.record__n { + grid-column: 1; grid-row: 1; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + color: var(--accent); +} +.record:hover .record__n { color: rgba(250, 244, 237, 0.78); } +.record__kind { + grid-column: 2; grid-row: 2; + display: flex; align-items: center; gap: 0.45rem; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + color: var(--acc, var(--fg-faint)); +} +@media (min-width: 768px) { .record__kind { grid-row: 1; } } +/* On the hover ink the dawn accents sit at roughly 1:1, so every one of + them takes a cream weight rather than keeping a hue nobody could read. + /78 is the floor for the meta layer — cream on ink clears AA at /74 and + these are 10px, so they get a little over the line rather than sitting + on it. */ +.record:hover .record__kind, +.record:hover .record__meta { color: rgba(250, 244, 237, 0.78); } +.record__kind svg { width: 12px; height: 12px; } +.record__title { + grid-column: 2; grid-row: 1; + font-family: var(--font-display); + font-size: 1.02rem; + font-weight: 600; + letter-spacing: -0.015em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@media (min-width: 768px) { .record__title { grid-column: 3; } } +.record__meta { + display: none; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + color: var(--fg-faint); +} +@media (min-width: 768px) { .record__meta { display: block; grid-column: 4; grid-row: 1; } } +.record__arrow { + grid-column: 3; grid-row: 1; + color: var(--fg-faint); + transition: transform 250ms var(--ease); +} +@media (min-width: 768px) { .record__arrow { grid-column: 5; } } +.record:hover .record__arrow { color: rgba(250, 244, 237, 0.78); transform: translateX(3px); } +.record__arrow svg { width: 15px; height: 15px; } /* ---- Hero ---------------------------------------------------------------- */ -.hero { position: relative; margin-top: clamp(1.5rem, 4vw, 3rem); } -/* The hero is a plate: a solid Rosé Pine band on the cream page, with the - main site's offset poster-slab shadow. */ -.hero.plate { +/* The lockup over the ledger. Full-bleed cream with an ink rule under it, + which is the page's hardest value break and the reason the marquee + below needs no top edge of its own. */ +.hero { + position: relative; + overflow: hidden; + border-bottom: 1px solid var(--fg); + background: var(--surface); +} +.hero__inner { padding-top: clamp(1.6rem, 3.4vw, 2.6rem); } +/* Sized in `cqw` against the text column rather than in `vw` against the + window, so the lockup fills the space it actually has. In `vw` it is + sized against a viewport the column stops tracking at `--maxw`, and past + that width the type and the rule under it drift out of line with the + rest of the page. */ +.hero__lockup { container-type: inline-size; } +.hero__line { height: 18.1cqw; } +.hero__word { + font-family: var(--font-lockup); + font-size: 21cqw; + line-height: 0.86; + font-weight: 600; + letter-spacing: 0.012em; + color: var(--fg); +} +@media (prefers-reduced-motion: no-preference) { + /* The line keeps settling after the letters have landed: the glyphs + finish at 780ms, the tracking at 1500ms. */ + .hero__word { animation: dmn-track-in 1500ms var(--ease-in) both; } + .hero__glyph { animation: dmn-up 780ms var(--ease-in) both; } +} +.hero__glyph { + display: inline-block; + transition: color 220ms ease; +} +/* Six letters, six accents, taken under the pointer rather than worn at + rest — the lockup teaches the key the rest of the site is coloured by, + but only to someone who goes looking. */ +.hero__glyph:nth-child(1):hover { color: var(--pine); } +.hero__glyph:nth-child(2):hover { color: var(--iris); } +.hero__glyph:nth-child(3):hover { color: var(--love); } +.hero__glyph:nth-child(4):hover { color: var(--rose); } +.hero__glyph:nth-child(5):hover { color: var(--gold); } +.hero__glyph:nth-child(6):hover { color: var(--foam); } + +/* Ink rule. Timed to land as the last glyph settles — six glyphs at 54ms + plus the tail of the 780ms keyframe. Width in `cqw` for the same reason + the lockup is: a viewport-relative rule outgrows the column. */ +.hero__rule { + display: block; + height: 3px; + width: 64cqw; + margin-top: 0.6cqw; + transform-origin: 0 50%; + background: var(--pine); +} +@media (prefers-reduced-motion: no-preference) { + .hero__rule { animation: dmn-draw 720ms var(--ease-in) 780ms both; } +} +.hero__subline { height: 7cqw; margin-top: 0.9cqw; } +.hero__sub { + font-family: var(--font-display); + font-size: 5.5cqw; + line-height: 0.82; + font-weight: 700; + font-stretch: 62%; + letter-spacing: 0.045em; + text-transform: uppercase; + color: var(--fg); + transform: scaleY(1.5); +} +/* One gesture rather than letter by letter: it is a subtitle, and two + per-glyph runs in a row read as an effect rather than as an entrance. */ +.hero__sub > span { display: inline-block; } +@media (prefers-reduced-motion: no-preference) { + .hero__sub > span { animation: dmn-wipe-in 860ms cubic-bezier(0.22, 0.61, 0.36, 1) 380ms both; } +} + +.hero__ledger { + margin-top: clamp(1.4rem, 3vw, 2.4rem); + padding-bottom: clamp(2rem, 4vw, 3rem); +} +.hero__card { + display: grid; + gap: 1.6rem 2.6rem; + border: 1px solid var(--fg); + background: var(--base); + padding: clamp(1.3rem, 2.4vw, 1.9rem); +} +@media (min-width: 720px) { .hero__card { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } } +.hero__eyebrow { + font-family: var(--font-mono); font-size: 10.5px; + letter-spacing: 0.2em; text-transform: uppercase; color: var(--pine); +} +.hero__h2 { + margin-top: 0.85rem; + font-family: var(--font-display); + font-size: clamp(1.6rem, 2.5vw, 2.4rem); + line-height: 0.94; + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.03em; + text-transform: uppercase; +} +.hero__blurb { margin-top: 0.9rem; max-width: 46ch; color: var(--fg); font-size: 0.95rem; line-height: 1.5; } +.hero__actions { margin-top: 1.2rem; display: flex; flex-wrap: wrap; gap: 0.6rem; } + +.hero__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(94px, 1fr)); + gap: 1rem 1.3rem; + align-content: start; + border-top: 1px solid var(--fg); + padding-top: 1.1rem; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.6; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.hero__stat b { font-weight: 400; } +.hero__stat--iris b { color: var(--iris); } +.hero__stat--love b { color: var(--love); } +.hero__stat--gold b { color: var(--gold); } + +/* The shell readout beside the card. Borderless on purpose: the card is + the printed spec, this is the terminal output next to it, so the two + read as different registers rather than as two boxes. */ +.whoami { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; max-width: 58ch; } +.whoami__cmd { display: flex; align-items: center; gap: 0.5rem; color: var(--fg-faint); flex-wrap: wrap; } +.whoami__cmd .u { color: var(--pine); } +.whoami__cmd .c { color: var(--fg); } +.whoami__caret { + display: inline-block; width: 0.5em; height: 1.05em; + background: color-mix(in oklab, var(--fg) 80%, transparent); + transform: translateY(0.12em); +} +@media (prefers-reduced-motion: no-preference) { + .whoami__caret { animation: dmn-blink 1.1s steps(1, end) infinite; } +} +.whoami__rows { + margin-top: 0.9rem; + padding-top: 0.9rem; + border-top: 1px solid var(--rule-faint); + display: grid; + grid-template-columns: 4.2rem 1fr; + gap: 0.45rem 1.2rem; + color: var(--fg-dim); +} +.whoami__rows dt { color: var(--fg-faint); } +.whoami__rows dd { display: flex; align-items: center; gap: 0.5rem; } + +/* ---- Marquee band -------------------------------------------------------- */ +/* The index grammar run sideways. The band is a ground, not an accent — + see the `--band` note in tokens.css. + + The bottom edge is the one at risk: it meets the page's next section at + too close a value to carry itself, which is why the ink rule below is + load-bearing rather than decorative. Do not drop it. */ +.marquee { + position: relative; + overflow: hidden; + background: var(--band); + /* Ink, written out rather than taken from `--fg`: the band pins the + night palette for its contents, which would flip this rule to a light + one. The edge below meets the cream page and the black tabs meet the + section under them, and in both cases it is a *dark* rule that keeps + the seam from bleeding. */ + border-bottom: 1px solid #575279; + --marquee-fg: #faf4ed; + /* /78, not /62. Cream on this band clears AA at /74, and these are the + smallest words on the page *and* the only ones in motion, so they get + a little over the line rather than sitting on it. At /62 the index + numbers and sheet counts were legible in a still frame and mush while + the band was moving. */ + --marquee-dim: rgba(250, 244, 237, 0.78); + + /* Pine, lifted for this band only. + Every other night accent clears its floor on `--band`: iris 4.75, + foam 5.83, gold 6.06, rose 5.88, love 3.42 — all at or above the 3:1 + large-text floor the display titles need. Published night pine + (#3e8fb0) lands at 2.73 and is the one that fails, because it is the + accent nearest the ground in both hue and value. + #6fb3d0 is the same hue walked up until it clears with headroom + (4.28:1) while staying visibly pine rather than sliding into foam, + which sits beside it in the rotation and would collapse two domains + onto one colour. */ + --pine: #6fb3d0; +} +.marquee__track { + display: flex; + width: max-content; + will-change: transform; +} +/* Two identical runs translated as one unit to -50%, which lands the + second exactly where the first started — the reset is invisible. */ +.marquee__run { + display: flex; + flex: none; + align-items: center; +} +.marquee__item { + display: flex; + align-items: baseline; + gap: 0.7rem; + padding: 0.95rem clamp(1.1rem, 2.4vw, 2rem); + white-space: nowrap; + font-family: var(--font-mono); + /* 11px, not the 10px `--step-micro` every other label on the site uses. + This is the one place the furniture type is in motion, and a word + sliding past at 70px/s needs the extra pixel that a static eyebrow + does not. */ + font-size: 11px; + letter-spacing: var(--track-micro); + text-transform: uppercase; + color: var(--marquee-fg); +} +.marquee__n { color: var(--marquee-dim); } +/* The kind *word* stays cream and only its icon takes the hue. Colouring + both put three competing colours in every entry, which is what made the + band read as noise rather than as an index. */ +.marquee__kind { color: var(--marquee-fg); display: inline-flex; align-items: center; gap: 0.4rem; } +.marquee__kind svg { width: 11px; height: 11px; color: var(--acc); } +/* The credential in display type, the qualifier trailing in the date slot + — record-row grammar, horizontally. */ +.marquee__title { + font-family: var(--font-display); + font-size: 1.06rem; + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.02em; + text-transform: uppercase; + color: var(--acc); +} +.marquee__qual { color: var(--marquee-dim); } +@media (max-width: 720px) { .marquee__qual { display: none; } } + +/* The band's own tab — `^:` survives in exactly one place. It is the + banner's self-naming mark, and the band names itself once; repeated on + every record it was furniture. The tab counts its records, because every + index on this site counts itself. */ +.marquee__tab { + position: absolute; + top: 0; bottom: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 0.5rem; + padding-inline: clamp(0.9rem, 2.2vw, 1.5rem); + /* The tabs are their own ground: Rosé Pine's black, which is the page's + furniture ink rather than another colour competing with the band. The + band pins the night palette (`.plate`), so `--base` is that black in + both modes. */ background: var(--base); - border-color: rgba(224, 222, 244, 0.12); - box-shadow: 6px 6px 0 rgba(78, 57, 105, 0.16), var(--glass-shadow); -} -:root[data-theme='dark'] .hero.plate { box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.4), var(--glass-shadow); } -.hero .pane__body { padding: clamp(1.6rem, 4vw, 3.2rem); } -.hero-grid { display: grid; gap: 2rem; } -@media (min-width: 880px) { .hero-grid { grid-template-columns: 1.35fr 1fr; align-items: center; } } -.hero-title { - font-size: var(--step-4); line-height: 0.98; letter-spacing: -0.02em; - font-weight: 700; text-wrap: balance; -} -.hero-title .hl { color: var(--accent); } -.hero-title .hl2 { color: var(--accent-2); } -.hero-sub { color: var(--fg-dim); font-size: var(--step-1); max-width: 46ch; margin-top: 1rem; } -.hero-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 1.6rem; } -.hero-stats { display: flex; gap: 1.6rem; margin-top: 1.8rem; flex-wrap: wrap; } -.hero-stat b { font-family: var(--font-display); font-size: var(--step-2); color: var(--fg); display: block; line-height: 1; } -.hero-stat span { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--fg-faint); } - -/* the mock terminal on the hero side */ -.term { font-family: var(--font-mono); font-size: 0.84rem; line-height: 1.7; } -.term .line { white-space: pre-wrap; word-break: break-word; } -.term .p { color: var(--accent); } -.term .c { color: var(--fg); } -.term .o { color: var(--fg-faint); } -.term .k { color: var(--accent-2); } -.term .cursor { display: inline-block; width: 0.55em; height: 1.05em; background: var(--accent); vertical-align: -0.16em; } -@media (prefers-reduced-motion: no-preference) { .term .cursor { animation: blink 1.15s steps(1) infinite; } } + color: #faf4ed; + font-family: var(--font-mono); + font-size: var(--step-micro); + letter-spacing: var(--track-wide); + text-transform: uppercase; +} +.marquee__tab--l { left: 0; } +.marquee__tab--r { right: 0; } +@media (max-width: 640px) { .marquee__tab--r { display: none; } } +.marquee__tab .dot { color: #9ccfd8; } + +/* ---- Section banner ------------------------------------------------------ */ +/* Dark plate, live noise field, left scrim. Legibility on the plate is + carried by two layers, not one: the canvas erases its own top and bottom + bands, AND this gradient sits over the noise, under the content. */ +.banner { + position: relative; + overflow: hidden; + background: var(--plate); + border-bottom: 1px solid var(--fg); +} +.banner__field { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } +.banner__scrim { + position: absolute; + top: 0; bottom: 0; left: 0; + width: 68%; + pointer-events: none; + background: linear-gradient( + 90deg, + rgb(var(--plate-scrim) / 0.95) 0%, + rgb(var(--plate-scrim) / 0.86) 52%, + rgb(var(--plate-scrim) / 0) 100% + ); +} +.banner__body { + position: relative; + z-index: 2; + display: grid; + gap: 1.8rem 2.6rem; + align-items: end; + padding-block: clamp(1.5rem, 3.2vw, 2.2rem); +} +@media (min-width: 900px) { .banner__body { grid-template-columns: minmax(0, 1fr) 280px; } } +.banner__title { + margin-top: 0.85rem; + font-family: var(--font-display); + font-size: clamp(1.9rem, 4.2vw, 3.2rem); + line-height: 0.92; + font-weight: 700; + font-stretch: 82%; + letter-spacing: -0.035em; + text-transform: uppercase; + color: var(--fg); +} +.banner__blurb { margin-top: 0.85rem; max-width: 58ch; color: var(--fg-dim); font-size: 0.95rem; line-height: 1.55; } +.banner__ledger { + display: grid; + gap: 0.55rem; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: var(--track-micro); + text-transform: uppercase; +} +.banner__row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--rule); + color: var(--fg-faint); +} +.banner__row b { font-weight: 400; color: var(--fg); } +.banner__row--acc b { color: var(--acc, var(--accent)); } /* ---- Section headers ----------------------------------------------------- */ -.section { margin-top: clamp(2.6rem, 6vw, 4.5rem); } -.section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.3rem; } -.section-head h2 { font-size: var(--step-2); } -.section-head .eyebrow { margin-bottom: 0.4rem; } +.section { margin-top: clamp(2.4rem, 5.5vw, 4rem); } +.section:last-child { padding-bottom: clamp(2.5rem, 6vw, 4.5rem); } +.section-head { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 1rem; margin-bottom: 1.2rem; +} +.section-head h2 { + margin-top: 0.6rem; + font-family: var(--font-display); + font-size: clamp(1.5rem, 2.8vw, 2.2rem); + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.03em; + line-height: 0.94; + text-transform: uppercase; +} + +/* ---- Payloads call-to-action band ---------------------------------------- */ +.cta-band { + display: block; + background: var(--band-ledger); + border-block: 1px solid var(--fg); + color: #faf4ed; +} +.cta-band__body { + display: flex; align-items: center; justify-content: space-between; + gap: 1.6rem; flex-wrap: wrap; + padding-block: clamp(1.5rem, 3.2vw, 2.3rem); +} +.cta-band__eyebrow { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-wide); text-transform: uppercase; + color: rgba(250, 244, 237, 0.72); +} +.cta-band h2 { + margin-top: 0.6rem; + font-family: var(--font-display); + font-size: clamp(1.7rem, 3.4vw, 2.6rem); + font-weight: 700; font-stretch: 82%; + letter-spacing: -0.035em; line-height: 0.94; + text-transform: uppercase; + color: #faf4ed; +} +.cta-band p { margin-top: 0.7rem; max-width: 54ch; color: rgba(250, 244, 237, 0.85); font-size: 0.93rem; line-height: 1.55; } +.cta-band .btn { + background: transparent; color: #faf4ed; + border-color: rgba(250, 244, 237, 0.5); + flex: none; +} +.cta-band:hover .btn { background: #faf4ed; color: var(--band-ledger); border-color: #faf4ed; } /* ---- Sheet page ---------------------------------------------------------- */ -.sheet-head { margin-top: 1.5rem; } -.breadcrumb { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-faint); display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; } +/* Same measure as the body below it, so the title, the rule under the + eyebrow and the prose all start and end on the same two lines. */ +.sheet-head { margin-top: 1.6rem; max-width: 1080px; } +.breadcrumb { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + color: var(--fg-faint); + display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; +} .breadcrumb a:hover { color: var(--accent); } -.breadcrumb .sep { color: var(--fg-faint); opacity: 0.5; } -.sheet-title { font-size: var(--step-3); margin-top: 0.8rem; } -.sheet-meta { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.9rem; } -.sheet-desc { color: var(--fg-dim); font-size: var(--step-1); margin-top: 0.9rem; max-width: 70ch; } +.breadcrumb .sep { opacity: 0.45; } +.sheet-title { + margin-top: 0.9rem; + font-family: var(--font-display); + font-size: clamp(1.9rem, 4vw, 3rem); + font-weight: 700; font-stretch: 84%; + letter-spacing: -0.035em; line-height: 0.94; + text-transform: uppercase; +} +.sheet-meta { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 1rem; } +.sheet-desc { color: var(--fg-dim); font-size: var(--step-1); margin-top: 1rem; max-width: 70ch; } -.sheet-layout { display: grid; grid-template-columns: 1fr; gap: 2rem; margin-top: 2rem; } +/* The reading measure, not the archive measure. `--maxw` is 1440px because + the index pages are built out of full-bleed bands and ruled tables; a + reading column laid out on it leaves a dead third between the prose and + the contents rail. The sheet pages get their own, narrower one. */ +.sheet-layout { + display: grid; grid-template-columns: 1fr; + gap: 2.5rem; margin-top: 2.2rem; + max-width: 1080px; +} @media (min-width: 1040px) { .sheet-layout { grid-template-columns: minmax(0, 1fr) 220px; } } -.toc { position: sticky; top: 70px; align-self: start; max-height: calc(100vh - 90px); overflow-y: auto; display: none; } +.toc { position: sticky; top: 78px; align-self: start; max-height: calc(100vh - 100px); overflow-y: auto; display: none; } @media (min-width: 1040px) { .toc { display: block; } } -.toc h4 { font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fg-faint); margin-bottom: 0.6rem; } -.toc a { display: block; padding: 0.24rem 0.6rem; border-left: 2px solid var(--glass-brd); color: var(--fg-faint); font-size: 0.82rem; line-height: 1.4; transition: color 0.18s, border-color 0.18s; } +.toc h4 { + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-wide); text-transform: uppercase; + color: var(--fg-faint); margin-bottom: 0.7rem; + padding-bottom: 0.5rem; border-bottom: 1px solid var(--rule); +} +.toc a { + display: block; padding: 0.26rem 0 0.26rem 0.7rem; + border-left: 2px solid var(--rule); + color: var(--fg-faint); font-size: 0.82rem; line-height: 1.4; + transition: color 180ms ease, border-color 180ms ease; +} .toc a:hover, .toc a.active { color: var(--accent); border-left-color: var(--accent); } -.toc a.lvl-3 { padding-left: 1.2rem; font-size: 0.78rem; } +.toc a.lvl-3 { padding-left: 1.3rem; font-size: 0.78rem; } -.prevnext { display: grid; gap: 1rem; margin-top: 2.5rem; } +.prevnext { display: grid; gap: 1px; margin-top: 3rem; background: var(--rule); border: 1px solid var(--rule); } @media (min-width: 620px) { .prevnext { grid-template-columns: 1fr 1fr; } } -.prevnext a { padding: 1rem 1.1rem; } -.prevnext .dir { font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-faint); letter-spacing: 0.1em; text-transform: uppercase; } +.prevnext a { padding: 1.1rem 1.2rem; background: var(--base); transition: background-color 240ms ease; } +.prevnext a:hover { background: var(--wash-strong); } +.prevnext .dir { + font-family: var(--font-mono); font-size: var(--step-micro); + color: var(--fg-faint); letter-spacing: var(--track-wide); text-transform: uppercase; +} .prevnext .nx { text-align: right; } -.prevnext strong { display: block; margin-top: 0.3rem; color: var(--fg); font-family: var(--font-display); } +.prevnext strong { + display: block; margin-top: 0.4rem; color: var(--fg); + font-family: var(--font-display); font-weight: 600; letter-spacing: -0.015em; +} /* ---- Footer -------------------------------------------------------------- */ -.site-footer { margin-top: clamp(3rem, 8vw, 6rem); border-top: 1px solid var(--glass-brd); padding: 2.4rem 0; color: var(--fg-faint); } -/* Footer is a plate too — a dark band closing the cream page. */ -.site-footer.plate { background: var(--base); border-top-color: rgba(224, 222, 244, 0.1); } -.site-footer .wrap { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: center; justify-content: space-between; } +/* A dark band closing the cream page — the same object as the marquee and + the banners, in its last position. */ +.site-footer { + position: relative; + overflow: hidden; + margin-top: clamp(3rem, 7vw, 5.5rem); + border-top: 1px solid var(--fg); + padding-block: clamp(2rem, 4vw, 3rem); + background: var(--base); + color: var(--fg-dim); +} +.site-footer .wrap { + display: grid; gap: 1.6rem 2.5rem; + align-items: start; +} +@media (min-width: 760px) { .site-footer .wrap { grid-template-columns: minmax(0, 1fr) auto; } } .site-footer a:hover { color: var(--accent); } -.footer-note { font-size: 0.82rem; max-width: 60ch; line-height: 1.5; } -.footer-mono { font-family: var(--font-mono); font-size: 0.76rem; letter-spacing: 0.04em; } +.footer-note { font-size: 0.86rem; max-width: 56ch; line-height: 1.55; margin-top: 0.8rem; } +.footer-mono { + display: block; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; + line-height: 1.9; +} +@media (min-width: 760px) { .footer-col { text-align: right; } } /* ---- PDF embed ----------------------------------------------------------- */ -.pdf-frame { width: 100%; height: min(80vh, 900px); border: 0; border-radius: var(--radius-sm); background: var(--code-bg); } +.pdf-frame { width: 100%; height: min(80vh, 900px); border: 1px solid var(--rule); background: var(--code-bg); } /* ---- Empty / utility ----------------------------------------------------- */ .muted { color: var(--fg-faint); } @@ -267,78 +860,57 @@ h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 700; line-height .mono { font-family: var(--font-mono); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -/* ---- Header nav ---------------------------------------------------------- */ -.header-nav { display: flex; align-items: center; gap: 1rem; margin-left: 0.4rem; } -.header-nav a { - font-family: var(--font-mono); - font-size: 0.86rem; +/* ---- Category chips ------------------------------------------------------ */ +.cat-nav { display: flex; flex-wrap: wrap; gap: 1px; background: var(--rule); border: 1px solid var(--rule); } +.cat-chip { + display: inline-flex; align-items: center; gap: 0.45rem; + padding: 0.5rem 0.8rem; + background: var(--base); + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; color: var(--fg-dim); - padding: 0.2rem 0; - border-bottom: 1.5px solid transparent; - transition: color 0.18s, border-color 0.18s; + transition: background-color 220ms ease, color 200ms ease; } -.header-nav a:hover { color: var(--accent); } -.header-nav a[aria-current='page'] { color: var(--fg); border-color: var(--accent); } -@media (max-width: 560px) { .header-nav { display: none; } } +.cat-chip:hover { background: var(--wash-strong); color: var(--fg); } +.cat-chip__tag { color: var(--acc); } +.cat-chip[aria-current='page'] { background: var(--acc); color: var(--base); } +.cat-chip[aria-current='page'] .cat-chip__tag { color: var(--base); } -/* ---- Payloads attribution banner + mirror badge -------------------------- */ +/* ---- PayloadsAllTheThings attribution ------------------------------------ */ .patt-credit { - display: flex; - align-items: center; - gap: 0.7rem; - flex-wrap: wrap; - margin: 0.2rem 0 1.6rem; - padding: 0.6rem 0.85rem; - border: 1px solid var(--glass-brd); + display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; + margin: 0.2rem 0 1.8rem; + padding: 0.7rem 0.9rem; + border: 1px solid var(--rule); border-left: 2px solid var(--iris); - border-radius: var(--radius-sm, 8px); - background: color-mix(in oklab, var(--iris) 8%, var(--glass-bg)); - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); - font-size: 0.84rem; + background: var(--wash); + font-size: 0.86rem; } .patt-badge { - font-family: var(--font-mono); - font-size: 0.66rem; - letter-spacing: 0.14em; - text-transform: uppercase; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; color: var(--iris); border: 1px solid color-mix(in oklab, var(--iris) 50%, transparent); - border-radius: 4px; - padding: 0.12rem 0.4rem; + padding: 0.14rem 0.4rem; flex: none; } .patt-credit__text { color: var(--fg-dim); line-height: 1.5; } .patt-credit__text a { color: var(--iris); border-bottom: 1px solid color-mix(in oklab, var(--iris) 40%, transparent); } .patt-credit__text a:hover { border-color: var(--iris); } -.patt-lic { display: inline-block; margin-left: 0.5rem; color: var(--fg-faint); font-family: var(--font-mono); font-size: 0.76rem; } +.patt-lic { display: inline-block; margin-left: 0.5rem; color: var(--fg-faint); font-family: var(--font-mono); font-size: 0.78rem; } .patt-lic code { background: none; color: var(--fg-faint); } .patt-src { margin-left: auto; - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-family: var(--font-mono); - font-size: 0.8rem; + display: inline-flex; align-items: center; gap: 0.35rem; + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; color: var(--fg-dim); - padding: 0.3rem 0.6rem; - border: 1px solid var(--glass-brd); - border-radius: 7px; - transition: color 0.18s, border-color 0.18s; + padding: 0.32rem 0.6rem; + border: 1px solid var(--rule); + transition: color 180ms ease, border-color 180ms ease; } .patt-src:hover { color: var(--iris); border-color: color-mix(in oklab, var(--iris) 50%, transparent); } -.payloads-cta { display: block; text-decoration: none; } -.payloads-cta.plate { background: var(--base); border-color: rgba(224, 222, 244, 0.12); box-shadow: 5px 5px 0 rgba(78, 57, 105, 0.14), var(--glass-shadow); } -:root[data-theme='dark'] .payloads-cta.plate { box-shadow: 5px 5px 0 rgba(0, 0, 0, 0.35), var(--glass-shadow); } -.payloads-cta__body { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1.5rem; - flex-wrap: wrap; -} - /* ---- Theme toggle: circular-reveal wipe ---------------------------------- */ /* Scoped to .theme-vt so page navigations keep Astro's default crossfade. The toggle sets --vt-x/--vt-y (origin) and --vt-r (end radius) in JS. */ diff --git a/src/styles/prose.css b/src/styles/prose.css @@ -1,30 +1,69 @@ /* ============================================================================ Prose — how a rendered cheatsheet reads. - Code blocks become terminal panes (enhanced by enhance-code.ts). + + The body is paper. The code is not: every block is a dark plate, in both + modes, exactly as the main site sets them. That is the page's core + rhythm — terminal output pasted into a printed document — and it is why + the blocks do not follow the theme. A code block that turns cream in + light mode stops being a terminal and becomes a quotation. ========================================================================== */ -.prose { color: var(--fg); font-size: var(--step-0); line-height: 1.68; max-width: 74ch; } -.prose > * + * { margin-top: 1.05rem; } +.prose { color: var(--fg); font-size: 17.5px; line-height: 1.68; letter-spacing: -0.003em; max-width: 78ch; } +.prose > * + * { margin-top: 1.15rem; } -.prose h2, .prose h3, .prose h4 { font-family: var(--font-display); line-height: 1.15; scroll-margin-top: 84px; } +.prose h2, .prose h3, .prose h4 { font-family: var(--font-display); scroll-margin-top: 92px; } .prose h2 { - font-size: var(--step-2); margin-top: 2.4rem; padding-bottom: 0.4rem; - border-bottom: 1px solid var(--glass-brd); + font-size: clamp(1.35rem, 2.2vw, 1.75rem); + font-weight: 700; + font-stretch: 84%; + letter-spacing: -0.03em; + line-height: 1; + text-transform: uppercase; + margin-top: 2.6rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--fg); +} +.prose h3 { + font-size: 1.18rem; font-weight: 700; letter-spacing: -0.02em; + line-height: 1.15; margin-top: 2rem; color: var(--fg); +} +.prose h4 { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: var(--track-micro); text-transform: uppercase; + margin-top: 1.6rem; color: var(--fg-faint); } -.prose h2::before { content: '// '; color: var(--accent); font-family: var(--font-mono); font-size: 0.7em; opacity: 0.8; } -.prose h3 { font-size: var(--step-1); margin-top: 1.8rem; color: var(--fg); } -.prose h4 { font-size: 1.02rem; margin-top: 1.4rem; color: var(--fg-dim); } -.prose p { color: var(--fg); } +.prose p { color: var(--fg); text-wrap: pretty; } .prose strong { color: var(--fg); font-weight: 600; } .prose em { color: var(--fg-dim); } +/* ---- Drop cap ------------------------------------------------------------ */ +/* The opening letter of a sheet, floated three lines deep in EB Garamond. + Set on a span rather than with `::first-letter` for two reasons: the + pseudo-element cannot be given a different family and weight reliably + across engines at this size, and — the real one — the letter stays real + selectable, searchable text this way, read as part of the word it opens + rather than as decoration sitting beside it. + + The rehype pass that splits it out (see `rehypeDropCap` in + astro.config.mjs) skips short openers, so an image caption or a + one-line note is left alone. */ +.prose .cap { + float: left; + font-family: var(--font-lockup); + font-size: 72px; + font-weight: 600; + line-height: 0.74; + color: var(--love); + margin: 5px 0.85rem 0 0; +} + .prose a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent); text-underline-offset: 3px; - transition: text-decoration-color 0.2s; + transition: text-decoration-color 200ms ease; } .prose a:hover { text-decoration-color: var(--accent); } @@ -33,108 +72,100 @@ .prose ul li::marker { color: var(--accent); } .prose ol li::marker { color: var(--fg-faint); font-family: var(--font-mono); } -.prose hr { border: 0; border-top: 1px solid var(--glass-brd); margin: 2rem 0; } +.prose hr { border: 0; border-top: 1px solid var(--rule); margin: 2.4rem 0; } -/* Inline code */ +/* Inline code — a tinted slug on the page, square, hairline-bordered. This + is the one place the paper carries code, and it stays paper-coloured; + the dark treatment is reserved for blocks. */ .prose :not(pre) > code { font-family: var(--font-mono); font-size: 0.86em; - padding: 0.12em 0.4em; - border-radius: 6px; + padding: 0.14em 0.4em; background: var(--code-bg); - border: 1px solid var(--glass-brd); + border: 1px solid var(--rule); color: var(--rose); word-break: break-word; } /* Blockquotes / callouts */ .prose blockquote { - border-left: 3px solid var(--accent-2); - background: var(--glass-bg); - padding: 0.8rem 1.1rem; - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + border-left: 2px solid var(--accent-2); + background: var(--wash); + padding: 0.9rem 1.1rem; color: var(--fg-dim); } .prose blockquote p { margin-top: 0.4rem; } .prose blockquote > :first-child { margin-top: 0; } -/* Tables */ -.prose .table-scroll { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--glass-brd); } +/* Tables — a ruled table, not a card with a table in it. */ +.prose .table-scroll { overflow-x: auto; border: 1px solid var(--rule); } .prose table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } .prose thead th { - background: var(--glass-bg-strong); - font-family: var(--font-mono); font-size: 0.76rem; letter-spacing: 0.04em; + background: var(--wash-strong); + font-family: var(--font-mono); font-size: var(--step-micro); + letter-spacing: var(--track-micro); text-transform: uppercase; color: var(--fg-dim); - text-align: left; padding: 0.6rem 0.8rem; white-space: nowrap; + text-align: left; padding: 0.62rem 0.8rem; white-space: nowrap; + border-bottom: 1px solid var(--rule); } -.prose tbody td { padding: 0.55rem 0.8rem; border-top: 1px solid var(--glass-brd); vertical-align: top; } -.prose tbody tr:hover { background: var(--glass-bg); } +.prose tbody td { padding: 0.58rem 0.8rem; border-top: 1px solid var(--rule); vertical-align: top; } +.prose tbody tr:hover { background: var(--wash); } -.prose img { border-radius: var(--radius-sm); border: 1px solid var(--glass-brd); margin-inline: auto; } +.prose img { border: 1px solid var(--rule); margin-inline: auto; } -/* ---- Code panes ---------------------------------------------------------- */ +/* ---- Code blocks --------------------------------------------------------- */ +/* A figure on the plate, with its caption above the code: the language at + the left, the copy control at the right, both in the 9.5px mono the rest + of the site labels things in. No window chrome, no traffic-light dots — + this is a printed listing of terminal output, not a simulated window. */ .code-pane { position: relative; - border: 1px solid var(--glass-brd); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--code-bg); - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); + background: var(--plate); + padding: 1.25rem 1.4rem 1.4rem; + /* Runs past the prose measure at wide viewports, because a fixed-width + terminal listing is the one thing on the page that genuinely needs + more than 74ch and wraps badly under it. */ + max-width: min(100%, 92ch); } .code-pane__bar { - display: flex; align-items: center; gap: 0.55rem; - padding: 0.4rem 0.7rem; - border-bottom: 1px solid var(--glass-brd); - background: linear-gradient(180deg, rgba(255,255,255,0.03), transparent); + display: flex; align-items: center; justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; } -.code-pane__dots { display: inline-flex; gap: 0.3rem; } -.code-pane__dots i { width: 8px; height: 8px; border-radius: 50%; display: block; } -.code-pane__dots i:nth-child(1) { background: var(--love); } -.code-pane__dots i:nth-child(2) { background: var(--gold); } -.code-pane__dots i:nth-child(3) { background: var(--foam); } .code-pane__lang { - font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.1em; - text-transform: uppercase; color: var(--fg-faint); margin-left: 0.2rem; + font-family: var(--font-mono); font-size: 9.5px; + letter-spacing: 0.18em; text-transform: uppercase; + color: #c4a7e7; } .code-copy { - margin-left: auto; - font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--fg-faint); - padding: 0.2rem 0.5rem; border-radius: 6px; - border: 1px solid var(--glass-brd); - transition: color 0.18s, border-color 0.18s; + display: inline-flex; align-items: center; gap: 0.45rem; + font-family: var(--font-mono); font-size: 9.5px; + letter-spacing: 0.18em; text-transform: uppercase; + color: #9ccfd8; + transition: color 200ms ease; } -.code-copy:hover { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 50%, transparent); } -.code-copy.copied { color: var(--foam); border-color: var(--foam); } +.code-copy:hover { color: #e0def4; } +.code-copy.copied { color: #f6c177; } +.code-copy svg { width: 11px; height: 11px; } .prose pre.astro-code { margin: 0; - padding: 0.95rem 1.1rem; + padding: 0; overflow-x: auto; - font-size: 0.84rem; - line-height: 1.6; + font-size: 13px; + line-height: 1.7; background: transparent !important; + color: #e0def4; } -.prose pre.astro-code code { font-family: var(--font-mono); display: block; } - -/* Shiki dual-theme: defaultColor:false -> flip on [data-theme] */ -:root .astro-code, -:root .astro-code span, -:root[data-theme='dark'] .astro-code, -:root[data-theme='dark'] .astro-code span { - color: var(--shiki-dark); - font-style: var(--shiki-dark-font-style); - font-weight: var(--shiki-dark-font-weight); - text-decoration: var(--shiki-dark-text-decoration); -} -:root[data-theme='light'] .astro-code, -:root[data-theme='light'] .astro-code span { - color: var(--shiki-light); - font-style: var(--shiki-light-font-style); - font-weight: var(--shiki-light-font-weight); - text-decoration: var(--shiki-light-text-decoration); +.prose pre.astro-code code { + font-family: var(--font-mono); + display: block; + /* Wrapped rather than scrolled: a cheatsheet's value is the whole + command being visible and copyable at a glance, and a long + `--script-args` tail hidden behind a horizontal scrollbar is the one + thing most likely to be pasted wrong. */ + white-space: pre-wrap; + word-break: break-word; } /* First heading of the doc is the page title (rendered separately) */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css @@ -4,6 +4,23 @@ (night). Type is Archivo + IBM Plex Mono, with an EB Garamond lockup for the wordmark. Palette values are the contrast-tuned DÆMON dawn/night tokens. The theme attribute is set on <html> by an inline head script before paint. + + ── Form language ── + The site is an editorial-brutalist paper archive, not a glass UI. Three + rules follow from that and everything here serves them: + + 1. Zero radius. Corners are square, everywhere, including images and + inputs. `--radius*` survive only so that consumers written against + them keep compiling; they all resolve to 0. + 2. No blur, no glow, no drop shadow. Structure is carried by 1px ink + rules (`--rule`), flat panels (`.slab`), and the grain film. + 3. Colour separates by *value*, not by translucency. A panel differs + from its page because it is a different ink, not because it is + frosted. + + Alpha still belongs on borders and washes. It does NOT belong on text: + use the ink / subtle / muted ramp, which is tuned to stay legible at + every step in both modes. ========================================================================== */ /* ---- Fonts (self-hosted, base-aware via Vite) ---------------------------- */ @@ -35,6 +52,18 @@ --hl-med: #dfdad9; --hl-high: #cecacd; + /* The published Rosé Pine Dawn pastels, kept for decoration — washes, + rules, noise bands, anything that is a shape rather than a word. On + cream they run from gold at 2.05:1 to love at 3.84:1, all under the + 4.5:1 floor, so never put text in them here. They are what the theme + actually looks like: use them as ground and put ink on top. */ + --pine-soft: #286983; + --foam-soft: #56949f; + --gold-soft: #ea9d34; + --love-soft: #b4637a; + --iris-soft: #907aa9; + --rose-soft: #d7827e; + /* semantic */ --bg: var(--base); --fg: var(--text); @@ -43,17 +72,36 @@ --accent: var(--iris); --accent-2: var(--pine); - /* glass — light frosted paper */ - --glass-bg: rgba(255, 250, 243, 0.72); - --glass-bg-strong: rgba(255, 250, 243, 0.9); - --glass-brd: rgba(87, 82, 121, 0.14); - --glass-brd-hi: rgba(87, 82, 121, 0.26); - --glass-blur: 16px; - --glass-shadow: 0 10px 34px rgba(87, 82, 121, 0.13); - --grid-line: rgba(87, 82, 121, 0.05); - --scan: rgba(121, 90, 162, 0.04); - --code-bg: rgba(242, 233, 225, 0.72); + /* Structure. `--rule` is the hairline every edge on the site is drawn + with; `--rule-hi` is the same ink at the weight a hovered or active + edge takes. `--wash` is the one permitted fill that is not a flat + colour — a paper tint for table stripes and hover grounds. */ + --rule: rgba(87, 82, 121, 0.22); + --rule-hi: rgba(87, 82, 121, 0.48); + --rule-faint: rgba(87, 82, 121, 0.12); + --wash: rgba(242, 233, 225, 0.6); + --wash-strong: #f2e9e1; + + --code-bg: #f2e9e1; --selection: rgba(121, 90, 162, 0.22); + + /* The grain film's weight on paper. Multiply, because on a light ground + noise has to be laid *into* the page — added to it, it is invisible. */ + --grain-blend: multiply; + --grain-opacity: 0.1; + + /* The banners' signal field, resolved from CSS at draw time so the + canvas follows the mode. On paper there is no light to add, so the + bands are multiplied in at lower gain (multiply bites harder than + `lighter` at the same alpha) using the published dawn hues. */ + --fuzz-blend: multiply; + --fuzz-gain: 0.55; + --fuzz-band-1: 144 122 169; /* iris-soft */ + --fuzz-band-2: 86 148 159; /* foam-soft */ + --fuzz-band-3: 180 99 122; /* love-soft */ + --fuzz-fil-1: 87 82 121; /* ink */ + --fuzz-fil-2: 86 148 159; + --fuzz-fil-3: 144 122 169; } /* ---- Dark = Rosé Pine (night) ------------------------------------------- */ @@ -74,6 +122,15 @@ --hl-med: #403d52; --hl-high: #524f67; + /* On the dark side the published accents already clear their floors as + text, so `-soft` is the same value rather than a separate pastel. */ + --pine-soft: #3e8fb0; + --foam-soft: #9ccfd8; + --gold-soft: #f6c177; + --love-soft: #eb6f92; + --iris-soft: #c4a7e7; + --rose-soft: #ebbcba; + --bg: var(--base); --fg: var(--text); --fg-dim: var(--subtle); @@ -81,16 +138,54 @@ --accent: var(--iris); --accent-2: var(--foam); - --glass-bg: rgba(31, 29, 46, 0.55); - --glass-bg-strong: rgba(38, 35, 58, 0.78); - --glass-brd: rgba(224, 222, 244, 0.10); - --glass-brd-hi: rgba(224, 222, 244, 0.18); - --glass-blur: 18px; - --glass-shadow: 0 8px 40px rgba(0, 0, 0, 0.45); - --grid-line: rgba(224, 222, 244, 0.045); - --scan: rgba(196, 167, 231, 0.05); - --code-bg: rgba(25, 23, 36, 0.66); + --rule: rgba(224, 222, 244, 0.16); + --rule-hi: rgba(224, 222, 244, 0.38); + --rule-faint: rgba(224, 222, 244, 0.08); + --wash: rgba(224, 222, 244, 0.05); + --wash-strong: #26233a; + + --code-bg: #131019; --selection: rgba(196, 167, 231, 0.30); + + /* Screen, not multiply: on ink, noise has to be added or it vanishes. */ + --grain-blend: screen; + --grain-opacity: 0.07; + + --fuzz-blend: lighter; + --fuzz-gain: 1; + --fuzz-band-1: 196 167 231; + --fuzz-band-2: 156 207 216; + --fuzz-band-3: 235 111 146; + --fuzz-fil-1: 224 222 244; + --fuzz-fil-2: 156 207 216; + --fuzz-fil-3: 196 167 231; +} + +/* ---- Bands: grounds, not accents ---------------------------------------- */ +/* A full-bleed band has to carry text on top of it, and an accent at + reading weight cannot also be a ground: at the accent's lightness the + night palette stops reading on it. So a band is an accent walked down + its own hue until the night accents all clear their floors on it. + + Two bands, two tokens — they shared one for a while on the main site + and retuning the marquee silently repainted the ledger halfway down + the page. These sit outside the theme blocks because a band is the + same ink in both modes: it is the page's constant. */ +:root { + /* The marquee. Iris, because iris is the signature accent — the hero + rule, the section eyebrows — so the one saturated stripe near the + top of the page belongs to the family around it. Published iris + (#795aa2, hue 266) walked down to ground weight at the same hue. + Cream on it: 9.10:1. */ + --band: #4e3969; + /* The ledger band, far enough down the page that the two never share a + sightline. Cream on it: 7.04:1. */ + --band-ledger: #015a77; + /* The plate ground the section banners and code blocks are drawn on, + and its scrim channels — the canvas reads these to erase its own + edges under the title. */ + --plate: #171322; + --plate-scrim: 23 19 34; } /* ---- Fonts + scale ------------------------------------------------------ */ @@ -107,11 +202,28 @@ --step-3: clamp(1.85rem, 1.4rem + 2vw, 2.9rem); --step-4: clamp(2.4rem, 1.6rem + 3.6vw, 4.4rem); - --radius: 14px; - --radius-sm: 9px; - --maxw: 1180px; + /* The micro label — 10px mono, uppercase, widely tracked. This is the + site's signature type object: every eyebrow, kind mark, stat label, + tab and code-block caption is set in it. Fixed in px on purpose; it + is furniture, and furniture does not scale with the headline. */ + --step-micro: 10px; + --track-micro: 0.18em; + --track-wide: 0.24em; + + /* Square corners, everywhere. Kept as tokens so existing consumers keep + compiling — they all resolve to nothing. */ + --radius: 0; + --radius-sm: 0; + + /* The archive measure. Wider than the old glass build's 1180px because + the page is now built out of full-bleed bands and ruled tables rather + than out of floating cards, and those want the room. */ + --maxw: 1440px; --rail: 240px; --ease: cubic-bezier(0.22, 1, 0.36, 1); + /* The site's entrance curve — slower out, hard landing. Distinct from + `--ease`, which is for hover-scale transitions. */ + --ease-in: cubic-bezier(0.2, 0.7, 0.2, 1); } /* ---- Plate: the DÆMON signature — an inverse (night) panel on the cream @@ -127,6 +239,8 @@ --text: #e0def4; --love: #eb6f92; --gold: #f6c177; --rose: #ebbcba; --pine: #3e8fb0; --foam: #9ccfd8; --iris: #c4a7e7; + --pine-soft: #3e8fb0; --foam-soft: #9ccfd8; --gold-soft: #f6c177; + --love-soft: #eb6f92; --iris-soft: #c4a7e7; --rose-soft: #ebbcba; --hl-low: #21202e; --hl-med: #403d52; --hl-high: #524f67; --bg: var(--base); --fg: var(--text); @@ -134,12 +248,21 @@ --fg-faint: var(--muted); --accent: var(--iris); --accent-2: var(--foam); - --glass-bg: rgba(31, 29, 46, 0.6); - --glass-bg-strong: rgba(38, 35, 58, 0.82); - --glass-brd: rgba(224, 222, 244, 0.12); - --glass-brd-hi: rgba(224, 222, 244, 0.2); - --glass-shadow: 0 12px 44px rgba(0, 0, 0, 0.4); - --grid-line: rgba(224, 222, 244, 0.05); - --code-bg: rgba(25, 23, 36, 0.72); + --rule: rgba(224, 222, 244, 0.16); + --rule-hi: rgba(224, 222, 244, 0.38); + --rule-faint: rgba(224, 222, 244, 0.08); + --wash: rgba(224, 222, 244, 0.05); + --wash-strong: #26233a; + --code-bg: #131019; + --grain-blend: screen; + --grain-opacity: 0.07; + --fuzz-blend: lighter; + --fuzz-gain: 1; + --fuzz-band-1: 196 167 231; + --fuzz-band-2: 156 207 216; + --fuzz-band-3: 235 111 146; + --fuzz-fil-1: 224 222 244; + --fuzz-fil-2: 156 207 216; + --fuzz-fil-3: 196 167 231; color: var(--fg); }