diff --git a/docs/Website/Assets/guru.svg b/docs/Website/Assets/guru.svg new file mode 100644 index 0000000..a9d4ed4 --- /dev/null +++ b/docs/Website/Assets/guru.svg @@ -0,0 +1,12 @@ + + Guru — the PSOGM face + The crash-screen face. Chamfered corners quote the old SchOS letterforms. + Drawn here for reference only: kernel/core/panic.c builds it from primitives + rather than from a bitmap, so it scales with the display and costs nothing in + the kernel image. + + + + + + diff --git a/docs/Website/Assets/shift.svg b/docs/Website/Assets/shift.svg new file mode 100644 index 0000000..ed9d5ba --- /dev/null +++ b/docs/Website/Assets/shift.svg @@ -0,0 +1,27 @@ + + SchOS — Shift + Three slabs, displaced. The layer diagram in docs/architecture/README.md + as a solid object, and the kernel's name (SchIFT) as a shape. + + Each slab shows three faces: the top, the front edge under its lower side, and + the right-end wall under its right diagonal. The right wall is not optional -- + without it the slab has no thickness where it is widest, and the corner reads + as a hole rather than an edge. + + Geometry is shared with "shift-mask.svg"; see that file. + + + + + + + + + + + + + + + + diff --git a/docs/Website/Assets/uberblock.svg b/docs/Website/Assets/uberblock.svg new file mode 100644 index 0000000..ff67457 --- /dev/null +++ b/docs/Website/Assets/uberblock.svg @@ -0,0 +1,44 @@ + + SchFS — Uberblock + The uberblock ring: slots written round-robin, one of them newest. + Mount takes the highest-numbered slot that validates, which is the whole + consistency argument for the format. + + Every slot is an explicit annular sector. An earlier version drew them with + stroke-dasharray on a circle, which is shorter to write and wrong in two ways: + a dash centred on the path start straddles the closure and renders as TWO + pieces with a seam between them, and the normal slot underneath the marked one + still drew, showing through. Neither can happen here -- the marked slot is one + shape and the slot it replaces is simply not emitted. + + 12 slots at 20 degrees of ink and 10 of gap, which closes at 360 exactly. The + marked slot is index 1, counting clockwise from the top. + + It is marked twice over: it carries the accent, the same colour as the root at + the centre, and it reaches outward past the ring while sharing the inner edge + with every other slot. The colour is the reading on screen; the size is what + survives include/logo.h, which is a single coverage channel and keeps no + colour at all. + + Stands alone, with no reference to SchOS, as projects/schfs-linux/README.md + requires. + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Website/README.md b/docs/Website/README.md new file mode 100644 index 0000000..4007a92 --- /dev/null +++ b/docs/Website/README.md @@ -0,0 +1,50 @@ + +# The website + +Three files and a folder of marks. No build step, no dependencies, no +framework — open `index.html` and it works, including from a `file://` URL. + +``` +index.html the page +style.css the palette, transcribed from include/theme.h +app.js two behaviours: the boot log types, sections fade in +Assets/ shift.svg (the logo), uberblock.svg (SchFS), guru.svg (PSOGM) +``` + +## The rules it follows + +**The palette is not a design decision.** It is `include/theme.h`, copied. A +site describing an operating system should look like the operating system, and +copying the header means the two cannot drift apart by accident. + +**PSOGM purple appears exactly once.** `#4B1D9A` is on the crash-screen section +and nowhere else, because [../branding/README.md](../branding/README.md) says +it means *the machine stopped* and that a colour used everywhere means nothing. +If it shows up in a second place, that is a bug in the page. + +**The typefaces are the ones the OS ships.** Roboto for text and JetBrains Mono +for the system voice — both already in `ports/`, both already in `NOTICE`. They +are requested from the system rather than downloaded: a font file would be +larger than the entire rest of the site. + +**The script is optional.** If `app.js` fails to load the page is still +complete and still readable. Everything it does is decoration, and it honours +`prefers-reduced-motion` by doing none of it. + +**The boot log is real.** The lines in `app.js` are from an actual boot, not +invented. The timer calibration line is there deliberately; see section 13 of +[../history/kernel.md](../history/kernel.md) for what it cost. + +## Editing it + +The numbers in the *Numbers* section are measured, not estimated, and they go +stale. To refresh them: + +```sh +find kernel include -name '*.c' -o -name '*.h' | xargs wc -l | tail -1 +grep -c '^SYSCALL' kernel/sys/table.inc +``` + +The feature cards carry a tag — `runs`, `in progress`, or `designed` — and the +tag is a promise. Anything marked `runs` is in the tree and works today. When +that stops being true, change the tag rather than the wording. diff --git a/docs/Website/app.js b/docs/Website/app.js new file mode 100644 index 0000000..3198b19 --- /dev/null +++ b/docs/Website/app.js @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * Two behaviours, no framework: + * 1. the hero console types a real boot log + * 2. sections fade in once, as they arrive + * + * Both are decoration. If this file fails to load the page is still complete + * and still readable, which is the test any script on a documentation site + * should have to pass. + */ +(function () { + "use strict"; + + var reduced = window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + /* ---- 1. the boot log ------------------------------------------------ + * These are real lines from a real boot, not invented ones. The timer + * calibration line in particular is there because of what it cost: the + * clock ran seven times slow until a graphics driver was made to + * acknowledge its own interrupt. See docs/history/kernel.md. + */ + var LINES = [ + { t: "[ 0.000]", k: "SchIFT", s: " 64-bit, 2 cpus" }, + { t: "[ 0.004]", k: "cpu0", s: ": lapic timer 999949260 Hz, tsc 3792 MHz" }, + { t: "[ 0.061]", k: "virtio-gpu", s: ": 1280x800, 4000 KB framebuffer, irq 11" }, + { t: "[ 0.088]", k: "e1000", s: ": 52:54:00:12:34:56, link up, irq 11" }, + { t: "[ 0.143]", k: "schfs0", s: ": mounted, generation 41" }, + { t: "[ 0.169]", k: "net", s: ": eth0 10.0.2.15/255.255.255.0" }, + { t: "[ 0.204]", k: "schinit", s: ": 4 jobs", g: true }, + { t: "", k: "", s: "# " } + ]; + + var out = document.getElementById("bootlog"); + + function span(cls, text) { + var e = document.createElement("span"); + if (cls) e.className = cls; + e.textContent = text; + return e; + } + + function emit(line) { + if (line.t) { + out.appendChild(span("t", line.t + " ")); + out.appendChild(span("k", line.k)); + } + out.appendChild(span(line.g ? "g" : "", line.s)); + out.appendChild(document.createTextNode("\n")); + } + + function cursor() { + var c = document.createElement("i"); + c.className = "cursor"; + out.appendChild(c); + } + + if (!out) { + /* nothing to do */ + } else if (reduced) { + LINES.forEach(emit); // the whole log at once, no animation + cursor(); + } else { + var i = 0; + (function step() { + if (i >= LINES.length) { cursor(); return; } + emit(LINES[i++]); + /* Uneven on purpose: a boot that ticks out at a metronome's pace reads + as a progress bar rather than as a machine doing different work. */ + setTimeout(step, 180 + Math.random() * 220); + })(); + } + + /* ---- 2. reveal on scroll ------------------------------------------ */ + + var targets = document.querySelectorAll(".reveal"); + + if (reduced || !("IntersectionObserver" in window)) { + Array.prototype.forEach.call(targets, function (el) { el.classList.add("in"); }); + return; + } + + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add("in"); + io.unobserve(entry.target); // once, not on every scroll past + }); + }, { rootMargin: "0px 0px -12% 0px", threshold: 0.05 }); + + Array.prototype.forEach.call(targets, function (el) { io.observe(el); }); +})(); diff --git a/docs/Website/index.html b/docs/Website/index.html new file mode 100644 index 0000000..619b2a6 --- /dev/null +++ b/docs/Website/index.html @@ -0,0 +1,294 @@ + + + + + +SchOS — a 64-bit operating system written from scratch + + + + + + +
+
+ + + SchOS + + +
+
+ +
+ +
+
+
+

SchOS

+

+ A 64-bit operating system written from scratch — its own kernel, its own + filesystem, its own bootloader. It boots on UEFI, drives real hardware, + and runs unmodified musl and toybox on top. +

+ +
+ +
+
+ ttyS0 + SchIFT +
+

+    
+
+
+ +
+
+

Origin

+

It started as a teaching kernel and stopped being one

+

+ SchOS began as xv6, MIT's + teaching operating system: 32-bit, single core, one filesystem, a few dozen + syscalls. Almost none of that survives. What follows is what replaced it. +

+ +
+
+ First +

64-bit, and more than one core

+

+ Four-level paging, a real SMP bring-up, per-CPU state and a trap path + that survives both. This is the part that invalidates most of what a + 32-bit uniprocessor kernel assumes. +

+
+
+ Then +

Its own filesystem

+

+ xv6's log-structured filesystem was replaced by SchFS: a + copy-on-write B-tree with an uberblock ring, extents and checksummed + metadata. A crash leaves either the old tree or the new one. +

+
+
+ Then +

A Linux-shaped ABI

+

+ The syscall table adopted Linux numbering, which is what makes an + unmodified musl and an unmodified toybox run. The table is one + .inc file, and it is the seam the whole system is built around. +

+
+
+ Now +

Hardware, not emulation

+

+ A UEFI loader, xHCI and USB storage, an e1000 with a real TCP/IP stack, + virtio-gpu and VBE framebuffers, and a device model where a driver never + names its consumer. +

+
+
+
+
+ +
+
+

Architecture

+

Two seams hold the whole thing together

+

+ Above the syscall table, software is ordinary and portable. Below the device + model, a driver never names its consumer and a consumer never names a driver. + Everything else is arrangement. +

+ +
+
userlandtoybox, mksh, musl, and the first-party programs in cmd/
+
schinitPID 1 — job manifests and supervision
+
syscall tableLinux numbering, one .inc file — the ABI seam
+
kernel servicesVFS, sockets, signals, mmap, poll, procfs
+
subsystemsSchFS, lwIP, text and graphics, the buffer cache
+
device modelclass vtables and bus binding — the driver seam
+
driversxHCI, e1000, virtio-gpu, VBE, IDE, PS/2
+
SchIFT corescheduler, paging, traps, IRQ, SMP, DMA
+
bootthe UEFI loader in boot/uefi
+
+ +

+ uname -s reports SchIFT — the kernel — the way every Unix + reports its kernel rather than its distribution. +

+
+
+ +
+
+

What it has

+

Built and running, and honest about the rest

+

+ Every card marked runs is in the tree and works today. The tags are + not aspirational; the things that are not finished say so. +

+ +
+
+ runs +

SchFS

+

+ Copy-on-write B-tree, a 128-slot uberblock ring, extents with refcounts + and reflink, CRC32c on metadata, and free space derived from the tree + rather than stored beside it — so there is no second structure to + disagree with the first. There is nothing to repair after a crash. +

+
+
+ runs +

SMP x86-64 kernel

+

+ Four-level paging, multiple cores, preemptive scheduling, a calibrated + LAPIC timer, real signal delivery and job control. 205 syscalls on + Linux numbering. +

+
+
+ runs +

Networking

+

+ An e1000 driver under lwIP, with sockets for AF_INET, AF_UNIX and + AF_NETLINK. DHCP brings the interface up at boot; ping + reaches the outside world. +

+
+
+ runs +

UEFI boot

+

+ A PE/COFF loader built with clang and lld-link that sets up its own page + tables and hands the kernel a framebuffer. No BIOS path, no chainloading. +

+
+
+ runs +

USB

+

+ xHCI, hubs, HID and mass storage — a keyboard and a USB disk that the + kernel can mount, through the same device model as everything else. +

+
+
+ runs +

A real userland

+

+ musl and toybox, both unmodified, plus mksh as /bin/sh. + fastfetch builds from upstream source with an empty + patches/ directory — when it could not detect something, the + answer was to give the kernel what it was looking for. +

+
+
+ in progress +

Schcom

+

+ The compositor that will own the display. v0 runs. The desktop above it + is the next large piece of work, and it is deliberately Wayland-shaped. +

+
+
+ designed +

SchAPI

+

+ The native programming interface — specified, not built, and deliberately + so. It should be designed from the compositor's experience rather + than before it. +

+
+
+
+
+ +
+
+

Numbers

+

The size of it

+
+
43,701lines in kernel/ and include/
+
205syscalls, Linux numbering
+
128uberblock ring slots
+
0patches carried against fastfetch
+
+
+
+ +
+
+ Guru, the SchOS crash-screen icon +
+

When it stops

+

PSOGM

+

+ When the kernel panics it does not scroll a stack trace past you. It draws + a full-screen report — what failed, where, and the state that mattered — + and encodes the whole thing as a QR code so it can leave the machine + without being retyped. +

+

+ This purple is #4B1D9A, and the theme reserves it for exactly + this. Normal states are calm and dark; only failure is loud. It is the one + colour in the system that means something, which is why it appears once on + this page and nowhere else. +

+
+
+
+ +
+
+

Reading

+

Where to look

+

+ The documentation is written to outlive the work. Anything finished is + described in architecture/; anything intended lives in + plan/; and the bugs worth remembering are kept in + history/, in full, including the wrong turns. +

+ +
+
+ +
+ + + + + + diff --git a/docs/Website/style.css b/docs/Website/style.css new file mode 100644 index 0000000..a795708 --- /dev/null +++ b/docs/Website/style.css @@ -0,0 +1,371 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * The palette is not chosen here. It is include/theme.h, transcribed, because + * a site that describes an operating system should look like the operating + * system. That includes the rule theme.h enforces: + * + * normal states are calm and dark; only failure is loud. + * + * PSOGM purple (--psogm) therefore appears exactly once on this page, in the + * crash-screen section. If it ever appears anywhere else, delete it there. + */ + +:root { + /* include/theme.h */ + --bg: #0C0E13; /* SCH_BG */ + --fg: #E6E8EC; /* SCH_FG */ + --dim: #707784; /* SCH_DIM */ + --accent: #7C5CFF; /* SCH_ACCENT */ + --accent-dim: #2A2440; /* SCH_ACCENT_DIM */ + --ok: #4ADE80; /* SCH_OK */ + --warn: #FBBF24; /* SCH_WARN */ + --psogm: #4B1D9A; /* PSOGM_BG — failure, and nothing else */ + + /* Surfaces lifted off the ground, keeping its blue bias rather than + sliding toward neutral grey. */ + --surface: #12151C; + --surface-2: #171B24; + --line: #212632; + + --sans: "Roboto", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", + Menlo, Consolas, monospace; + + --measure: 66ch; + --gap: clamp(1rem, 2.5vw, 1.75rem); + --section: clamp(4rem, 10vw, 7.5rem); +} + +*, *::before, *::after { box-sizing: border-box; } + +html { scroll-behavior: smooth; } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem); + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +/* ---- shared shells ------------------------------------------------- */ + +.wrap { + width: min(100% - 2.5rem, 68rem); + margin-inline: auto; +} + +section { padding-block: var(--section); } + +.eyebrow { + font-family: var(--mono); + font-size: 0.75rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 0.75rem; +} + +h1, h2, h3 { text-wrap: balance; line-height: 1.15; margin: 0; } + +h2 { + font-size: clamp(1.75rem, 1.3rem + 2vw, 2.75rem); + font-weight: 700; + letter-spacing: -0.02em; +} + +.lede { + max-width: var(--measure); + color: var(--dim); + font-size: clamp(1.05rem, 1rem + 0.35vw, 1.2rem); + margin: 1.25rem 0 0; +} + +p { max-width: var(--measure); } + +a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 3px; } +a:hover { color: var(--fg); } +a:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + border-radius: 2px; +} + +code, .mono { font-family: var(--mono); font-size: 0.9em; } +code { color: var(--fg); background: var(--surface-2); padding: 0.1em 0.4em; border-radius: 3px; } + +/* ---- header -------------------------------------------------------- */ + +header.top { + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} +.top .wrap { + display: flex; + align-items: center; + gap: 1.5rem; + padding-block: 0.85rem; +} +.brand { + display: flex; align-items: center; gap: 0.6rem; + font-weight: 700; letter-spacing: -0.01em; + color: inherit; text-decoration: none; +} +.brand img { width: 26px; height: 26px; display: block; } +.top nav { margin-left: auto; display: flex; gap: 1.5rem; font-size: 0.9rem; } +.top nav a { color: var(--dim); text-decoration: none; } +.top nav a:hover { color: var(--fg); } +@media (max-width: 46rem) { .top nav { display: none; } } + +/* ---- hero ---------------------------------------------------------- */ + +.hero { padding-block: clamp(3.5rem, 9vw, 6.5rem) var(--section); } +.hero .wrap { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + gap: clamp(2rem, 5vw, 4rem); + align-items: center; +} +@media (max-width: 56rem) { + .hero .wrap { grid-template-columns: 1fr; } +} + +.hero h1 { + font-size: clamp(2.75rem, 1.8rem + 4.2vw, 4.75rem); + font-weight: 700; + letter-spacing: -0.035em; +} +.hero h1 .mark { + display: inline-block; + width: 0.95em; + height: 0.95em; + vertical-align: -0.08em; + margin-right: 0.22em; +} +.hero p { color: var(--dim); font-size: clamp(1.05rem, 1rem + 0.4vw, 1.25rem); margin-top: 1.25rem; } +.hero .cta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; } + +.btn { + display: inline-block; + padding: 0.65rem 1.25rem; + border-radius: 4px; + font-size: 0.925rem; + font-weight: 500; + text-decoration: none; + border: 1px solid var(--line); + color: var(--fg); + background: var(--surface); +} +.btn:hover { border-color: var(--accent); color: var(--fg); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #0B0714; font-weight: 700; } +.btn.primary:hover { background: #8F73FF; border-color: #8F73FF; color: #0B0714; } + +/* ---- console ------------------------------------------------------- */ + +.console { + background: #090B0F; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + box-shadow: 0 24px 60px -30px #000; +} +.console-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--line); + background: var(--surface); + font-family: var(--mono); + font-size: 0.72rem; + color: var(--dim); + letter-spacing: 0.06em; +} +.console-bar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); } +.console-bar .dot.live { background: var(--ok); } +.console-bar span { margin-left: auto; } + +.console pre { + margin: 0; + padding: 1rem 1.1rem 1.4rem; + font-family: var(--mono); + font-size: clamp(0.72rem, 0.62rem + 0.35vw, 0.82rem); + line-height: 1.85; + color: var(--fg); + overflow-x: auto; + min-height: 16.5rem; +} +.console .t { color: var(--dim); } +.console .k { color: var(--accent); } +.console .g { color: var(--ok); } +.console .cursor { + display: inline-block; + width: 0.55em; + height: 1.05em; + background: var(--accent); + vertical-align: -0.18em; + animation: blink 1.1s step-end infinite; +} +@keyframes blink { 50% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .console .cursor { animation: none; } } + +/* ---- origin timeline ----------------------------------------------- */ + +.origin-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--gap); + margin-top: 3rem; +} +.step { + border-left: 2px solid var(--accent-dim); + padding-left: 1.1rem; +} +.step h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.4rem; } +.step .when { font-family: var(--mono); font-size: 0.72rem; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; display: block; margin-bottom: 0.5rem; } +.step p { color: var(--dim); font-size: 0.925rem; margin: 0; } + +/* ---- the stack ----------------------------------------------------- */ + +.stack { display: flex; flex-direction: column; gap: 3px; margin-top: 3rem; } +.layer { + display: grid; + grid-template-columns: 10.5rem 1fr; + gap: 1.25rem; + align-items: baseline; + padding: 0.95rem 1.15rem; + background: var(--surface); + border: 1px solid transparent; + border-radius: 3px; +} +.layer:hover { border-color: var(--accent-dim); background: var(--surface-2); } +.layer .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); letter-spacing: 0.04em; } +.layer .what { color: var(--dim); font-size: 0.9rem; } +.layer.seam { background: var(--accent-dim); } +.layer.seam .name { color: var(--fg); } +.layer.seam .what { color: #C9BEEC; } +@media (max-width: 42rem) { + .layer { grid-template-columns: 1fr; gap: 0.3rem; } +} +.seam-note { margin-top: 1.5rem; color: var(--dim); font-size: 0.9rem; } + +/* ---- features ------------------------------------------------------ */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + gap: var(--gap); + margin-top: 3rem; +} +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.card h3 { font-size: 1.05rem; font-weight: 700; } +.card p { color: var(--dim); font-size: 0.925rem; margin: 0; max-width: none; } +.card .tag { + align-self: flex-start; + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 0.2rem 0.5rem; + border-radius: 3px; + border: 1px solid var(--line); + color: var(--dim); +} +.card .tag.runs { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); } +.card .tag.partial{ color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); } +.card .tag.planned{ color: var(--dim); } + +/* ---- numbers ------------------------------------------------------- */ + +.figures { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: var(--gap); + margin-top: 3rem; +} +.figure { border-top: 1px solid var(--line); padding-top: 1rem; } +.figure b { + display: block; + font-family: var(--mono); + font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.25rem); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} +.figure span { color: var(--dim); font-size: 0.85rem; } + +/* ---- the crash screen: the one loud thing --------------------------- */ + +.crash { background: var(--psogm); color: #fff; } +.crash .wrap { + display: grid; + grid-template-columns: 8rem minmax(0, 1fr); + gap: clamp(1.5rem, 4vw, 3rem); + align-items: start; +} +@media (max-width: 44rem) { .crash .wrap { grid-template-columns: 1fr; } } +.crash img { width: 100%; max-width: 8rem; height: auto; } +.crash .eyebrow { color: #C3AEEC; } +.crash h2 { color: #fff; } +.crash p { color: #DBCEF5; } +.crash code { background: rgba(0, 0, 0, 0.28); color: #fff; } + +/* ---- closing links -------------------------------------------------- */ + +.links { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: var(--gap); margin-top: 2.5rem; } +.link-card { + display: block; + padding: 1.2rem; + border: 1px solid var(--line); + border-radius: 5px; + text-decoration: none; + color: var(--fg); + background: var(--surface); +} +.link-card:hover { border-color: var(--accent); color: var(--fg); } +.link-card b { display: block; margin-bottom: 0.3rem; } +.link-card span { color: var(--dim); font-size: 0.875rem; } + +footer { + border-top: 1px solid var(--line); + padding-block: 2.5rem 3.5rem; + color: var(--dim); + font-size: 0.85rem; +} +footer .wrap { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: center; } +footer .mono { font-size: 0.78rem; } + +/* ---- reveal on scroll ---------------------------------------------- */ + +.reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s ease, transform .5s ease; } +.reveal.in { opacity: 1; transform: none; } +@media (prefers-reduced-motion: reduce) { + .reveal { opacity: 1; transform: none; transition: none; } +} + +/* The SchFS mark, set beside its own card heading. Baseline-aligned rather + than centred: it sits next to a word, so it follows the word. */ +.card h3 .cardmark { + width: 1.15em; + height: 1.15em; + vertical-align: -0.22em; + margin-right: 0.4em; +}