Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
.PHONY: launch
.PHONY: launch cover

launch:
@echo "Launching the application..."
npm run dev
npm run dev

# Rebuild static/cover.png (the og:image) from tools/og-card.html.
#
# The card is copied into public/ and served over HTTP rather than opened as a
# file:// URL, so the Jost webfont and the logo are same-origin — woff2 is
# CORS-gated, and a cross-origin miss fails silently to the fallback font.
cover:
npm run build
cp tools/og-card.html public/og-card.html
@python3 -m http.server 8899 --directory public >/dev/null 2>&1 & \
srv=$$!; sleep 1; \
node tools/og-shot.mjs http://127.0.0.1:8899/og-card.html static/cover.png 1200 630; \
kill $$srv
rm -f public/og-card.html
@echo "static/cover.png regenerated"
56 changes: 55 additions & 1 deletion assets/js/custom.js
Original file line number Diff line number Diff line change
@@ -1 +1,55 @@
// Put your custom JS code here
// Put your custom JS code here

// Refresh the homepage star counts from /.netlify/functions/stars.
//
// The counts are already rendered at build time, so this only corrects them
// between rebuilds. Every failure path leaves the build-time values in place:
// running `hugo server` without `netlify dev` has no function to call, and a
// visitor may be offline.
//
// Cards are deliberately not re-sorted. This runs after first paint, so
// reordering would visibly shuffle them. The order therefore reflects the last
// deploy, and only looks wrong if two projects swap rank between deploys.
(function () {
function format(count) {
return count >= 1000 ? (count / 1000).toFixed(1) + "k" : String(count);
}

function refreshStars() {
var badges = document.querySelectorAll(".feature-stars[data-repo]");
if (!badges.length) {
return;
}

fetch("/.netlify/functions/stars")
.then(function (response) {
if (!response.ok) {
throw new Error("stars endpoint responded " + response.status);
}
return response.json();
})
.then(function (stars) {
for (var i = 0; i < badges.length; i++) {
var badge = badges[i];
var count = stars[badge.dataset.repo.toLowerCase()];
if (typeof count !== "number") {
continue;
}

badge.querySelector("[data-stars-count]").textContent = format(count);
badge.title = count + " stars on GitHub";
// Reveals the badge on cards whose build-time lookup came back empty.
badge.classList.remove("feature-stars-pending");
}
})
.catch(function () {
// Keep the build-time counts.
});
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", refreshStars, { once: true });
} else {
refreshStars();
}
})();
292 changes: 291 additions & 1 deletion assets/scss/common/_custom.scss
Original file line number Diff line number Diff line change
@@ -1 +1,291 @@
// Put your custom SCSS code here
// Put your custom SCSS code here

// Shrink the top-right navbar icons (social links + color mode toggler)
#socialMenu .social-link svg,
#buttonColorMode svg {
width: 20px;
height: 20px;
}

// Skip link: the first focusable thing on the page, hidden until it has focus.
//
// Not Bootstrap's `.visually-hidden-focusable`, so this does not depend on that
// utility surviving purgecss. Clipped rather than `display: none`, because a
// display-none element cannot receive focus at all.
.skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 1080; // above the sticky navbar ($zindex-sticky is 1020)
padding: 0.625rem 1rem;
background: $primary;
color: #fff;
border-bottom-right-radius: 0.375rem;
text-decoration: none;

&:not(:focus) {
// The standard visually-hidden recipe: out of sight, still in the a11y tree.
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

&:focus {
color: #fff;
}
}

// Keep the footer at the bottom of the viewport on short pages
// (the homepage does not fill the screen otherwise).
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}

// Flex items default to `min-width: auto`, so wide content (code blocks,
// tables) could stretch the page instead of scrolling inside its own box
// now that `body` is a flex container.
body > * {
min-width: 0;
}

.footer {
margin-top: auto;

// Doks sizes footer items at $font-size-base (16px) from `md` up, which is
// heavy for a copyright line under an 18px body. No media query needed to
// override it: same specificity, and this file is imported last.
li {
font-size: 0.8125rem;
}
}

// Scale the homepage headline with the viewport rather than wrapping it.
.home h1.home-title {
// `clamp()` passes through to CSS; Sass would try to evaluate `min()`
// itself and fail on the mixed rem/vw units.
font-size: clamp(1.5rem, 3.4vw, 3rem);
// Doks pulls the home headline up by 1rem, which eats into the hero padding.
margin-top: 0;
margin-bottom: 1.75rem;
}

// One line, but only where one line fits. Below `sm` the headline would have to
// drop to ~15px to fit 320px on a single line, so it wraps to two legible lines
// instead — `nowrap` there ran off the right edge and scrolled the page
// sideways.
@include media-breakpoint-up(sm) {
.home h1.home-title {
white-space: nowrap;
}
}

// The one piece of prose on the page. Constrained because a full-width measure
// under a centred headline reads as a caption rather than a statement.
.home .lead {
max-width: 40rem;
margin-inline: auto;
opacity: 0.75;
}

// The headline carries the top of the page alone, so give it room to sit in.
// The slack comes out of the dead band above the footer, which the short
// homepage leaves over anyway.
.home {
.section.container-fluid {
// Scaled, not fixed: the headline itself scales with the viewport, and a
// flat 5.5rem left 88px of air above a 20px headline on a phone.
padding-top: clamp(2.5rem, 7vw, 5.5rem);
padding-bottom: clamp(2rem, 5.5vw, 4.5rem);
}

.section-features {
padding-top: 0;
}
}

// Brand mark in the navbar (top left), next to the site title
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;

&::before {
content: "";
display: inline-block;
flex: 0 0 auto;
width: 32px;
height: 32px;
background: url("images/logo.png") no-repeat center / contain;
border-radius: 50%;
}
}

// Project cards on the homepage.
//
// Flex rather than grid or Bootstrap's row/col: seven cards never fill a row
// evenly, and flex-wrap centres the short final row instead of leaving a hole
// on one side. The explicit basis keeps every card the same width, which
// `flex-grow` would not — the last row would stretch wider than the rows above.
$feature-gap: 1.25rem;

// Names the grid without shouting: smaller than the headline, uppercase and
// tracked out so it reads as a label for the section rather than a second title
// competing with the hero.
//
// Deliberately not called `.section-title` — the theme already uses that for the
// "Related posts" heading on blog and legal pages (doks-core _posts.scss:55), and
// this would have restyled those into tiny uppercase labels.
.feature-grid-title {
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
// Not lower: at 13px this is normal-size text for WCAG, so it needs 4.5:1.
// 0.5 measured 3.01:1 and failed; 0.7 measures 5.4:1.
opacity: 0.7;
}

.feature-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $feature-gap;
// It is a `ul` now, for the item count screen readers announce. Strip the
// list affordances the browser adds; the cards are the visual affordance.
padding-left: 0;
margin-bottom: 0;
list-style: none;
}

.feature-card {
flex: 0 0 100%;
}

@include media-breakpoint-up(sm) {
.feature-card {
// Two per row: subtract the one gap between them.
flex-basis: calc(50% - #{$feature-gap * 0.5});
}
}

@include media-breakpoint-up(lg) {
.feature-card {
// Four per row: three gaps spread across four cards.
flex-basis: calc(25% - #{$feature-gap * 0.75});
}
}

.feature-card {
padding: 1.5rem;
// A warm tint rather than neutral grey: at these low alphas it barely reads
// as colour, but it stops the cards from looking like grey wireframes.
background: rgba($primary, 0.02);
border: 1px solid rgba($primary, 0.14);
border-radius: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;

// `h3` rather than `h2`: the grid now sits under its own section heading, so
// the card titles are one level deeper in the outline.
h3 {
// Doks puts a 2rem top margin on every heading, which leaves a gap
// between the card's top edge and its title.
margin-top: 0;
margin-bottom: 0.5rem;
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 1.125rem;

a {
color: inherit;
text-decoration: none;
}
}

// Decorative, and `aria-hidden` in the markup. `flex: 0 0 auto` stops a
// wide emoji from being squeezed by a long project name.
.feature-icon {
flex: 0 0 auto;
font-size: 1rem;
line-height: 1;
}

p {
margin-bottom: 0;
font-size: 1rem;
opacity: 0.78;
}

&:hover {
border-color: rgba($primary, 0.6);
box-shadow: 0 6px 20px rgba($primary, 0.12);
transform: translateY(-2px);

h3 a {
color: $primary;
}
}
}

// The donation call to action. Deliberately unboxed: a panel here competed with
// the project cards for attention and left a large empty band across the page.
// A plain centred line separates it from the grid on whitespace alone.
.feature-cta {
// Separated by whitespace alone — no rule and no box. The gap is a little
// wider than the grid's own row gap so the invitation still reads as the end
// of the page rather than an eighth card that fell out of the row.
margin-top: 3rem;
text-align: center;

.feature-cta-title {
display: block;
font-weight: 600;
color: $primary;
}

p {
margin: 0.125rem 0 0;
font-size: 0.9375rem;
opacity: 0.7;
}
}

// GitHub star count, right-aligned on the card's title line
.feature-stars {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
flex: 0 0 auto;
font-size: 0.8125rem;
font-weight: 500;

svg {
// A gold star, as GitHub draws it. The muting sits on the number instead of
// the whole badge, so the colour is not washed out by an opacity of its own.
color: #e3a008;
// Nudge the star onto the text baseline
margin-bottom: -0.125rem;
}

// The count stays in the body colour: yellow text this small would be hard to
// read on white, and the star alone carries the colour.
[data-stars-count] {
opacity: 0.65;
}
}

// No count yet: the build-time lookup failed and assets/js/custom.js has not
// filled this in (or cannot reach the endpoint). Hide it rather than show a
// bare star with no number.
.feature-stars-pending {
display: none;
}
9 changes: 7 additions & 2 deletions assets/scss/common/_variables-custom.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
// Put your custom SCSS variables here
$primary: #F2A884;
// Put your custom SCSS variables here

// The brand orange, sampled from the logo. The previous value (#F2A884) was a
// pale tint of it that only managed 1.96:1 contrast against white — well below
// the 4.5:1 WCAG AA needs for text — so links and accents barely registered and
// the site read as monochrome. This hits 4.56:1.
$primary: #d93c0d;
Loading
Loading