/* catcrafts.net — stylesheet Plain CSS, one file, no preprocessor. One file rather than several because the DOM partition cannot add elements (only the SSR'd head can), so extra files would mean either extra head plumbing or a blocking @import — and a single file compresses better anyway. @layer is doing real work here: it makes the cascade predictable without specificity games, so a components rule always beats a base rule regardless of selector weight. That is the preprocessor-free way to keep a growing stylesheet maintainable. Design direction: this is an open-source systems shop, so the visual language is terminal-adjacent — near-black surfaces, hairline borders, one accent, a monospace face for structural text. Deliberately not the purple-gradient treatment it replaces, which read as the wrong category for the work. */ @layer reset, tokens, base, layout, components, utilities, overrides; /* ─── reset ─────────────────────────────────────────────────────────── */ @layer reset { *, *::before, *::after { box-sizing: border-box; } * { margin: 0; padding: 0; } img, picture, svg, video, canvas { display: block; max-width: 100%; } button, input, select, textarea { font: inherit; color: inherit; } ul, ol { list-style: none; } } /* ─── tokens ────────────────────────────────────────────────────────── */ @layer tokens { :root { color-scheme: dark; /* Semantic surface names, not "dark-secondary" — so a light theme later is a token swap rather than a rename across the file. */ --surface-0: #0b0d10; --surface-1: #12151a; --surface-2: #181c23; --surface-3: #1f242c; --border: color-mix(in oklab, white 9%, transparent); --border-strong: color-mix(in oklab, white 18%, transparent); --text: #e6e8ec; --text-muted: #98a1ae; --text-faint: #6b7482; --text-on-accent: #06202b; --accent: #4cc2ff; --accent-hover: #7ad4ff; --accent-dim: color-mix(in oklab, #4cc2ff 18%, transparent); --ok: #48d597; --warn: #e8b339; --danger: #ff6b6b; /* Fluid type scale, 1.2 ratio. clamp() means no font-size media queries. */ --step--1: clamp(0.84rem, 0.81rem + 0.15vw, 0.92rem); --step-0: clamp(1rem, 0.96rem + 0.2vw, 1.08rem); --step-1: clamp(1.2rem, 1.12rem + 0.4vw, 1.4rem); --step-2: clamp(1.44rem, 1.3rem + 0.7vw, 1.8rem); --step-3: clamp(1.73rem, 1.5rem + 1.15vw, 2.4rem); --step-4: clamp(2.07rem, 1.7rem + 1.9vw, 3.2rem); /* Spacing scale — replaces the ad-hoc rem values this file used to have scattered through it. */ --s-3: 0.25rem; --s-2: 0.5rem; --s-1: 0.75rem; --s0: 1rem; --s1: 1.5rem; --s2: 2rem; --s3: 3rem; --s4: 4.5rem; --radius: 10px; --radius-sm: 6px; --radius-pill: 999px; --measure: 68ch; --container: 68rem; --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --dur-fast: 110ms; --dur: 200ms; --ease: cubic-bezier(0.25, 0.8, 0.25, 1); } @media (prefers-reduced-motion: reduce) { :root { --dur-fast: 1ms; --dur: 1ms; } } } /* ─── base ──────────────────────────────────────────────────────────── */ @layer base { html { -webkit-text-size-adjust: 100%; } body { font-family: var(--font-sans); font-size: var(--step-0); line-height: 1.65; color: var(--text); background: var(--surface-0); min-height: 100vh; display: flex; flex-direction: column; /* A single very soft radial instead of the old three-stop gradient: it reads as depth without competing with the content. */ background-image: radial-gradient( ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--accent) 7%, transparent), transparent 70%); background-repeat: no-repeat; } h1, h2, h3 { line-height: 1.2; text-wrap: balance; } p { text-wrap: pretty; } a { color: var(--accent); text-decoration: none; } a:hover { color: var(--accent-hover); text-decoration: underline; } code, kbd, samp, pre { font-family: var(--font-mono); font-size: 0.92em; } code { background: var(--surface-2); border: 1px solid var(--border); padding: 0.1em 0.35em; border-radius: var(--radius-sm); } pre { background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s0); overflow-x: auto; } /* :focus-visible rather than :focus so a mouse click leaves no ring, and :where() keeps specificity at zero so components can override without !important. There was no focus style at all before this. */ :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); } ::selection { background: var(--accent-dim); } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-track { background: var(--surface-1); } ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: var(--radius-pill); border: 2px solid var(--surface-1); } ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } } /* ─── layout ────────────────────────────────────────────────────────── */ @layer layout { #catcrafts-root { display: contents; } #cc-header { position: sticky; top: 0; z-index: 100; background: color-mix(in oklab, var(--surface-1) 88%, transparent); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); } .nav-container { max-width: var(--container); margin-inline: auto; padding: var(--s-1) var(--s1); display: flex; align-items: center; justify-content: space-between; gap: var(--s1); flex-wrap: wrap; } main { flex: 1; width: 100%; max-width: var(--container); margin-inline: auto; padding: var(--s3) var(--s1) var(--s4); } .section { margin-top: var(--s3); } #cc-footer { border-top: 1px solid var(--border); background: var(--surface-1); margin-top: auto; } .footer-content { max-width: var(--container); margin-inline: auto; padding: var(--s2) var(--s1); display: flex; flex-direction: column; gap: var(--s-1); } } /* ─── components ────────────────────────────────────────────────────── */ @layer components { /* nav */ .logo { display: inline-flex; align-items: center; gap: var(--s-2); font-family: var(--font-mono); font-size: var(--step-1); font-weight: 600; color: var(--text); letter-spacing: -0.01em; } .logo:hover { color: var(--text); text-decoration: none; } /* The mark is an inline SVG (see RenderNav): the cat face draws in currentColor, so this one declaration colours it. */ .logo__mark { color: var(--accent); display: inline-flex; } .logo__mark svg { width: 1.4em; height: 1.4em; } /* The cat's mouth is the old ">_" prompt, and its underscore blinks like a real terminal cursor: step-end, not a fade — cursors snap. Guarded below for prefers-reduced-motion, which the --dur trick can't catch because this animation has a literal duration. */ .logo-cursor { animation: logo-blink 1.2s step-end infinite; } @keyframes logo-blink { 0% { opacity: 1; } 50% { opacity: 0; } } @media (prefers-reduced-motion: reduce) { .logo-cursor { animation: none; } } /* On a narrow viewport the wordmark is the first thing worth dropping — the cat mark still identifies the site and the nav gets the room. */ @media (max-width: 26rem) { .logo__text { display: none; } } .nav-list { display: flex; gap: var(--s-3); flex-wrap: wrap; } .nav-link { display: inline-block; padding: var(--s-3) var(--s-1); border-radius: var(--radius-sm); color: var(--text-muted); font-size: var(--step--1); font-weight: 500; transition: color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease); } .nav-link:hover { color: var(--text); background: var(--surface-3); text-decoration: none; } .nav-link--active { color: var(--accent); } .nav-link--active:hover { color: var(--accent-hover); } /* hero — no VISIBLE headline by choice: a slogan-shaped heading is the marketing register this site refuses. The mission sentence itself is the h1 (document outline, crawlers) but wears body-prose clothes: same size step, weight and rhythm as the paragraph that completes it, so the two render as one block of text. */ .hero { max-width: var(--measure); } .hero__lede { font-size: var(--step-1); color: var(--text); text-wrap: balance; } h1.hero__lede { font-weight: 400; line-height: inherit; } .hero__actions { margin-top: var(--s1); display: flex; gap: var(--s-1); flex-wrap: wrap; } .section__title { font-size: var(--step-2); letter-spacing: -0.015em; padding-bottom: var(--s-2); border-bottom: 1px solid var(--border); margin-bottom: var(--s1); } /* buttons */ .btn { display: inline-block; padding: var(--s-2) var(--s0); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text); font-size: var(--step--1); font-weight: 550; transition: background-color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); } .btn:hover { background: var(--surface-3); border-color: var(--accent); color: var(--text); text-decoration: none; } .btn--primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); } .btn--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--text-on-accent); } .link-more { font-size: var(--step--1); font-family: var(--font-mono); } .link-more::after { content: " \2192"; } /* featured cards on the home page */ .mini-grid { display: grid; gap: var(--s0); grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); } .mini-card { padding: var(--s0); background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--dur-fast) var(--ease); } .mini-card:hover { border-color: var(--border-strong); } .mini-card__title { font-size: var(--step-0); font-family: var(--font-mono); } .mini-card__blurb { margin-top: var(--s-2); font-size: var(--step--1); color: var(--text-muted); /* Clamp so uneven blurb lengths don't make a ragged grid. */ display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } /* recent-posts list */ .recent { display: flex; flex-direction: column; } .recent__item { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s0); padding: var(--s-2) 0; border-bottom: 1px solid var(--border); } .recent__date { font-family: var(--font-mono); font-size: var(--step--1); color: var(--text-faint); white-space: nowrap; } /* page headers */ .page-header { max-width: var(--measure); margin-bottom: var(--s2); } .page-header__title { font-size: var(--step-3); letter-spacing: -0.02em; } .page-header__lede { margin-top: var(--s-1); color: var(--text-muted); } /* projects — cards, deliberately shallow. The Forgejo page is the canonical home for each project, so a card carries a name, one sentence and a language chip, and the whole thing points there. */ .project-grid { display: grid; gap: var(--s0); grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); } .project-card { display: flex; flex-direction: column; padding: var(--s0); background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--dur-fast) var(--ease); } .project-card:hover { border-color: var(--accent); } .project-card__title { font-size: var(--step-0); font-family: var(--font-mono); } .project-card__blurb { margin-top: var(--s-2); font-size: var(--step--1); color: var(--text-muted); } .project-card__lang { /* Pushed to the card's bottom edge so the chips align across a row even when blurb lengths differ. */ margin-top: auto; padding-top: var(--s-1); font-family: var(--font-mono); font-size: var(--step--1); color: var(--text-faint); } /* status badges */ .badge { font-family: var(--font-mono); font-size: var(--step--1); padding: 0.1em 0.6em; border-radius: var(--radius-pill); border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap; } .badge--active { color: var(--ok); border-color: color-mix(in oklab, var(--ok) 35%, transparent); background: color-mix(in oklab, var(--ok) 10%, transparent); } .badge--experiment { color: var(--warn); border-color: color-mix(in oklab, var(--warn) 35%, transparent); background: color-mix(in oklab, var(--warn) 10%, transparent); } /* posts */ .post-list { display: grid; gap: var(--s0); } .post-card { padding: var(--s1); background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color var(--dur-fast) var(--ease); } .post-card:hover { border-color: var(--border-strong); } .post-card__header { display: grid; gap: var(--s-3); } .post-card__title { font-size: var(--step-1); letter-spacing: -0.01em; } .post-card__title a { color: var(--text); } .post-card__title a:hover { color: var(--accent); text-decoration: none; } .post-card__meta { margin-top: var(--s-3); display: flex; gap: var(--s-1); align-items: baseline; flex-wrap: wrap; font-family: var(--font-mono); font-size: var(--step--1); color: var(--text-faint); } .post-card__community { color: var(--text-muted); } .post-card__excerpt { margin-top: var(--s-1); color: var(--text-muted); max-width: var(--measure); } .post-card__link { margin-top: var(--s-2); font-family: var(--font-mono); font-size: var(--step--1); /* A pasted URL has no spaces to break at and would otherwise force horizontal page scroll on a phone. */ overflow-wrap: anywhere; } /* Only reached by a post with no excerpt to trail — normally the read-more link rides at the end of .post-card__excerpt instead. */ .post-card__more { margin-top: var(--s-1); } /* Trailing an excerpt, the link is part of that paragraph and wraps with it, but it must not break across lines itself: "Read the full / post →" reads as two different things. */ .post-card__excerpt .link-more { white-space: nowrap; } /* ── one post, in full ────────────────────────────────────────────── The body is prose rendered from Markdown by Catcrafts.Shared:Markdown, so the element set here is exactly what that module can emit and no more. Everything is capped at --measure: a post page is reading material, and full-viewport line lengths are not read, they are skimmed. Media is the exception — it keeps the full column, because a screenshot of a feature-support table is unreadable at text width. */ .post-body { display: grid; gap: var(--s0); margin-top: var(--s1); } /* The measure goes on the prose, not on the container, so media can use the full column: a post page is reading material and full-viewport line lengths get skimmed rather than read, but a screenshot of a feature support table is unreadable at text width. A real table is exempt for exactly the same reason a screenshot of one is. */ .post-body > :not(.post-media, .post-body__table) { max-width: var(--measure); } /* .post-media carries its own top margin for the cards, where it follows an excerpt; inside this grid the gap already provides it. */ .post-body > .post-media { margin-top: 0; } /* Grid items default to min-width:auto, which refuses to shrink below their content. Without this the overflow-x on a code block below never engages — the track widens instead and the whole PAGE scrolls sideways, which is the one thing wide content must never cause. */ .post-body > *, .post-body__quote > * { min-width: 0; } .post-body h2 { font-size: var(--step-2); letter-spacing: -0.01em; } .post-body h3 { font-size: var(--step-1); } .post-body h4, .post-body h5, .post-body h6 { font-size: var(--step-0); } /* Extra air above a heading, none below: a heading belongs to what follows it, and the grid gap alone reads as floating between two equally distant paragraphs. */ .post-body :is(h2, h3, h4, h5, h6) { margin-top: var(--s1); } .post-body :is(h2, h3, h4, h5, h6):first-child { margin-top: 0; } .post-body a { overflow-wrap: anywhere; } /* Struck prose is the author retracting a line for effect. Muted as well as struck, because a line through body-coloured text on a dark surface reads as a rendering fault rather than as a deliberate aside. */ .post-body s { color: var(--text-muted); } /* The reset drops list markers globally, and everywhere else on the site that is right — the nav, the card grids and the chain list are lists for semantics, not for bullets. A post body is the one place that wants them back: these are the author's numbered mini-guides, and without a marker an ordered list is indistinguishable from an unordered one and the `start=` the parser goes out of its way to emit is invisible. */ .post-body__list { padding-left: var(--s1); display: grid; gap: var(--s-2); } ul.post-body__list { list-style: disc outside; } ol.post-body__list { list-style: decimal outside; } .post-body__list li { padding-left: var(--s-3); } .post-body__quote { padding: var(--s-2) var(--s0); border-left: 2px solid var(--border-strong); background: var(--surface-1); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; color: var(--text-muted); display: grid; gap: var(--s-1); } .post-body__code { padding: var(--s-1) var(--s0); background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: var(--step--1); /* Terminal output and measurement tables are tabular: wrapping them destroys the alignment that is the whole reason they were pasted. Scroll the block instead — never the page. */ overflow-x: auto; } .post-body :not(pre) > code { font-family: var(--font-mono); font-size: 0.9em; padding: 0.1em 0.3em; background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--radius-sm); } /* Pipe tables from a post body. Bordered rather than boxed like the code block: a table already has structure, and a frame around a grid of lines reads as two competing ones. */ .post-body__table { /* The wrapper is what scrolls. A
| . The delimiter row is
the only thing that moves a column, and it says so with a class. */
text-align: left;
padding: var(--s-2) var(--s-1);
border-bottom: 1px solid var(--border);
/* Cells wrap rather than forcing a scroll for every table, but not below
the width of a short word: nine columns on a phone would otherwise
shred into one character per line. Past that the container scrolls,
which is what it is for. */
min-width: 6ch;
}
.post-body__table th {
background: var(--surface-1);
border-bottom: 1px solid var(--border-strong);
/* Vertical-align matters here and nowhere else: a wrapped two-line heading
next to a one-line one should share a baseline at the bottom, where the
data starts. */
vertical-align: bottom;
}
.post-body__table tbody tr:last-child :is(th, td) { border-bottom: 0; }
/* Scoped under the wrapper for specificity, not for scoping: `:is(th, td)`
contributes a type selector, so the bare class would LOSE to the
text-align:left above and an aligned column would silently not be one. */
.post-body__table .post-body__cell--center { text-align: center; }
.post-body__table .post-body__cell--right { text-align: right; }
/* An image inside a sentence still gets its own line — these are
screenshots being pointed at, not icons sitting in the text. The |
|---|