feat(logo): add seasonal logo to website with glob-based loading

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 21:45:04 +02:00
1 parent 2dbc92035d
commit d0a167461f
6 files changed
+150 -81

No files matched your search

+16 -50
View File
@@ -1,32 +1,10 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import lockupColor from "../assets/logos/thingport-lockup-compact-color.svg";
import lockupOnDark from "../assets/logos/thingport-lockup-compact-on-dark.svg";
import autumn from "../assets/logos/seasonal/autumn.svg";
import birthday from "../assets/logos/seasonal/birthday.svg";
import christmas from "../assets/logos/seasonal/christmas.svg";
import coffee from "../assets/logos/seasonal/coffee.svg";
import diwali from "../assets/logos/seasonal/diwali.svg";
import easter from "../assets/logos/seasonal/easter.svg";
import halloween from "../assets/logos/seasonal/halloween.svg";
import hanukkah from "../assets/logos/seasonal/hanukkah.svg";
import holi from "../assets/logos/seasonal/holi.svg";
import jonines from "../assets/logos/seasonal/jonines.svg";
import lunarNewYear from "../assets/logos/seasonal/lunarnewyear.svg";
import muertos from "../assets/logos/seasonal/muertos.svg";
import newYear from "../assets/logos/seasonal/newyear.svg";
import programmersDay from "../assets/logos/seasonal/programmersday.svg";
import ramadan from "../assets/logos/seasonal/ramadan.svg";
import saltibarsciai from "../assets/logos/seasonal/saltibarsciai.svg";
import spring from "../assets/logos/seasonal/spring.svg";
import stPatricks from "../assets/logos/seasonal/stpatricks.svg";
import summer from "../assets/logos/seasonal/summer.svg";
import valentines from "../assets/logos/seasonal/valentines.svg";
import winter from "../assets/logos/seasonal/winter.svg";
import womensDay from "../assets/logos/seasonal/womensday.svg";
import { type Season, seasonFor } from "../utils/season";
import { seasonFor } from "../utils/season";
type Props = {
size?: "lg" | "md" | "sm";
@@ -40,31 +18,8 @@ const HEIGHTS: Record<NonNullable<Props["size"]>, string> = {
sm: "1.125rem",
};
// April Fools' turns the logo itself, so it has none.
const OVERLAYS: Record<Exclude<Season, "aprilFools">, string> = {
newYear,
lunarNewYear,
valentines,
holi,
womensDay,
stPatricks,
spring,
ramadan,
easter,
saltibarsciai,
summer,
jonines,
birthday,
programmersDay,
autumn,
coffee,
diwali,
halloween,
muertos,
hanukkah,
winter,
christmas,
};
// Each season's overlay is named after it, in lower case; only today's is loaded.
export const SEASONAL_OVERLAYS = import.meta.glob<string>("../assets/logos/seasonal/*.svg", { import: "default" });
// Upside down all day, with a wobble now and then as if it noticed.
const upsideDown = keyframes`
@@ -78,9 +33,20 @@ const upsideDown = keyframes`
export default function Wordmark({ size = "md", seasonal = false }: Props) {
const theme = useTheme();
const [season] = useState(() => (seasonal ? seasonFor(new Date()) : null));
const [overlay, setOverlay] = useState<string | null>(null);
const src = theme.palette.mode === "dark" ? lockupOnDark : lockupColor;
const height = HEIGHTS[size];
const overlay = season && season !== "aprilFools" ? OVERLAYS[season] : undefined;
useEffect(() => {
const load = season && SEASONAL_OVERLAYS[`../assets/logos/seasonal/${season.toLowerCase()}.svg`];
if (!load) return;
let active = true;
void load().then((url) => active && setOverlay(url));
return () => {
active = false;
};
}, [season]);
const logo = (
<Box
component="img"
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { easterSunday, equinox, seasonFor } from "./season";
import { easterSunday, equinox, SEASONS, seasonFor } from "./season";
const on = (year: number, month: number, day: number) => seasonFor(new Date(year, month - 1, day, 12));
const minutesOff = (date: Date, iso: string) => Math.abs(date.getTime() - Date.parse(iso)) / 60_000;
@@ -113,3 +113,13 @@ describe("seasonFor", () => {
expect(on(2026, 10, 14)).toBeNull();
});
});
describe("seasonal overlays", () => {
it("has a drawing for every season but April Fools', which turns the logo itself", () => {
const files = Object.keys(import.meta.glob("../assets/logos/seasonal/*.svg")).map((path) =>
path.replace(/^.*\/(.+)\.svg$/, "$1"),
);
const seasons = SEASONS.filter((season) => season !== "aprilFools").map((season) => season.toLowerCase());
expect(files.toSorted()).toEqual(seasons.toSorted());
});
});
+28 -24
View File
@@ -1,27 +1,31 @@
export type Season =
| "newYear"
| "lunarNewYear"
| "valentines"
| "holi"
| "womensDay"
| "stPatricks"
| "spring"
| "ramadan"
| "aprilFools"
| "easter"
| "saltibarsciai"
| "summer"
| "jonines"
| "birthday"
| "programmersDay"
| "autumn"
| "coffee"
| "diwali"
| "halloween"
| "muertos"
| "hanukkah"
| "winter"
| "christmas";
/** In calendar order. Each has an overlay of the same name, in lower case, in assets/logos/seasonal, but April Fools' turns the logo. */
export const SEASONS = [
"newYear",
"lunarNewYear",
"valentines",
"holi",
"womensDay",
"stPatricks",
"spring",
"ramadan",
"aprilFools",
"easter",
"saltibarsciai",
"summer",
"jonines",
"birthday",
"programmersDay",
"autumn",
"coffee",
"diwali",
"halloween",
"muertos",
"hanukkah",
"winter",
"christmas",
] as const;
export type Season = (typeof SEASONS)[number];
const DAY_MS = 24 * 60 * 60 * 1000;
+2 -1
View File
@@ -2,7 +2,8 @@
The product site, docs and blog, published to GitHub Pages by
[`.github/workflows/web-pages.yml`](../.github/workflows/web-pages.yml). Built with [Astro](https://astro.build) as
plain static HTML. The only JavaScript is the theme toggle.
plain static HTML. The little JavaScript there is: the theme toggle, copy buttons, and the seasonal logo, which
the visitor's browser picks by date (the pages are built ahead of time).
```bash
cd web
+1 -1
View File
@@ -15,7 +15,7 @@ const current = Astro.url.pathname;
<header class="site-header">
<div class="container bar">
<a class="brand" href={url()} aria-label="Thingport home"><Logo /></a>
<a class="brand" href={url()} aria-label="Thingport home"><Logo seasonal /></a>
<nav class="nav" aria-label="Main">
{
NAV.map((item) => (
+92 -4
View File
@@ -2,10 +2,98 @@
import lockupColor from "../../../frontend/src/assets/logos/thingport-lockup-compact-color.svg";
import lockupOnDark from "../../../frontend/src/assets/logos/thingport-lockup-compact-on-dark.svg";
type Props = { height?: number };
const { height = 30 } = Astro.props;
type Props = {
height?: number;
/** Dresses the logo up for the season it is, as the app's sidebar does. */
seasonal?: boolean;
};
const { height = 30, seasonal = false } = Astro.props;
const width = (h: number, img: { width: number; height: number }) => Math.round((img.width / img.height) * h);
// Every season's overlay, by its lower-case name; the page is built ahead of time, so the visitor's browser picks today's.
const overlays = seasonal
? Object.fromEntries(
Object.entries(
import.meta.glob<{ default: ImageMetadata }>("../../../frontend/src/assets/logos/seasonal/*.svg", {
eager: true,
}),
).map(([path, image]) => [path.replace(/^.*\/(.+)\.svg$/, "$1"), image.default.src]),
)
: null;
---
<img class="only-light" src={lockupColor.src} alt="Thingport" height={height} width={width(height, lockupColor)} />
<img class="only-dark" src={lockupOnDark.src} alt="Thingport" height={height} width={width(height, lockupOnDark)} />
<span class="logo" style={`--h: ${height}px`} data-overlays={overlays && JSON.stringify(overlays)}>
<img class="only-light" src={lockupColor.src} alt="Thingport" height={height} width={width(height, lockupColor)} />
<img class="only-dark" src={lockupOnDark.src} alt="Thingport" height={height} width={width(height, lockupOnDark)} />
</span>
<script>
import { seasonFor } from "../../../frontend/src/utils/season";
const season = seasonFor(new Date());
for (const logo of season ? document.querySelectorAll<HTMLElement>(".logo[data-overlays]") : []) {
const src = (JSON.parse(logo.dataset.overlays!) as Record<string, string>)[season!.toLowerCase()];
if (season === "aprilFools") logo.classList.add("fools");
else if (src) {
const overlay = document.createElement("img");
overlay.className = "overlay";
overlay.src = src;
overlay.alt = "";
overlay.setAttribute("aria-hidden", "true");
logo.append(overlay);
}
logo.classList.add("seasonal");
}
</script>
<style>
.logo {
position: relative;
display: inline-flex;
line-height: 0;
}
/* Room for what spills past the logo's right edge. */
.logo:global(.seasonal) {
margin-right: 1rem;
}
/* The overlay's viewBox (-30 -45 530 175) is the logo's (0 0 411 100) with room around it. */
.logo :global(.overlay) {
position: absolute;
left: calc(var(--h) * -0.3);
top: calc(var(--h) * -0.45);
height: calc(var(--h) * 1.75);
width: auto;
/* Wider than the logo it sits in, which the site's img rule would otherwise shrink it to. */
max-width: none;
pointer-events: none;
}
/* April Fools': upside down all day, with a wobble now and then as if it noticed. */
.logo:global(.fools) img {
transform: rotate(180deg);
animation: upside-down 5s ease-in-out infinite;
}
@keyframes upside-down {
0%,
70%,
100% {
transform: rotate(180deg);
}
76% {
transform: rotate(168deg);
}
82% {
transform: rotate(191deg);
}
88% {
transform: rotate(176deg);
}
94% {
transform: rotate(183deg);
}
}
@media (prefers-reduced-motion: reduce) {
.logo:global(.fools) img {
animation: none;
}
}
</style>