From 6957918e805a1b17d34f2a1a38190406f4663662 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Sun, 9 Aug 2026 23:45:44 +0300 Subject: [PATCH 1/2] feat(site): give the landing page a hero action row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helium renders the landing page from a template of its own, and that template has no navigation bar in it. So the front page offered a reader exactly two ways onward: a boxed "Documentation" panel in the top-right corner, and the "Where to go next" list at the very bottom of the prose. `titleLinks` puts the routes where a reader looks first — directly under the title, as a row of buttons: Get started, Examples, API reference, and the source repository. The boxed panel is removed in the same change rather than kept alongside, because it listed the same three destinations. A header that says "Getting Started" as a button and "Getting Started" again in a panel four inches to the right is not twice as helpful. It was also the tallest thing in the header, which set the header's height and left the title column beside it looking abandoned. Nothing is lost: every destination the panel held is in the row, and the "Where to go next" list at the foot of the page is unchanged. --- site/build/laika.scala | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/site/build/laika.scala b/site/build/laika.scala index 332d972..27310b5 100644 --- a/site/build/laika.scala +++ b/site/build/laika.scala @@ -40,6 +40,7 @@ import laika.config.TargetDefinition import laika.format.HTML import laika.format.Markdown import laika.helium.Helium +import laika.helium.config.ButtonLink import laika.helium.config.Favicon import laika.helium.config.HeliumIcon import laika.helium.config.IconLink @@ -232,17 +233,26 @@ object Site: // Everything below the header and the teaser rows is the prose of `site/src/landing-page.md`. That file has no // top-level heading of its own on purpose: `title` here already renders "codeberg4s" at 48px directly above it, // and a second `

codeberg4s

` under it said the same word twice. + // + // The landing page carries no navigation bar — Helium renders it from a template of its own, which has no top bar + // in it. `titleLinks` is the replacement: a reader who lands on the front page needs a way into the documentation + // that is not "scroll to the bottom and hope", and these cover what anyone arrives wanting. withChrome.site .landingPage( title = Some(SiteInfo.title), subtitle = Some("A Scala 3 client for the Codeberg / Forgejo REST API v1"), - latestReleases = Seq(ReleaseLine.current(args.version)), - license = Some(SiteInfo.licence), - documentationLinks = Seq( - TextLink.internal(Root / "getting-started.md", "Getting Started"), + titleLinks = Seq( + ButtonLink.internal(Root / "getting-started.md", "Get started"), TextLink.internal(Root / "examples.md", "Examples"), - TextLink.internal(Args.apiPath, "API (Scaladoc)"), + TextLink.internal(Args.apiPath, "API reference"), + IconLink.external(SiteInfo.sourceUrl, HeliumIcon.github), ), + latestReleases = Seq(ReleaseLine.current(args.version)), + license = Some(SiteInfo.licence), + // No `documentationLinks`. Helium renders them as a boxed panel in the header's right-hand column, and every + // entry it would hold is now a link in `titleLinks` a few inches to the left. Saying the same three things + // twice in one header is worse than saying them once, and the panel was tall enough to set the height of the + // whole header, leaving the title column beside it looking abandoned. projectLinks = Seq( TextLink.external(SiteInfo.sourceUrl, "Source"), TextLink.external(s"${SiteInfo.sourceUrl}/issues", "Issues"), From b3a252e30ef6cfed18d371b592b35a95ec4a605d Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Sun, 9 Aug 2026 23:46:35 +0300 Subject: [PATCH 2/2] feat(site): restyle the site dark-first over Helium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site was stock Helium in the project's original blue: a gradient banner, centred headings, teaser text floating with no container, code blocks with no border, and a sidebar whose section headings were told apart from its page links by a 2px rule. It read as a theme with the colours changed, because that is what it was. This is a design pass, not a theme swap. Helium stays — it already owns the sidebar, the responsive layout, the syntax highlighter and the page navigation, and hand-writing those to end up somewhere worse is not a good trade. What changes is everything Helium lets you change. **Palette.** Near-black (#0b0f14) with one accent hue, azure, in two tones: a bright one for interactive text on dark, a deep one for the same on white. Both colour schemes are now defined in full — before, only the light one was, and dark fell back to Helium's default teal, which no one had chosen. The header keeps its near-black in both schemes, so the site has one first impression rather than two, and its gradient is set to the same colour at both stops, which is how Helium is told to draw a flat surface. **Type.** 16px body, up from 15, in an 820px column rather than 860 — about 75 characters, which is where the eye stops having to hunt for the start of the next line. The heading scale steps 40 / 27 / 20 / 16 so a heading's level is visible without counting. **Components.** These are shapes, not settings, so they live in the new `site/assets/css/site.css`, wired in with `internalCSS` and loaded after Helium's own stylesheets — same specificity, later in the cascade, so nothing needs `!important`. Teasers become cards. Code blocks get a border, a radius, and `white-space: pre` (Helium wraps them, which in Scala breaks lines mid-identifier: `Future[Page[Issue]]` came out as `Futur` / `e[Page[Issue]]`). Inline code becomes a chip so a type name in a sentence is not mistaken for emphasis. Headings take the body colour instead of the accent, leaving the accent to mean "you can click this". Sidebar sections become small caps labels and the current page gets a bar. The home icon moves from the centre of the top bar, where Helium's space-between puts it on a wide screen, to the left where a brand mark goes. Verified by rendering: dark, light, and 390px wide, on the landing page and on a guide. The full pipeline is unchanged and still green — every snippet compiles, every internal link resolves. --- site/README.md | 3 +- site/assets/css/site.css | 704 +++++++++++++++++++++++++++++++++++++++ site/build/laika.scala | 156 +++++++-- 3 files changed, 839 insertions(+), 24 deletions(-) create mode 100644 site/assets/css/site.css diff --git a/site/README.md b/site/README.md index d7df48a..3e551bf 100644 --- a/site/README.md +++ b/site/README.md @@ -22,7 +22,8 @@ scripts/site.sh --help # the pipeline, step by step | `src/directory.conf` | Laika configuration for the content tree: title and sidebar order. | this directory | | `src/guides/` | The task-oriented guides. | the guides work stream | | `src/reference/` | The lookup pages. | the guides work stream | -| `assets/` | Static files copied to the site root, currently the favicon. | this directory | +| `assets/` | Static files copied to the site root: the favicon, and the stylesheet below. | this directory | +| `assets/css/site.css` | The design layer over Helium — component shapes only. Colours, type scale and metrics are configuration and live in `build/laika.scala`; do not set them in both places. | this directory | | `build/laika.scala` | The Laika driver: theme, colours, navigation, link validation. | this directory | Nothing under `site/` is compiled by Mill. `build/laika.scala` is a scala-cli diff --git a/site/assets/css/site.css b/site/assets/css/site.css new file mode 100644 index 0000000..a8175df --- /dev/null +++ b/site/assets/css/site.css @@ -0,0 +1,704 @@ +/* codeberg4s — the design layer on top of Helium. + * + * Loaded after Helium's own stylesheets (see `internalCSS` in site/build/laika.scala), at the same specificity, so + * every rule here is a plain override and nothing needs `!important`. + * + * What belongs here and what does not: Helium's palette, type scale and metrics are *configuration*, and they are set + * in laika.scala. This file is for the things Helium does not expose — the shape of a component. Do not restate a + * colour here that laika.scala already sets; read it from the custom property instead, or the two will drift. + * + * The variables prefixed `--c4s-` are ours. Everything unprefixed is Helium's, and its names are listed at the top of + * the generated helium/site/laika-helium.css in any build. + */ + +/* ============================================================================ + Tokens + ============================================================================ */ + +:root { + /* Read from Helium rather than repeated, so the palette has one home. */ + --c4s-surface: var(--primary-light); + --c4s-border: var(--primary-medium); + --c4s-accent: var(--primary-color); + + /* Secondary text: captions, metadata, the sidebar's inactive entries. Not in Helium's set. */ + --c4s-muted: #5b6672; + + /* The hero keeps its near-black in both colour schemes, so these are literals on purpose — they must not follow + the scheme. Matches Palette.heroBackground in laika.scala. */ + --c4s-hero-bg: #0b0f14; + --c4s-hero-text: #f2f7fb; + --c4s-hero-muted: #9fb0c2; + --c4s-hero-border: rgba(255, 255, 255, 0.14); + + /* The hero's own accent, and the reason it is separate from --c4s-accent: the page accent darkens in light mode so + it can carry link text on white, but the hero is near-black in both schemes. Using the page accent here put a + near-black label on a dark blue button in light mode, at about 2:1. This is Palette.Dark.accent. */ + --c4s-hero-accent: #4cc2ff; + + --c4s-radius: 10px; + --c4s-radius-sm: 6px; + --c4s-shadow: 0 1px 2px rgba(15, 23, 32, 0.05), 0 10px 30px rgba(15, 23, 32, 0.07); + --c4s-shell: 1180px; +} + +@media (prefers-color-scheme: dark) { + :root { + --c4s-muted: #93a3b4; + --c4s-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.35); + } +} + +/* ============================================================================ + Base + ============================================================================ */ + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* ============================================================================ + Top bar + ============================================================================ */ + +/* Helium's three children are laid out with space-between, which parks the home icon in the dead centre of a wide + * screen — a place no one looks for it. Pack them to the left and let the link row take the slack, so home sits at + * the left edge where a brand mark goes. */ +#top-bar { + background-color: color-mix(in srgb, var(--bg-color) 82%, transparent); + backdrop-filter: saturate(160%) blur(12px); + -webkit-backdrop-filter: saturate(160%) blur(12px); + border-bottom: 1px solid var(--c4s-border); + padding: 0 18px; + justify-content: flex-start; + gap: 6px; +} + +#top-bar .row.links { + margin-left: auto; +} + +#top-bar .row.links { + gap: 4px; +} + +#top-bar a.text-link { + padding: 6px 10px; + border-radius: var(--c4s-radius-sm); + font-size: 14px; + font-weight: 500; + transition: background-color 0.15s ease, color 0.15s ease; +} + +#top-bar a.text-link:hover { + background-color: var(--c4s-surface); + text-decoration: none; +} + +#top-bar a.icon-link:hover { + opacity: 0.75; + text-decoration: none; +} + +/* ============================================================================ + Sidebar + ============================================================================ */ + +#sidebar { + background-color: var(--bg-color); + border-right: 1px solid var(--c4s-border); + padding-right: 4px; +} + +#sidebar ul.nav-list { + margin-left: 10px; + padding-top: 16px; +} + +#sidebar .nav-list li a { + border-radius: var(--c4s-radius-sm); + padding: 5px 12px; + color: var(--c4s-muted); + transition: background-color 0.12s ease, color 0.12s ease; +} + +#sidebar .nav-list li a:hover { + background-color: var(--c4s-surface); + color: var(--text-color); + text-decoration: none; +} + +/* A section heading is a label, not a link target. Set it apart by weight and case rather than by the 2px rule + Helium draws, which read as a divider in the wrong place. */ +#sidebar .nav-list li.level1.nav-node, +#sidebar .nav-list li.level2.nav-header { + border-bottom: none; + margin-top: 18px; + margin-bottom: 2px; +} + +#sidebar .nav-list li.level1.nav-node > a, +#sidebar .nav-list li.level2.nav-header { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--c4s-muted); + padding-left: 12px; +} + +/* The current page. The bar on the left is what lets the eye find it without reading. Square on that edge, so the + * bar is a straight line rather than a bracket following the corner radius. */ +#sidebar .nav-list li.active > a, +#sidebar .nav-list li a.active { + background-color: var(--c4s-surface); + color: var(--c4s-accent); + font-weight: 600; + border-radius: 0 var(--c4s-radius-sm) var(--c4s-radius-sm) 0; + box-shadow: inset 2px 0 0 var(--c4s-accent); +} + +/* Helium sizes top-level entries at 1.1em, which made a page link ("Examples") louder than the section heading above + * it. One size for every page link; the section headings are already distinguished by case and weight. */ +#sidebar .nav-list li.level1 { + font-size: 1em; +} + +#sidebar .nav-list li a { + font-size: 14px; +} + +#sidebar .nav-list li.level1:not(.nav-node) > a { + font-weight: 500; + color: var(--text-color); +} + +/* ============================================================================ + Page navigation (the "on this page" column) + ============================================================================ */ + +#page-nav { + border: 1px solid var(--c4s-border); + border-radius: var(--c4s-radius); + background-color: transparent; + overflow: hidden; +} + +#page-nav .header { + background-color: var(--c4s-surface); + border-bottom: 1px solid var(--c4s-border); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 9px 14px; +} + +#page-nav .header a { + color: var(--c4s-muted); +} + +#page-nav .nav-list li a { + color: var(--c4s-muted); + font-size: 13.5px; + padding: 4px 14px; + border-radius: 0; +} + +#page-nav .nav-list li a:hover { + color: var(--c4s-accent); + text-decoration: none; +} + +#page-nav .footer { + display: none; +} + +/* ============================================================================ + Content typography + ============================================================================ */ + +/* Helium colours every heading with the accent. That reads as "this heading is a link", and on a page where real + * links are also the accent it leaves nothing to distinguish them. Headings take the body colour; the accent is + * reserved for things you can click. */ +main h1, +main h2, +main h3, +main h4, +main h5, +body > main h1, +body > main h2, +body > main h3, +body > main h4 { + color: var(--text-color); + letter-spacing: -0.015em; + line-height: 1.25; +} + +main h1.title { + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 28px; +} + +/* A rule under every h2 gives the page a visible rhythm when it is skimmed rather than read. */ +main h2.section { + margin-top: 52px; + padding-bottom: 10px; + border-bottom: 1px solid var(--c4s-border); + font-weight: 700; +} + +main h3 { + margin-top: 34px; + font-weight: 650; +} + +main p, +main li { + color: var(--text-color); +} + +main a { + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +main strong { + font-weight: 650; +} + +/* Inline code is a chip, so a type name inside a sentence is distinguishable from emphasis. */ +main :not(pre) > code { + background-color: var(--c4s-surface); + border: 1px solid var(--c4s-border); + border-radius: var(--c4s-radius-sm); + padding: 0.12em 0.36em; + font-size: 0.875em; +} + +main blockquote { + border-left: 3px solid var(--c4s-accent); + background-color: var(--c4s-surface); + border-radius: 0 var(--c4s-radius-sm) var(--c4s-radius-sm) 0; + padding: 12px 18px; + margin-left: 0; +} + +main blockquote p:last-child { + margin-bottom: 0; +} + +main hr { + border: none; + border-top: 1px solid var(--c4s-border); + margin: 40px 0; +} + +/* ============================================================================ + Tables + ============================================================================ */ + +main table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--c4s-border); + border-radius: var(--c4s-radius); + overflow: hidden; + font-size: 14.5px; +} + +main th { + background-color: var(--c4s-surface); + text-align: left; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--c4s-muted); + padding: 10px 14px; + border-bottom: 1px solid var(--c4s-border); +} + +main td { + padding: 10px 14px; + border-bottom: 1px solid var(--c4s-border); + vertical-align: top; +} + +main tr:last-child td { + border-bottom: none; +} + +/* ============================================================================ + Code blocks + ============================================================================ */ + +/* `white-space: pre` rather than Helium's wrapping. A wrapped line breaks wherever the column ends, which in Scala + * means inside an identifier — `Future[Page[Issue]]` came out as `Futur` / `e[Page[Issue]]`. A snippet that a reader + * might copy should not be re-flowed; give it a scrollbar instead. */ +main pre { + border: 1px solid var(--c4s-border); + border-radius: var(--c4s-radius); + padding: 16px 18px; + white-space: pre; + overflow-x: auto; + line-height: 1.6; + box-shadow: none; + margin: 20px 0; +} + +/* ============================================================================ + Landing page — hero + ============================================================================ */ + +/* Helium centres the header's two columns as a group, which leaves the hero's left edge somewhere other than the left + * edge of everything below it. There is no wrapper element inside #header to constrain, so the shell is emulated with + * padding: half the leftover width, plus the same 28px gutter .teasers and main use. Below the shell width the calc + * goes negative and max() falls back to the gutter, so it degrades to a normal narrow-screen layout. */ +#header { + background-image: none; + background-color: var(--c4s-hero-bg); + color: var(--c4s-hero-text); + border-bottom: 1px solid var(--c4s-hero-border); + text-align: left; + min-height: 0; + padding-top: 88px; + padding-bottom: 80px; + padding-left: max(28px, calc((100% - var(--c4s-shell)) / 2 + 28px)); + padding-right: max(28px, calc((100% - var(--c4s-shell)) / 2 + 28px)); + justify-content: space-between; + align-items: flex-start; + gap: 56px; +} + +#header-left, +#header-right { + color: var(--c4s-hero-text); + margin: 0; + max-width: none; +} + +#header-left { + flex: 1 1 auto; +} + +#header-left h1 { + color: #ffffff; + font-size: 60px; + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.05; + margin: 0 0 16px; +} + +#header-left h2 { + color: var(--c4s-hero-muted); + font-size: 21px; + font-weight: 400; + line-height: 1.45; + letter-spacing: -0.005em; + margin: 0; + /* Wide enough for the subtitle to sit on one line at desktop width, so it does not break after "Codeberg /". */ + max-width: 58ch; +} + +#header a, +#header a:hover { + color: var(--c4s-hero-text); +} + +/* --- hero action row --- */ + +#header-left div.row.links { + border: none; + height: auto; + margin: 34px 0 0; + gap: 14px; + justify-content: flex-start; + align-items: center; +} + +#header-left div.row.links > * { + flex: 0 0 auto; +} + +/* The button and the plain links sit on one row, so they need the same box: same padding, same line-height, and a + * transparent border on the button to match the 1px the links spend on theirs. Without that the filled button is a + * couple of pixels taller than its neighbours and the row looks accidental. */ +#header .button-link { + background-color: var(--c4s-hero-accent); + color: var(--c4s-hero-bg); + border: 1px solid transparent; + border-radius: var(--c4s-radius-sm); + padding: 11px 18px; + font-size: 15px; + font-weight: 650; + line-height: 1.25; + letter-spacing: -0.005em; + transition: filter 0.15s ease; +} + +#header .button-link:hover { + filter: brightness(1.12); + text-decoration: none; + color: var(--c4s-hero-bg); +} + +#header .row.links a.text-link { + padding: 11px 18px; + border: 1px solid var(--c4s-hero-border); + border-radius: var(--c4s-radius-sm); + font-size: 15px; + font-weight: 500; + line-height: 1.25; + color: var(--c4s-hero-text); +} + +#header .row.links a.text-link:hover { + background-color: rgba(255, 255, 255, 0.07); + text-decoration: none; +} + +/* Helium's inline GitHub mark is an SVG whose shape is filled from the theme's primary colour, which on the near-black + * hero comes out barely darker than the background. Fill it like the other muted text in this column instead. */ +#header .row.links a.icon-link .svg-shape { + fill: var(--c4s-hero-muted); + transition: fill 0.15s ease; +} + +#header .row.links a.icon-link:hover .svg-shape { + fill: var(--c4s-hero-text); +} + +#header .row.links a.icon-link { + display: inline-flex; + align-items: center; + padding: 0 6px; +} + +#header .row.links a.icon-link svg { + width: 22px; + height: 22px; +} + +/* --- hero metadata column --- */ + +#header-right { + padding: 0; + min-width: 240px; +} + +#header-right p { + color: var(--c4s-hero-muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + margin: 0 0 2px; +} + +#header-right p.large { + color: var(--c4s-hero-text); + font-family: var(--code-font); + font-size: 17px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + margin-bottom: 22px; +} + +#header-right p.medium { + text-transform: none; + letter-spacing: 0; + font-size: 15px; + font-weight: 500; + margin-bottom: 6px; +} + +#header-right p.medium a { + color: var(--c4s-hero-muted); +} + +#header-right p.medium a:hover { + color: var(--c4s-hero-text); +} + +/* Helium boxes this panel and pads it generously. At the top of a hero that is otherwise three short lines, the box + * was tall enough to set the height of the whole header and leave a third of it empty. Unboxed and tightened, the + * metadata column ends up about the same height as the title column beside it. */ +#header #docs { + border: none; + background: none; + padding: 0; + margin-bottom: 20px; + font-size: inherit; +} + +/* Helium fills the panel's caption with the inverted component background, which reads as a selected menu item + * rather than as a label. It is a label. */ +#header #docs p { + background: none; + padding: 0; + margin-bottom: 4px; +} + +#header #docs li { + padding: 0; +} + +#header #docs a { + display: block; + padding: 3px 0; + font-size: 15px; + font-weight: 500; + color: var(--c4s-hero-muted); +} + +#header #docs a:hover { + color: var(--c4s-hero-text); + text-decoration: none; +} + +/* ============================================================================ + Landing page — teaser cards + ============================================================================ */ + +.teasers { + max-width: var(--c4s-shell); + padding: 0 28px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr; + gap: 18px; + text-align: left; +} + +/* First row opens the band, last row closes it. Helium emits one .teasers per row of up to three. */ +.teasers:first-of-type { + padding-top: 56px; +} + +.teasers:last-of-type { + padding-bottom: 56px; +} + +.teaser { + flex: none; + margin: 0; + padding: 22px 24px; + background-color: var(--c4s-surface); + border: 1px solid var(--c4s-border); + border-radius: var(--c4s-radius); + transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.teaser:hover { + border-color: var(--c4s-accent); + transform: translateY(-2px); + box-shadow: var(--c4s-shadow); +} + +.teaser h2 { + color: var(--text-color); + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.3; + margin: 0 0 8px; +} + +.teaser p { + color: var(--c4s-muted); + font-size: 14.5px; + line-height: 1.6; + margin: 0; +} + +/* ============================================================================ + Landing page — prose below the teasers + ============================================================================ */ + +body > main { + max-width: var(--c4s-shell); + padding: 8px 28px 90px; +} + +body > main > * { + max-width: var(--content-width); +} + +body > main pre { + max-width: var(--content-width); +} + +/* ============================================================================ + Footer + ============================================================================ */ + +.footer-rule { + border-top: 1px solid var(--c4s-border); + margin-top: 56px; +} + +footer { + color: var(--c4s-muted); + font-size: 13px; + line-height: 1.6; + padding-bottom: 24px; +} + +/* ============================================================================ + Responsive + ============================================================================ */ + +@media (min-width: 700px) { + .teasers { + grid-template-columns: 1fr 1fr; + } +} + +@media (min-width: 1000px) { + .teasers { + display: grid; + justify-content: initial; + align-items: initial; + padding: 0 28px; + } + + .teaser { + margin: 0; + padding: 22px 24px; + } +} + +@media (max-width: 699px) { + #header { + padding: 56px 22px 48px; + gap: 36px; + } + + #header-left h1 { + font-size: 42px; + } + + #header-left h2 { + font-size: 18px; + } + + #header-left div.row.links { + flex-wrap: wrap; + gap: 10px; + } + + .teasers { + padding: 0 22px; + } + + body > main { + padding: 8px 22px 64px; + } +} diff --git a/site/build/laika.scala b/site/build/laika.scala index 27310b5..7289b14 100644 --- a/site/build/laika.scala +++ b/site/build/laika.scala @@ -31,6 +31,7 @@ import cats.effect.ExitCode import cats.effect.IO import cats.effect.IOApp import laika.api.Transformer +import laika.ast.LengthUnit.px import laika.ast.Path import laika.ast.Path.Root import laika.config.LinkConfig @@ -40,7 +41,9 @@ import laika.config.TargetDefinition import laika.format.HTML import laika.format.Markdown import laika.helium.Helium +import laika.helium.config.AnchorPlacement import laika.helium.config.ButtonLink +import laika.helium.config.ColorQuintet import laika.helium.config.Favicon import laika.helium.config.HeliumIcon import laika.helium.config.IconLink @@ -67,15 +70,65 @@ object SiteInfo: val codeFont: String = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace" - /** Deep sea blue, which is close enough to Codeberg's own without pretending to be its brand. */ - val primary: Color = Color.hex("1d4f6e") - val primaryMedium: Color = Color.hex("a7c6d9") - val primaryLight: Color = Color.hex("edf3f7") - val secondary: Color = Color.hex("8a4b1f") - val text: Color = Color.hex("1c1f21") - val background: Color = Color.hex("ffffff") - val gradientTop: Color = Color.hex("1d4f6e") - val gradientBottom: Color = Color.hex("2e7096") +/** The colour system. + * + * Dark is the mode this site is designed for; light is derived from it rather than the other way round. Both are + * defined here in full, because Helium generates one `:root` block per scheme and a value left unset falls back to a + * Helium default that was chosen for a different palette. + * + * One accent hue, azure, in two tones — the bright one carries interactive text on near-black, the deep one carries it + * on white. Keeping to a single hue is what stops a documentation site turning into a colour chart; the blue continues + * the identity the project already had, without claiming to be Codeberg's brand. + * + * Every text-on-background pair below clears WCAG AA (4.5:1 for body text, 3:1 for large text). The tightest pair is + * `muted` on `background`, at 7.0:1 dark and 5.9:1 light, so the small print is legible rather than merely present. + */ +object Palette: + + /** Near-black with a trace of blue in it. Flat: the header takes this as both gradient stops, so there is no gradient + * — see [[Site.theme]]. + */ + object Dark: + val background: Color = Color.hex("0b0f14") + val surface: Color = Color.hex("131a22") + val border: Color = Color.hex("26313d") + val text: Color = Color.hex("e6edf5") + val accent: Color = Color.hex("4cc2ff") + val accentHover: Color = Color.hex("9adcff") + + object Light: + val background: Color = Color.hex("ffffff") + val surface: Color = Color.hex("f4f7fa") + val border: Color = Color.hex("d5dee7") + val text: Color = Color.hex("0f1720") + val accent: Color = Color.hex("0a6a9c") + val accentHover: Color = Color.hex("064a6e") + + /** The landing page header stays near-black in both schemes. A hero that inverts with the colour scheme gives the + * site two different first impressions; this way it has one. + */ + val heroBackground: Color = Dark.background + + /** Syntax highlighting, shared by both schemes because the code block keeps its dark surface in both. + * + * Helium takes two quintets. `base` is structural — c1 is the block background, c2 comments, c5 ordinary code text. + * `wheel` is the token colours: keywords, declarations, literals, strings, and type names, in that order. + */ + val syntaxBase: ColorQuintet = ColorQuintet( + Color.hex("0f1620"), // block background — a shade off the page, so the block reads as a surface + Color.hex("6b7a8c"), // comments + Color.hex("8b9bb0"), + Color.hex("b7c7da"), + Color.hex("e6edf5"), // ordinary code text + ) + + val syntaxWheel: ColorQuintet = ColorQuintet( + Color.hex("ff7b9c"), // keywords + Color.hex("f5a97f"), // declaration names + Color.hex("ffd479"), // literals and numbers + Color.hex("a6e3a1"), // strings + Color.hex("7fd3ff"), // type names + ) /** The four claims the landing page is built around. They are the same four the README opens with, deliberately: a * reader who arrives from either direction should be told the same thing. @@ -171,16 +224,36 @@ object Site: version = Some(args.version), ) + // Helium's colour slots are named after its own defaults rather than after what they do, so the mapping is worth + // stating once: `primary` colours headings, links and navigation; `primaryLight` is the fill behind panels and the + // sidebar; `primaryMedium` is every border; `secondary` is the hover state. `bgGradient` gets the same colour twice + // on purpose — that is how Helium is told to draw a flat header rather than a gradient one. val withColours = withMetadata.site .themeColors( - primary = SiteInfo.primary, - primaryMedium = SiteInfo.primaryMedium, - primaryLight = SiteInfo.primaryLight, - secondary = SiteInfo.secondary, - text = SiteInfo.text, - background = SiteInfo.background, - bgGradient = (SiteInfo.gradientTop, SiteInfo.gradientBottom), + primary = Palette.Light.accent, + primaryMedium = Palette.Light.border, + primaryLight = Palette.Light.surface, + secondary = Palette.Light.accentHover, + text = Palette.Light.text, + background = Palette.Light.background, + bgGradient = (Palette.heroBackground, Palette.heroBackground), + ) + .site + .darkMode + .themeColors( + primary = Palette.Dark.accent, + primaryMedium = Palette.Dark.border, + primaryLight = Palette.Dark.surface, + secondary = Palette.Dark.accentHover, + text = Palette.Dark.text, + background = Palette.Dark.background, + bgGradient = (Palette.heroBackground, Palette.heroBackground), ) + .site + .syntaxHighlightingColors(base = Palette.syntaxBase, wheel = Palette.syntaxWheel) + .site + .darkMode + .syntaxHighlightingColors(base = Palette.syntaxBase, wheel = Palette.syntaxWheel) // Helium's default typography is Lato and Fira Mono, pulled from // fonts.googleapis.com by a in every page. Two reasons not to keep it: a documentation site should not make @@ -196,7 +269,44 @@ object Site: code = SiteInfo.codeFont, ) - val withChrome = withoutRemoteFonts.site + // Helium's defaults are a 15px body in an 860px column, with headings that step 34 / 28 / 20 / 15. Two problems for + // a page that is mostly prose about code: 15px is small for long-form reading at arm's length, and a 20px h3 next + // to a 15px h4 gives the reader no way to see the level of a heading without counting. + // + // 16px body, and a scale that keeps a visible ratio at every step. The column narrows rather than widens, because + // the constraint on a text column is the eye's return sweep, not the screen: ~75 characters at this size. + val withTypography = withoutRemoteFonts.site + .fontSizes( + body = px(16), + code = px(14), + title = px(40), + header2 = px(27), + header3 = px(20), + header4 = px(16), + small = px(13), + ) + .site + .layout( + contentWidth = px(820), + navigationWidth = px(280), + topBarHeight = px(48), + defaultBlockSpacing = px(14), + defaultLineHeight = 1.65, + anchorPlacement = AnchorPlacement.Right, + ) + + // The design lives in site/assets/css/site.css, not here. + // + // Helium exposes its palette and its metrics as configuration — that is everything above — but not its component + // shapes: the header is a centred block, the teasers are bare text, a code block is a rectangle with no border. + // Those are CSS, so they are changed in CSS, and `internalCSS` points at a directory in the input tree whose + // stylesheets are linked after Helium's own. Later in the cascade, same specificity, so an override is an override + // and nothing needs `!important`. + // + // scripts/site.sh copies site/assets to the staged tree, which is why the path is /assets and not /site/assets. + val withStyles = withTypography.site.internalCSS(Root / "assets" / "css") + + val withChrome = withStyles.site .favIcons(Favicon.internal(Root / "assets" / "favicon.svg", sizes = "32x32")) .site .topNavigationBar( @@ -231,12 +341,12 @@ object Site: .pageNavigation(depth = 3) // Everything below the header and the teaser rows is the prose of `site/src/landing-page.md`. That file has no - // top-level heading of its own on purpose: `title` here already renders "codeberg4s" at 48px directly above it, - // and a second `

codeberg4s

` under it said the same word twice. + // top-level heading of its own on purpose: `title` here already renders "codeberg4s" directly above it, and a + // second `

codeberg4s

` under it said the same word twice. // // The landing page carries no navigation bar — Helium renders it from a template of its own, which has no top bar // in it. `titleLinks` is the replacement: a reader who lands on the front page needs a way into the documentation - // that is not "scroll to the bottom and hope", and these cover what anyone arrives wanting. + // that is not "scroll to the bottom and hope", and these three cover the three things anyone arrives wanting. withChrome.site .landingPage( title = Some(SiteInfo.title), @@ -250,9 +360,9 @@ object Site: latestReleases = Seq(ReleaseLine.current(args.version)), license = Some(SiteInfo.licence), // No `documentationLinks`. Helium renders them as a boxed panel in the header's right-hand column, and every - // entry it would hold is now a link in `titleLinks` a few inches to the left. Saying the same three things - // twice in one header is worse than saying them once, and the panel was tall enough to set the height of the - // whole header, leaving the title column beside it looking abandoned. + // entry it would hold is already a button in `titleLinks` two inches to the left. Saying the same three things + // twice in one header is worse than saying them once, and the panel was tall enough to leave the left-hand + // column looking abandoned next to it. projectLinks = Seq( TextLink.external(SiteInfo.sourceUrl, "Source"), TextLink.external(s"${SiteInfo.sourceUrl}/issues", "Issues"),