Phase 5: every page of the site, the resume PDF and RSS

Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 21:33:49 +00:00
parent 7025e95f89
commit 99ecdc2113
83 changed files with 11681 additions and 14 deletions
@@ -0,0 +1,41 @@
---
/** The two-column body of the Blog and topic pages: the posts, then the sidebar. */
interface Props {
aside: string;
}
const { aside } = Astro.props;
---
<div class="cols">
<div class="main"><slot /></div>
<aside aria-label={aside} class="side"><slot name="aside" /></aside>
</div>
<style>
.cols {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding: 24px 0 48px;
}
.main {
flex: 3 1 620px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.side {
flex: 1 1 280px;
max-width: 340px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 1000px) {
.side {
max-width: none;
}
}
</style>
@@ -0,0 +1,75 @@
---
/** The Connected pages panel on a post: see connectedPages() in lib/blog.ts. */
import type { Connected } from "../../lib/blog.ts";
import PostThumb from "./PostThumb.astro";
import SidePanel from "./SidePanel.astro";
interface Props {
items: Connected[];
}
const { items } = Astro.props;
---
{
items.length > 0 && (
<SidePanel title="Connected pages" seal="layered">
<ul class="cp">
{items.map((i) => (
<li>
<a href={i.href}>
<PostThumb src={i.img} width={52} height={52} class="ct" />
<span class="w">
{i.kicker && <span class="k">{i.kicker}</span>}
<span class="t">{i.title}</span>
{i.sub && <span class="s">{i.sub}</span>}
</span>
</a>
</li>
))}
</ul>
</SidePanel>
)
}
<style>
.cp {
margin: 0;
padding: 0;
list-style: none;
}
.cp a {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 0 10px 3px;
border-top: 1px solid var(--gk-lines);
text-decoration: none;
}
.cp :global(.ct) {
outline-offset: 2px;
}
.w {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.k {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.t {
font-size: 14px;
font-weight: 600;
color: var(--gk-text);
}
.cp a:hover .t {
color: var(--gk-teal-bright);
}
.s {
font-size: 12px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,71 @@
---
/**
* A hero whose eyebrow is a breadcrumb ("Blog / Making"), for topic, post
* and case study pages. Extra content (the meta line) goes in the slot.
*/
interface Props {
crumbs: { label: string; href?: string }[];
title: string;
lede?: string;
/** Long titles (posts) wrap at 22 characters and run slightly smaller. */
long?: boolean;
}
const { crumbs, title, lede, long } = Astro.props;
---
<div class="hero">
<nav class="crumbs" aria-label="Breadcrumb">
{
crumbs.map((c, i) => (
<>
{i > 0 && <span aria-hidden="true"> / </span>}
{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}
</>
))
}
</nav>
<h1 class:list={["title", { long }]}>{title}</h1>
{lede && <p class="lede">{lede}</p>}
<slot />
</div>
<style>
.hero {
display: flex;
flex-direction: column;
gap: 12px;
}
.crumbs {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.crumbs a {
color: var(--gk-gold-bright);
text-decoration: none;
}
.crumbs a:hover {
text-decoration: underline;
}
.title {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
overflow-wrap: anywhere;
}
.title.long {
font-size: clamp(30px, 5.5vw, 46px);
letter-spacing: 0.06em;
line-height: 1.1;
max-width: 22ch;
}
.lede {
margin: 0;
font-size: 17px;
color: var(--gk-soft);
max-width: 62ch;
line-height: 1.6;
}
</style>
@@ -0,0 +1,111 @@
---
/** A case study's facts: employer, role, period, key numbers and tech. */
import Chip from "@gk/ui/components/Chip.astro";
import Kicker from "@gk/ui/components/Kicker.astro";
import type { Post } from "../../lib/blog.ts";
interface Props {
post: Post;
}
const d = Astro.props.post.data;
const cells = [
{ label: "Employer", value: d.employer ?? "Personal project" },
d.role && { label: "Role", value: d.role },
d.period && { label: "Period", value: d.period },
].filter((c): c is { label: string; value: string } => !!c);
const facts = d.facts ?? [];
---
<section class="facts" aria-label="Project facts">
<dl class="grid">
{
cells.map((c) => (
<div class="cell">
<dt>{c.label}</dt>
<dd>{c.value}</dd>
</div>
))
}
{
facts.map((f) => (
<div class="cell num">
<dt>{f.label}</dt>
<dd>{f.value}</dd>
</div>
))
}
</dl>
{
d.tech && d.tech.length > 0 && (
<div class="tech">
<Kicker ticks={false}>Tech</Kicker>
<ul>
{d.tech.map((t) => (
<li>
<Chip core>{t}</Chip>
</li>
))}
</ul>
</div>
)
}
</section>
<style>
.facts {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 26px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.grid {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px 24px;
}
.cell {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 14px;
border-left: 1px solid rgba(201, 162, 74, 0.45);
}
dt {
font-size: 11px;
letter-spacing: 0.2em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
dd {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gk-text);
line-height: 1.4;
}
.num dd {
font-family: var(--gk-font-display);
font-size: 24px;
color: var(--gk-gold-bright);
}
.tech {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding-top: 16px;
border-top: 1px solid var(--gk-lines);
}
.tech ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>
@@ -0,0 +1,232 @@
---
/**
* The Blog's top slot: the featured or latest post in the one featured
* panel. The cover fills the left side when the post has one; otherwise the
* blog's seal art stands in.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StarPath from "@gk/ui/components/StarPath.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { filamentBanner } from "@gk/ui/components/seals.ts";
import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
interface Props {
post: Post;
label: "Featured" | "Latest";
site: SiteIndex;
}
const { post, label, site: s } = Astro.props;
const href = s.href(post) ?? "#";
const cover = mediaSrc(post.data.cover);
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
---
<article class="lead" aria-labelledby="lead-title">
<div class:list={["art", { photo: cover }]}>
{
cover ? (
<>
<img src={cover} alt={post.title} class="cover" onerror="this.remove()" />
<span class="shade" aria-hidden="true" />
<span class="mat" aria-hidden="true" />
</>
) : (
<>
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
<StarPath strength="wild" steps={7} centerStar={false} class="path" seed={post.slug} />
<TickRing size={196} class="ring">
<Seal name="transmutation" size={150} class="seal" />
</TickRing>
</>
)
}
<span class="no" aria-hidden="true">
<TickScale width={150} height={10} count={30} />
<span>No. 01 · {label}</span>
</span>
</div>
<div class="text">
<span class="meta">
<Star size={12} />
<span class="kicker">{label} · {post.data.topic}</span>
<span class="date">{dateLine(post)}</span>
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
</span>
<h2 id="lead-title" class="title"><a href={href}>{post.title}</a></h2>
<span class="underline" aria-hidden="true"></span>
{post.data.summary && <p class="summary">{post.data.summary}</p>}
{links.length > 0 && <span class="chips">{links.map((l) => <Chip href={l.href}>{l.label}</Chip>)}</span>}
<Button href={href} arrow class="go">{post.data.postType === "build log" ? "Read the build log" : "Read the post"}</Button>
</div>
<GiltCorners />
</article>
<style>
.lead {
position: relative;
display: flex;
flex-wrap: wrap;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: var(--gk-radius);
box-shadow:
inset 0 0 0 4px var(--gk-midnight),
inset 0 0 0 5px rgba(201, 162, 74, 0.35);
}
.art {
flex: 1 1 440px;
min-height: 320px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
var(--gk-sky);
}
.art.photo {
min-height: 340px;
background: var(--gk-sky);
}
.banner {
position: absolute;
width: 150%;
max-width: none;
opacity: 0.32;
pointer-events: none;
}
.art :global(.path) {
position: absolute;
left: -10%;
width: 120%;
top: 50%;
transform: translateY(-50%) rotate(-16deg);
opacity: 0.8;
}
.art :global(.seal) {
filter: drop-shadow(0 0 16px rgba(201, 162, 74, 0.35));
}
.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.shade {
position: absolute;
inset: 0;
background:
linear-gradient(to top, rgba(14, 21, 34, 0.88), transparent 42%),
linear-gradient(to bottom, rgba(14, 21, 34, 0.45), transparent 30%);
}
.mat {
position: absolute;
inset: 14px;
border: 1px solid rgba(201, 162, 74, 0.5);
border-radius: 6px;
}
.no {
position: absolute;
left: 24px;
bottom: 20px;
display: flex;
flex-direction: column;
gap: 6px;
font-family: var(--gk-font-display);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--gk-gold-bright);
}
.text {
flex: 1 1 420px;
min-width: 0;
padding: 36px;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.date {
font-size: 12px;
color: var(--gk-muted);
}
.title {
margin: 0;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0.03em;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-gold-bright);
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.summary {
margin: 4px 0 0;
font-size: 15px;
color: var(--gk-soft);
line-height: 1.6;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.text :global(.go) {
align-self: flex-start;
margin-top: 6px;
}
@media (max-width: 640px) {
.art {
flex-basis: 100%;
min-height: 240px;
}
.art.photo {
min-height: 240px;
}
.text {
padding: 26px 22px 30px;
}
.title {
font-size: 26px;
}
}
</style>
@@ -0,0 +1,462 @@
---
/**
* The all-dark reading layout shared by blog posts and case studies: hero
* with the meta line, the article panel (cover, body, tags), the sidebar (On
* this page, Connected pages), previous and next, related posts and a call
* to action. A case study passes its facts strip in the `facts` slot.
*/
import PageShell from "@gk/ui/components/PageShell.astro";
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import type { NavKey } from "@gk/ui/components/nav.ts";
import type { PageFamily } from "@gk/ui/components/seals.ts";
import { connectedPages, neighbours, nowStatuses, pageLinks, readMinutes, relatedPosts, withHeadings, type Post } from "../../lib/blog.ts";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import ConnectedPages from "./ConnectedPages.astro";
import CrumbHero from "./CrumbHero.astro";
import PostList from "./PostList.astro";
import SidePanel from "./SidePanel.astro";
import StarHeading from "./StarHeading.astro";
interface Props {
post: Post;
site: SiteIndex;
family: PageFamily;
current: NavKey;
crumbs: { label: string; href?: string }[];
/** The newest-first list this post sits in, for previous and next. */
list: Post[];
/** The posts Related may draw from. */
pool: Post[];
/** Words for previous and next, like "post" or "case study". */
noun?: string;
}
const { post, site: s, family, current, crumbs, list, pool, noun = "post" } = Astro.props;
const d = post.data;
const body = post.body ?? "";
const { html, headings } = withHeadings(s.render(body));
const cover = mediaSrc(d.cover);
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
const { previous, next } = neighbours(list, post);
let related = relatedPosts(s, post, pool);
let relatedText = "Posts that share pages with this one.";
if (!related.length) {
related = pool.filter((p) => p.path !== post.path && p.data.topic === d.topic).slice(0, 3);
relatedText = `More from ${d.topic}, newest first.`;
}
const connected = connectedPages(s, post);
const isCase = d.postType === "case study";
const making = d.topic === "Making";
---
<PageShell title={post.title} family={family} current={current} description={d.summary}>
<Fragment slot="head">
<meta property="og:type" content="article" />
<meta property="og:title" content={post.title} />
{d.summary && <meta property="og:description" content={d.summary} />}
{cover && <meta property="og:image" content={new URL(cover, Astro.site ?? Astro.url.origin).href} />}
<link rel="alternate" type="application/rss+xml" title="Greg Kilmer" href="/rss.xml" />
</Fragment>
<Fragment slot="hero">
<CrumbHero crumbs={crumbs} title={post.title} lede={d.summary} long>
<div class="meta">
<time datetime={d.date}>{formatDate(d.date)}</time>
<span aria-hidden="true">·</span>
<span>{readMinutes(body)} min read</span>
{(links.length > 0 || now.length > 0) && <span aria-hidden="true">·</span>}
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
{links.map((l) => <Chip href={l.href}>{l.label}</Chip>)}
</div>
</CrumbHero>
</Fragment>
{Astro.slots.has("facts") && <div class="facts"><slot name="facts" /></div>}
<div class="cols">
<article class="paper" aria-label={post.title}>
<GiltCorners />
{
cover && (
<figure class="cover">
<img src={cover} alt={post.title} onerror="this.parentElement.remove()" />
</figure>
)
}
{html ? <div class="prose" set:html={html} /> : <p class="prose-empty">[PLACEHOLDER]</p>}
{
d.tags && d.tags.length > 0 && (
<div class="tags">
<span class="tl">Tagged</span>
{d.tags.map((t) => <Chip>{t}</Chip>)}
</div>
)
}
</article>
{(headings.length > 1 || connected.length > 0) && (
<aside aria-label={`About this ${noun}`} class="side">
{
headings.length > 1 && (
<SidePanel title="On this page" seal="zodiac">
<ul class="toc">
{headings.map((h) => (
<li>
<a href={`#${h.id}`}>{h.text}</a>
</li>
))}
</ul>
</SidePanel>
)
}
<ConnectedPages items={connected} />
</aside>
)}
</div>
{
(previous || next) && (
<nav class="pn" aria-label={`Previous and next ${noun}`}>
{previous ? (
<a href={s.href(previous) ?? "#"} class="gk-card pc" rel="prev">
<span class="pk">Previous {noun}</span>
<span class="pt">{previous.title}</span>
</a>
) : (
<span class="spacer" />
)}
{next && (
<a href={s.href(next) ?? "#"} class="gk-card pc next" rel="next">
<span class="pk">Next {noun}</span>
<span class="pt">{next.title}</span>
</a>
)}
</nav>
)
}
{
related.length > 0 && (
<section class="more" aria-labelledby="keep-reading">
<div class="mh">
<StarHeading id="keep-reading">Keep reading</StarHeading>
<p class="ms">{relatedText}</p>
</div>
<PostList posts={related} site={s} />
</section>
)
}
<section class="cta" aria-labelledby="cta-title">
<div class="ci">
<div class="cw">
<h2 id="cta-title" class="ct">{isCase ? "Have a project like this?" : "Want something like this?"}</h2>
<p class="cs">
{isCase ? "Tell me about it in an inquiry, or see the rest of my work." : making ? "Some of it is in the Store. Anything else can start as an inquiry." : "Anything you want to ask or start can begin as an inquiry."}
</p>
</div>
<div class="cb">
{isCase ? (
<>
<Button href="/inquiries">Start an inquiry</Button>
<Button variant="secondary" href="/work">All case studies</Button>
</>
) : making ? (
<>
<Button href="/store">Visit the Store</Button>
<Button variant="secondary" href="/inquiries">Start an inquiry</Button>
</>
) : (
<>
<Button href="/inquiries">Start an inquiry</Button>
<Button variant="secondary" href="/blog">Back to the Blog</Button>
</>
)}
</div>
</div>
</section>
</PageShell>
<style>
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
font-size: 13px;
color: var(--gk-muted);
}
.facts {
padding-top: 36px;
}
.cols {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding-top: 36px;
}
.paper {
position: relative;
flex: 3 1 640px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
padding: 48px 56px;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: var(--gk-radius);
box-shadow:
inset 0 0 0 4px var(--gk-midnight),
inset 0 0 0 5px rgba(201, 162, 74, 0.3);
}
.cover {
margin: 0;
}
.cover img {
display: block;
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 8px;
}
.prose,
.prose-empty {
display: flex;
flex-direction: column;
gap: 18px;
margin: 0;
font-size: 17px;
line-height: 1.8;
color: var(--gk-soft);
}
.prose > :global(*) {
margin: 0;
}
.prose :global(p),
.prose :global(li) {
max-width: 68ch;
}
.prose :global(h2) {
margin-top: 16px;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.05em;
color: var(--gk-text);
scroll-margin-top: 24px;
}
.prose :global(h2)::before {
content: "";
flex: none;
width: 13px;
height: 13px;
background: var(--gk-gold);
clip-path: polygon(50% 0, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0 50%, 42% 42%);
}
.prose :global(h3) {
margin-top: 8px;
font-size: 20px;
color: var(--gk-text);
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 22px;
}
.prose :global(blockquote) {
margin: 8px 0;
padding: 4px 0 4px 20px;
border-left: 3px solid var(--gk-gold);
font-family: var(--gk-font-display);
font-size: 22px;
line-height: 1.5;
color: var(--gk-text);
}
.prose :global(blockquote p) {
margin: 0;
}
.prose :global(img) {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
.prose :global(code) {
font-size: 0.9em;
padding: 1px 6px;
border-radius: 4px;
background: var(--gk-sky);
border: 1px solid var(--gk-lines);
}
.prose :global(pre) {
overflow-x: auto;
padding: 16px;
border-radius: 8px;
background: var(--gk-sky);
border: 1px solid var(--gk-lines);
font-size: 14px;
line-height: 1.6;
}
.prose :global(pre code) {
padding: 0;
border: 0;
background: none;
}
.prose :global(hr) {
width: 100%;
border: 0;
border-top: 1px solid var(--gk-lines);
}
.prose :global(table) {
border-collapse: collapse;
font-size: 15px;
display: block;
overflow-x: auto;
}
.prose :global(th),
.prose :global(td) {
padding: 8px 12px;
border-bottom: 1px solid var(--gk-lines);
text-align: left;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--gk-lines);
}
.tl {
align-self: center;
font-size: 13px;
color: var(--gk-muted);
}
.side {
flex: 1 1 260px;
max-width: 320px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 24px;
}
.toc {
margin: 0;
padding: 0;
list-style: none;
}
.toc a {
display: block;
padding: 6px 0 6px 12px;
border-left: 2px solid var(--gk-lines);
font-size: 14px;
color: var(--gk-muted);
text-decoration: none;
}
.toc a:hover,
.toc a:focus-visible {
color: var(--gk-text);
border-left-color: var(--gk-gold);
}
.pn {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 32px;
}
.pc,
.spacer {
flex: 1 1 300px;
}
.pc {
gap: 4px;
padding: 18px 20px;
}
.pc.next {
text-align: right;
}
.pk {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.pt {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
}
.more {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 32px;
}
.mh {
display: flex;
flex-direction: column;
gap: 6px;
}
.ms {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.cta {
margin: 40px calc(50% - 50vw) 0;
background: var(--gk-midnight);
border-top: 1px solid var(--gk-lines);
border-bottom: 1px solid var(--gk-lines);
}
.ci {
max-width: var(--gk-page-max);
margin: 0 auto;
padding: 36px var(--gk-gutter);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.cw {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct {
margin: 0;
font-size: 24px;
letter-spacing: 0.05em;
}
.cs {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.cb {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 1000px) {
.side {
position: static;
max-width: none;
}
}
@media (max-width: 640px) {
.paper {
padding: 32px 22px;
}
.prose :global(h2) {
font-size: 22px;
}
.prose :global(blockquote) {
font-size: 19px;
}
}
</style>
@@ -0,0 +1,91 @@
---
/**
* Posts as fixed-height rows, or an empty state when there are none.
*/
import Button from "@gk/ui/components/Button.astro";
import Seal from "@gk/ui/components/Seal.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import type { Post } from "../../lib/blog.ts";
import type { SiteIndex } from "../../lib/site.ts";
import PostRow from "./PostRow.astro";
interface Props {
posts: Post[];
site: SiteIndex;
/** Kicker per post, e.g. the topic and its linked craft. */
kicker?: (p: Post) => string | undefined;
/** Empty state text. */
emptyTitle?: string;
emptyText?: string;
emptyAction?: { label: string; href: string };
as?: "h2" | "h3";
}
const { posts, site: s, kicker, emptyTitle = "Nothing written here yet", emptyText, emptyAction, as } = Astro.props;
---
{
posts.length ? (
<ul class="list">
{posts.map((p) => (
<PostRow post={p} site={s} kicker={kicker?.(p)} as={as} />
))}
</ul>
) : (
<div class="empty">
<TickRing size={88} medallion>
<Seal name="transmutation" size={60} />
</TickRing>
<div class="words">
<p class="t">{emptyTitle}</p>
{emptyText && <p class="d">{emptyText}</p>}
{emptyAction && (
<Button variant="secondary" size="compact" href={emptyAction.href} class="act">
{emptyAction.label}
</Button>
)}
</div>
</div>
)
}
<style>
.list {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.empty {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 28px;
border: 1px dashed rgba(201, 162, 74, 0.45);
border-radius: var(--gk-radius);
background: rgba(19, 28, 43, 0.6);
}
.words {
flex: 1 1 260px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.t {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
color: var(--gk-text);
}
.d {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-soft);
max-width: 56ch;
}
.words :global(.act) {
margin-top: 8px;
}
</style>
@@ -0,0 +1,139 @@
---
/**
* One post as a fixed-height row: picture, topic and date, title, a
* two-line summary, Now chips for linked notes and page-link chips.
*/
import Chip from "@gk/ui/components/Chip.astro";
import Star from "@gk/ui/components/Star.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import PostThumb from "./PostThumb.astro";
interface Props {
post: Post;
site: SiteIndex;
/** The kicker over the title; the topic by default. */
kicker?: string;
/** Heading level of the title. */
as?: "h2" | "h3";
}
const { post, site: s, kicker, as: H = "h3" } = Astro.props;
const href = s.href(post) ?? "#";
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
---
<li class="row">
<span class="mark" aria-hidden="true"><Star size={11} tone="violet" /></span>
<a href={href} class="pic" tabindex="-1" aria-hidden="true">
<PostThumb src={mediaSrc(post.data.cover)} class="thumb" />
</a>
<div class="body">
<span class="meta">
<span class="kicker">{kicker ?? post.data.topic}</span>
<span class="date">{dateLine(post)}</span>
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
</span>
<H class="title"><a href={href}>{post.title}</a></H>
{post.data.summary && <p class="summary">{post.data.summary}</p>}
<span class="chips">
{links.length ? links.map((l) => <Chip href={l.href}>{l.label}</Chip>) : <span class="none">Not tied to a page</span>}
</span>
</div>
</li>
<style>
.row {
position: relative;
display: flex;
gap: 20px;
padding: 22px 0 20px;
border-top: 1px dashed rgba(244, 242, 237, 0.16);
}
.mark {
position: absolute;
left: 0;
top: -6px;
display: flex;
padding-right: 6px;
background: var(--gk-sky);
}
.pic {
display: flex;
flex: none;
align-self: flex-start;
margin: 4px 0 0 4px;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.date,
.none {
font-size: 12px;
color: var(--gk-muted);
}
.title {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
line-height: 1.3;
letter-spacing: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-gold-bright);
}
.summary {
margin: 0;
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
@media (max-width: 640px) {
.row {
gap: 14px;
}
.pic :global(.thumb) {
--w: 96px !important;
--h: 76px !important;
}
.title {
font-size: 17px;
}
}
</style>
@@ -0,0 +1,53 @@
---
/**
* A post's picture in a row or panel: the cover over star-chart art with the
* monogram, which also stands in when there is no cover or it fails to load.
*/
import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url";
interface Props {
src?: string;
alt?: string;
width?: number;
height?: number;
class?: string;
}
const { src, alt = "", width = 176, height = 120, class: className } = Astro.props;
const mark = Math.round(Math.min(width, height) / 2);
---
<span class:list={["thumb", className]} style={`--w:${width}px;--h:${height}px`}>
<img src={monogram} alt="" width={mark} height={mark} class="mono" aria-hidden="true" />
{src && <img src={src} alt={alt} loading="lazy" decoding="async" class="photo" onerror="this.remove()" />}
</span>
<style>
.thumb {
position: relative;
flex: 0 0 var(--w);
width: var(--w);
height: var(--h);
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
outline: 1px solid rgba(201, 162, 74, 0.4);
outline-offset: 3px;
border: 1px solid var(--gk-lines);
background:
radial-gradient(ellipse at 30% 20%, rgba(106, 47, 224, 0.3), transparent 60%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 22px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 22px),
var(--gk-sky);
}
.mono {
opacity: 0.5;
}
.photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,83 @@
---
/**
* The newest statuses, each with its subject and how many posts link to it.
*/
import Button from "@gk/ui/components/Button.astro";
import { plural } from "../../lib/page.ts";
import type { SiteIndex } from "../../lib/site.ts";
import SidePanel from "./SidePanel.astro";
interface Props {
site: SiteIndex;
limit?: number;
}
const { site: s, limit = 4 } = Astro.props;
const items = s.statuses().slice(0, limit).map((st) => {
const subject = st.data.subject ? s.byName(st.data.subject) : undefined;
const posts = subject ? s.backlinks(subject, { type: "post" }).filter((p) => (p as { data: { postType?: string } }).data.postType !== "case study").length : 0;
return {
kind: st.data.kind,
title: subject?.title ?? st.title,
href: (subject && s.href(subject)) ?? s.href(st) ?? "/now",
sub: posts ? plural(posts, "post") : st.data.note,
};
});
---
{
items.length > 0 && (
<SidePanel title="Right now" seal="zodiac">
<ul class="now">
{items.map((i) => (
<li>
<a href={i.href}>
<span class="k">{i.kind}</span>
<span class="t">{i.title}</span>
{i.sub && <span class="s">{i.sub}</span>}
</a>
</li>
))}
</ul>
<Button variant="text" href="/now" class="now-all">Everything on the Now page</Button>
</SidePanel>
)
}
<style>
.now {
margin: 0;
padding: 0;
list-style: none;
}
.now a {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-top: 1px solid var(--gk-lines);
text-decoration: none;
}
.k {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-teal-bright);
text-transform: uppercase;
}
.t {
font-size: 14px;
font-weight: 600;
color: var(--gk-text);
}
.now a:hover .t {
color: var(--gk-teal-bright);
}
.s {
font-size: 12px;
color: var(--gk-muted);
}
:global(.now-all) {
align-self: flex-start;
font-size: 13px;
padding-top: 6px;
}
</style>
@@ -0,0 +1,49 @@
---
/**
* Plain rows in a sidebar panel: a label and a count, with the current one
* in teal. Used for Topics and the Archive.
*/
interface Props {
items: { label: string; href: string; count?: number | string; current?: boolean }[];
}
const { items } = Astro.props;
---
<ul class="rows">
{
items.map((i) => (
<li>
<a href={i.href} aria-current={i.current ? "page" : undefined}>
<span>{i.label}</span>
{i.count !== undefined && <span class="n">{i.count}</span>}
</a>
</li>
))
}
</ul>
<style>
.rows {
margin: 0;
padding: 0;
list-style: none;
}
a {
display: flex;
justify-content: space-between;
gap: 8px;
min-height: 32px;
align-items: center;
padding: 4px 0;
font-size: 14px;
color: var(--gk-text);
text-decoration: none;
border-top: 1px solid var(--gk-lines);
}
a:hover,
a[aria-current] {
color: var(--gk-teal-bright);
}
.n {
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,47 @@
---
/**
* A sidebar panel on the blog pages: Midnight box, a faint seal in the
* corner, a starred kicker over a tick scale, then its rows.
*/
import Kicker from "@gk/ui/components/Kicker.astro";
import Seal from "@gk/ui/components/Seal.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
interface Props {
title: string;
seal?: SealName;
/** Heading level for the title, so the outline stays correct. */
as?: "h2" | "h3";
}
const { title, seal, as: H = "h2" } = Astro.props;
---
<section class="panel">
{seal && <Seal name={seal} size={104} class="mark" />}
<H class="head"><Kicker star>{title}</Kicker></H>
<slot />
</section>
<style>
.panel {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.panel :global(.mark) {
position: absolute;
right: -26px;
top: -26px;
opacity: 0.14;
pointer-events: none;
}
.head {
margin: 0;
font: inherit;
}
</style>
@@ -0,0 +1,41 @@
---
/** A section heading led by a gold star, with the filament underline. */
import Star from "@gk/ui/components/Star.astro";
interface Props {
id?: string;
level?: 2 | 3;
}
const { id, level = 2 } = Astro.props;
const H = `h${level}` as "h2" | "h3";
---
<div class="sh">
<H id={id} class="h"><Star size={14} /><slot /></H>
<span class="u" aria-hidden="true"></span>
</div>
<style>
.sh {
display: flex;
flex-direction: column;
gap: 6px;
}
.h {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.06em;
}
.u {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
</style>
@@ -0,0 +1,18 @@
---
/** The Topics panel: every topic with its post count, linking to its page. */
import { topicHref, topics } from "../../lib/blog.ts";
import type { SiteIndex } from "../../lib/site.ts";
import RowList from "./RowList.astro";
import SidePanel from "./SidePanel.astro";
interface Props {
site: SiteIndex;
title?: string;
current?: string;
}
const { site: s, title = "Topics", current } = Astro.props;
const items = topics.map((t) => ({ label: t, href: topicHref(t), count: s.posts({ topic: t }).length, current: t === current }));
---
<SidePanel title={title} seal="layered">
<RowList items={items} />
</SidePanel>
@@ -0,0 +1,209 @@
---
/**
* The landing's "From the blog": one featured post (pinned, else newest) with
* its image and topic, and the two most recent beside it. Renders nothing when
* there are no posts.
*/
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import { slugify } from "@gk/schema";
import { blogStrip, readMinutes } from "../../lib/home.ts";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
interface Props {
site: SiteIndex;
}
const { site: s } = Astro.props;
const { featured, recent } = blogStrip(s);
const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`;
---
{
featured && (
<div class="strip">
<div class="head">
<div class="gk-heading-sm">
<h3 id="from-blog">From the blog</h3>
<span class="underline" aria-hidden="true" />
</div>
<a href="/blog" class="gk-text all">All articles</a>
</div>
<div class="posts">
<article class="featured gk-card gk-linked">
<StarChartArt
seal="seven-metals"
ratio="auto"
src={mediaSrc(featured.data.cover)}
alt={featured.data.cover ? featured.title : ""}
class="art"
/>
<div class="body">
<div class="tags">
<span class="label">{featured.data.pinned ? "Featured" : "Latest"}</span>
<a class="gk-chip" href={topicHref(featured.data.topic)}>{featured.data.topic}</a>
</div>
<h4 class="ftitle">
<a href={s.href(featured) ?? "/blog"}>{featured.title}</a>
</h4>
<p class="summary">{featured.data.summary}</p>
<span class="date">
<time datetime={featured.data.date}>{formatDate(featured.data.date)}</time> · {readMinutes(featured.body)} min read
</span>
</div>
</article>
{
recent.length > 0 && (
<div class="recent">
{recent.map((p) => (
<article class="gk-card gk-linked small">
<span class="tags">
<span class="label">New</span>
<a class="gk-chip" href={topicHref(p.data.topic)}>{p.data.topic}</a>
</span>
<h4 class="rtitle">
<a href={s.href(p) ?? "/blog"}>{p.title}</a>
</h4>
<span class="date">
<time datetime={p.data.date}>{formatDate(p.data.date)}</time> · {readMinutes(p.body)} min read
</span>
</article>
))}
</div>
)
}
</div>
</div>
)
}
<style>
.strip {
display: flex;
flex-direction: column;
gap: 22px;
margin-top: 16px;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.gk-heading-sm {
display: flex;
flex-direction: column;
gap: 8px;
}
.gk-heading-sm h3 {
margin: 0;
font-size: 22px;
letter-spacing: 0.08em;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.all {
font-size: 14px;
}
.posts {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.featured {
flex: 2 1 560px;
min-width: 0;
padding: 0;
overflow: hidden;
flex-direction: row;
flex-wrap: wrap;
gap: 0;
}
.featured :global(.art) {
flex: 1 1 240px;
min-width: 0;
height: 260px;
border-radius: 0;
border-right: 2px solid var(--gk-gold);
}
.body {
flex: 2 1 300px;
min-width: 0;
padding: 26px;
display: flex;
flex-direction: column;
gap: 10px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.label {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h4 {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.2;
}
h4 a {
color: var(--gk-text);
text-decoration: none;
}
h4 a::after {
content: "";
position: absolute;
inset: 0;
}
.tags a {
position: relative;
z-index: 1;
}
.ftitle {
font-size: 24px;
}
.rtitle {
font-size: 17px;
}
.summary {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-muted);
}
.date {
margin-top: auto;
font-size: 12px;
color: var(--gk-muted);
}
.recent {
flex: 1 1 280px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.small {
padding: 18px 20px;
}
@media (max-width: 640px) {
.featured :global(.art) {
border-right: 0;
border-bottom: 2px solid var(--gk-gold);
height: 200px;
flex-basis: 100%;
}
}
</style>
@@ -0,0 +1,67 @@
---
/** A row link inside a landing or About door: a title, an optional line, and an arrow. */
import Icon from "@gk/ui/components/Icon.astro";
interface Props {
href: string;
title: string;
line?: string;
size?: "large" | "small";
}
const { href, title, line, size = "large" } = Astro.props;
---
<li>
<a href={href} class:list={["door-link", size]}>
<span class="words">
<span class="t">{title}</span>
{line && <span class="l">{line}</span>}
</span>
<Icon name="arrow-right" size={size === "large" ? 22 : 16} />
</a>
</li>
<style>
li {
list-style: none;
}
.door-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--gk-starlight);
text-decoration: none;
transition: color var(--gk-ease);
}
.door-link:hover {
color: var(--gk-gold-bright);
}
.large {
min-height: 62px;
padding: 8px 0;
border-bottom: 1px solid rgba(244, 242, 237, 0.35);
}
.small {
min-height: 48px;
border-top: 1px solid var(--gk-lines);
font-weight: 600;
}
.words {
display: flex;
flex-direction: column;
min-width: 0;
}
.large .t {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
letter-spacing: 0.05em;
}
.l {
font-size: 14px;
color: var(--gk-soft);
}
.door-link :global(svg) {
flex: none;
}
</style>
@@ -0,0 +1,87 @@
---
/**
* One status on the landing's Now strip: kind, subject, a meta line, the
* note and when it was updated. The card links to the status on /now.
*/
import Star from "@gk/ui/components/Star.astro";
import { formatDate } from "../../lib/site.ts";
import type { StatusView } from "../../lib/home.ts";
interface Props {
view: StatusView;
}
const { view: v } = Astro.props;
---
<li class="now-card">
<span class="kind">
{v.kind}
{v.pinned && <span class="pin"><Star size={9} /><span class="gk-visually-hidden">Pinned</span></span>}
</span>
<h3 class="title">
{v.href ? <a href={v.href}>{v.title}</a> : v.title}
</h3>
{v.meta && <span class="meta">{v.meta}</span>}
{v.note && <p class="note">{v.note}</p>}
<a class="when" href={`/now#${v.slug}`}>Updated <time datetime={v.date}>{formatDate(v.date)}</time></a>
</li>
<style>
.now-card {
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
padding: 22px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kind {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.pin {
display: inline-flex;
}
.title {
margin: 0;
font-size: 18px;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-teal-bright);
}
.meta {
font-size: 13px;
color: var(--gk-muted);
}
.note {
margin: 4px 0 0;
padding-left: 12px;
border-left: 2px solid var(--gk-gold);
font-size: 14px;
line-height: 1.55;
}
.when {
margin-top: auto;
padding-top: 8px;
font-size: 12px;
color: var(--gk-muted);
text-decoration: none;
}
.when:hover {
color: var(--gk-text);
}
</style>
@@ -0,0 +1,53 @@
---
/**
* Greg's portrait in a gold frame. No portrait file exists yet, so the
* star-chart art stands in; pass `src` once there is one.
*/
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
interface Props {
/** "hero": the thick gold frame on the landing; "panel": the framed panel on About. */
variant?: "hero" | "panel";
src?: string;
alt?: string;
seal?: SealName;
class?: string;
}
const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", class: className } = Astro.props;
---
<figure class:list={["portrait", variant, className]}>
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
{variant === "panel" && <GiltCorners inset={4} size={30} />}
{!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
</figure>
<style>
.portrait {
position: relative;
margin: 0;
}
.hero {
width: 200px;
border: 3px solid var(--gk-gold);
border-radius: var(--gk-radius-lg);
padding: 6px;
background: rgba(106, 47, 224, 0.12);
}
.hero :global(.art) {
border-radius: var(--gk-radius);
}
.panel {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(201, 162, 74, 0.55);
background: var(--gk-midnight);
}
.panel :global(.art) {
border-radius: 6px;
width: 100%;
height: 320px;
}
</style>
@@ -0,0 +1,181 @@
---
/**
* One status on the Now page, an anchor at its slug: a thumbnail, the kind
* (and Pinned), the subject linked to its page, the note, a chip to the page
* the kind belongs to, a post about the subject when there is one, and the date.
*/
import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url";
import Chip from "@gk/ui/components/Chip.astro";
import Icon from "@gk/ui/components/Icon.astro";
import Star from "@gk/ui/components/Star.astro";
import type { StatusView } from "../../lib/home.ts";
import { formatDate, type SiteIndex } from "../../lib/site.ts";
interface Props {
view: StatusView;
site: SiteIndex;
}
const { view: v, site: s } = Astro.props;
const post = v.subject ? s.backlinks(v.subject, { type: "post" })[0] : undefined;
const postHref = post ? s.href(post) : null;
---
<li id={v.slug} class="row">
{
v.cover ? (
<img src={v.cover} alt="" class="thumb photo" loading="lazy" decoding="async" width="56" height="56" />
) : (
<span class="thumb tile" aria-hidden="true">
<img src={monogram} alt="" width="28" height="28" />
</span>
)
}
<div class="main">
<span class="labels">
<span class="kind"><Icon name={v.icon} size={14} />{v.kind}</span>
{v.pinned && <span class="pinned"><Star size={9} />Pinned</span>}
</span>
<h3 class="title">{v.href ? <a href={v.href}>{v.title}</a> : v.title}</h3>
{v.meta && <span class="meta">{v.meta}</span>}
{v.note && <p class="note">{v.note}</p>}
</div>
<div class="side">
{v.chip && <Chip href={v.chip.href}>{v.chip.label}</Chip>}
{
post && postHref && (
<a href={postHref} class="gk-text post">
<Icon name="list-text" size={14} />
{post.title}
</a>
)
}
<span class="date">Updated <time datetime={v.date}>{formatDate(v.date)}</time></span>
</div>
</li>
<style>
.row {
display: flex;
gap: 16px;
align-items: center;
padding: 14px 0;
border-top: 1px solid var(--gk-lines);
scroll-margin-top: 24px;
}
.row:target {
background: linear-gradient(90deg, var(--gk-gold-tint), transparent 70%);
}
.thumb {
flex: 0 0 56px;
width: 56px;
height: 56px;
border-radius: 8px;
border: 1px solid var(--gk-lines);
outline: 1px solid rgba(201, 162, 74, 0.35);
outline-offset: 2px;
}
.photo {
object-fit: cover;
}
.tile {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse at 30% 20%, rgba(106, 47, 224, 0.3), transparent 60%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 14px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 14px),
var(--gk-sky);
}
.tile img {
opacity: 0.5;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.labels {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.kind,
.pinned {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
}
.kind {
color: var(--gk-teal-bright);
}
.pinned {
gap: 4px;
color: var(--gk-gold-bright);
}
.title {
margin: 0;
font-size: 17px;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-teal-bright);
}
.meta,
.note {
margin: 0;
font-size: 13px;
color: var(--gk-muted);
}
.note {
color: var(--gk-soft);
}
.side {
flex: 0 1 260px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
text-align: right;
}
.post {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.date {
font-size: 12px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.row {
flex-wrap: wrap;
align-items: flex-start;
}
.main {
flex: 1 1 calc(100% - 72px);
}
.side {
flex: 1 1 100%;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 8px 14px;
padding-left: 72px;
text-align: left;
}
}
</style>
@@ -0,0 +1,145 @@
---
/**
* The favorites constellation: round covers strung on a dotted gold line,
* each linking to its row in the list.
*/
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { mediaSrc } from "../../lib/site.ts";
import type { Item } from "../../lib/library.ts";
interface Props {
items: Item[];
}
const { items } = Astro.props;
const lift = [18, 0, 26, 0, 22, 2, 20];
const n = items.length;
const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lift[i % lift.length]! + 40}`).join(" ");
---
{
n > 0 && (
<section aria-labelledby="lib-fav" class="fav">
<div class="head">
<h2 id="lib-fav" class="kick">
<Star size={14} />
The favorites constellation
</h2>
<TickScale width={150} height={10} count={30} />
</div>
<div class="sky">
<svg aria-hidden="true" class="line" viewBox="0 0 1000 154" preserveAspectRatio="none">
<polyline points={points} fill="none" stroke="#C9A24A" stroke-width="1.4" stroke-dasharray="2 6" vector-effect="non-scaling-stroke" />
</svg>
<ul style={`--n:${n}`}>
{
items.map((item, i) => (
<li style={`--lift:${lift[i % lift.length]}px`}>
<a href={`#${item.slug}`}>
<img src={mediaSrc(item.data.cover)} alt="" width="80" height="80" decoding="async" />
<span>{item.title}</span>
</a>
</li>
))
}
</ul>
</div>
</section>
)
}
<style>
.fav {
padding: 0 0 8px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 18px;
}
.kick {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.sky {
position: relative;
}
ul {
position: relative;
display: grid;
grid-template-columns: repeat(var(--n), minmax(0, 1fr));
min-height: 154px;
margin: 0;
padding: 0;
list-style: none;
}
.line {
position: absolute;
inset: 0;
width: 100%;
height: 154px;
pointer-events: none;
}
li {
display: flex;
justify-content: center;
padding-top: var(--lift);
}
a {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 112px;
max-width: 100%;
color: var(--gk-text);
text-decoration: none;
}
img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--gk-midnight);
box-shadow:
0 0 0 2px var(--gk-gold),
0 0 22px rgba(201, 162, 74, 0.45);
transition: box-shadow 0.2s;
}
a:hover img {
box-shadow:
0 0 0 2px var(--gk-gold-bright),
0 0 30px rgba(201, 162, 74, 0.7);
}
span {
font-size: 13px;
font-weight: 600;
text-align: center;
line-height: 1.3;
}
@media (max-width: 760px) {
ul {
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 20px;
}
.line {
display: none;
}
li {
padding-top: 0;
}
img {
width: 64px;
height: 64px;
}
}
</style>
@@ -0,0 +1,61 @@
---
/**
* The rail-and-list layout both tabs use. The rail is a GET form, so filters
* work without JS (an Apply button submits it); the page script filters live
* and hides the button.
*/
interface Props {
action: string;
/** For the page script to find the form. */
formId: string;
}
const { action, formId } = Astro.props;
---
<div class="layout">
<aside aria-label="Filters" class="rail">
<form id={formId} method="get" action={action} class="rail-form">
<input type="hidden" name="f" value="1" />
<slot name="rail" />
<button type="submit" class="gk-btn gk-sec gk-sm apply" data-nojs>Apply filters</button>
</form>
</aside>
<div class="main">
<slot />
</div>
</div>
<style>
.layout {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding: 24px 0 72px;
}
.rail {
flex: 1 1 220px;
max-width: 260px;
min-width: 0;
}
.rail-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.apply {
align-self: flex-start;
}
.main {
flex: 3 1 600px;
min-width: 0;
}
@media (max-width: 760px) {
.layout {
gap: 28px;
}
.rail {
max-width: none;
flex-basis: 100%;
}
}
</style>
@@ -0,0 +1,130 @@
---
/**
* One board game on The Shelf: box art (or star-chart art), how it plays,
* title, blurb, its states and a link to BoardGameGeek. The card is the
* game's anchor (`#slug`); the data-* attributes feed the filter and sort
* script.
*/
import Card from "@gk/ui/components/Card.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import type { Game } from "../../lib/library.ts";
interface Props {
game: Game;
site: SiteIndex;
hidden?: boolean;
}
const { game, site, hidden } = Astro.props;
const d = game.data;
const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
---
<li
id={game.slug}
class="game"
data-p={plays.map((p) => p.toLowerCase()).join("|") || undefined}
data-o={d.owned ? "1" : undefined}
data-w={d.wantToPlay ? "1" : undefined}
data-f={d.favorite ? "1" : undefined}
data-lp={d.lastPlayed}
data-c={d.created}
hidden={hidden}
>
<Card as="div" class="game-card">
<StarChartArt class="box" ratio="1 / 1" seal="tria-prima" src={mediaSrc(d.cover)} alt="" />
<div class="body">
{plays.length > 0 && <span class="plays">{plays.join(" · ")}</span>}
<h3 class="t">
{game.title}
{d.favorite && <><Star size={12} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
<p class="blurb">{d.blurb}</p>
{
(status || d.wantToPlay || !d.owned || d.lastPlayed) && (
<div class="states">
{status && <StatusChip status={status} site={site} />}
{d.wantToPlay && <StatusPill kind="state">Want to play</StatusPill>}
{!d.owned && <StatusPill kind="neutral">Not owned</StatusPill>}
{d.lastPlayed && <span class="played">Last played {formatDate(d.lastPlayed)}</span>}
</div>
)
}
<a class="gk-text gk-quiet bgg" href={`https://boardgamegeek.com/boardgame/${d.bggId}`} rel="external noopener">
BoardGameGeek<span class="gk-visually-hidden">: {game.title}</span>
</a>
</div>
</Card>
</li>
<style>
.game {
display: flex;
scroll-margin-top: 24px;
}
.game[hidden] {
display: none;
}
.game :global(.game-card) {
flex: 1;
padding: 0;
gap: 0;
overflow: hidden;
}
.game:target :global(.game-card) {
border-color: var(--gk-gold);
}
.game :global(.box) {
border-radius: 0;
border-bottom: 1px solid var(--gk-lines);
}
.body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.plays {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-muted);
}
.t {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
line-height: 1.3;
letter-spacing: 0.02em;
}
.blurb {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--gk-muted);
}
.states {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.played {
font-size: 12px;
color: var(--gk-muted);
}
.bgg {
align-self: flex-start;
margin-top: auto;
padding-top: 4px;
font-size: 12px;
}
</style>
@@ -0,0 +1,81 @@
---
/**
* The hero both Library tabs share: the title, the tab control linking The
* Library and The Shelf, and the page's lede.
*/
import PageHero from "../PageHero.astro";
interface Props {
current: "library" | "shelf";
lede?: string;
}
const { current, lede } = Astro.props;
const tabs = [
{ key: "library", href: "/library", name: "The Library", blurb: "Books, video games, anime, TV, movies" },
{ key: "shelf", href: "/library/shelf", name: "The Shelf", blurb: "Board games" },
];
---
<PageHero eyebrow="My world" title="Library" />
<nav aria-label="Library sections">
<ul class="tabs">
{
tabs.map((t) => (
<li>
<a href={t.href} class="tab" aria-current={t.key === current ? "page" : undefined}>
<span class="name">{t.name}</span>
<span class="blurb">{t.blurb}</span>
</a>
</li>
))
}
</ul>
</nav>
{lede && <p class="lede">{lede}</p>}
<style>
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px 36px;
margin: 8px 0 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.tab {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 4px 14px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--gk-muted);
text-decoration: none;
transition: color var(--gk-ease), border-color var(--gk-ease);
}
.tab:hover {
color: var(--gk-text);
border-bottom-color: var(--gk-gold-line);
}
.tab[aria-current] {
color: var(--gk-text);
border-bottom-color: var(--gk-gold);
}
.name {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.06em;
}
.blurb {
font-size: 13px;
font-weight: 500;
}
.lede {
margin: 6px 0 0;
font-size: 17px;
line-height: 1.6;
color: var(--gk-soft);
max-width: 64ch;
}
</style>
@@ -0,0 +1,162 @@
---
/**
* One Library item as a fixed-height row: cover (or star-chart art), title,
* verdict and note, details, the newest blog post about it, and on the right
* its Now chip and medium. The row is the item's anchor (`#slug`); the data-*
* attributes feed the filter script. Styles are global (lib-* classes) so
* hundreds of rows carry no scoping attributes.
*/
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusChip from "../StatusChip.astro";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import { mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
interface Props {
item: Item;
site: SiteIndex;
hidden?: boolean;
}
const { item, site, hidden } = Astro.props;
const d = item.data;
const status = site.statusFor(item);
const post = site.backlinks(item, { type: "post" })[0];
const postHref = post && site.href(post);
const details = [d.creator, d.year, d.platform, d.progress, d.group].filter((x) => x !== undefined && x !== "");
---
<li
id={item.slug}
class="lib-row"
data-m={mediaGroupOf(item)}
data-s={sectionOf(item)}
data-f={d.favorite ? "1" : undefined}
data-b={post ? "1" : undefined}
hidden={hidden}
>
<StarChartArt class="lib-cover" ratio="2 / 3" seal="zodiac" src={mediaSrc(d.cover)} />
<div class="lib-body">
<h3 class="lib-t">
{item.title}
{d.favorite && <><Star size={14} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
{d.verdict && <p class="lib-v">{d.verdict}</p>}
{d.note && <p class="lib-v lib-n">{d.note}</p>}
{details.length > 0 && <p class="lib-d">{details.join(" · ")}</p>}
{
post && postHref && (
<a href={postHref} class="lib-blog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v14H4zM8 9h8M8 13h5" /></svg>
Blog: {post.title}
</a>
)
}
</div>
<div class="lib-side">
{status && <StatusChip status={status} site={site} />}
<span class="lib-med">{mediumLabel(d.medium)}</span>
</div>
</li>
<style is:global>
.lib-row {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 0;
border-top: 1px solid var(--gk-lines);
scroll-margin-top: 24px;
}
.lib-row:target {
background: linear-gradient(90deg, var(--gk-gold-tint), transparent 70%);
}
.lib-row[hidden] {
display: none !important;
}
.lib-row .lib-cover.gk-art {
flex: 0 0 48px;
width: 48px;
height: 72px;
border-radius: 6px;
border: 1px solid var(--gk-lines);
}
.lib-row .lib-cover .mat {
inset: 4px;
border-radius: 3px;
opacity: 0.6;
}
.lib-row .lib-cover .seal {
width: 30px;
height: 30px;
}
.lib-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.lib-t {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
letter-spacing: 0.02em;
line-height: 1.3;
}
.lib-v,
.lib-d {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--gk-muted);
}
.lib-n {
font-style: italic;
}
.lib-d {
font-size: 12px;
color: var(--gk-placeholder);
}
.lib-blog {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.lib-side {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.lib-med {
font-size: 12px;
letter-spacing: 0.12em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-muted);
}
@media (max-width: 560px) {
.lib-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 8px 14px;
}
.lib-row .lib-cover.gk-art {
grid-row: span 2;
}
.lib-side {
grid-column: 2;
flex-direction: row;
align-items: center;
}
}
</style>
@@ -0,0 +1,55 @@
---
/** A list group's heading: a state dot, the name in Cinzel, and the gold underline. */
interface Props {
id: string;
dot: "live" | "open" | "done";
}
const { id, dot } = Astro.props;
---
<div class="lh">
<h2 id={id}><span class:list={["dot", dot]} aria-hidden="true"></span><slot /></h2>
<span class="ul" aria-hidden="true"></span>
</div>
<style>
.lh {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
letter-spacing: 0.08em;
}
.dot {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
}
.live {
background: var(--gk-teal-bright);
box-shadow: 0 0 10px var(--gk-teal-bright);
}
.open {
border: 1px solid var(--gk-text);
}
.done {
background: var(--gk-muted);
}
.ul {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
</style>
@@ -0,0 +1,64 @@
---
/**
* One group in a filter rail: a gold kicker legend over checkboxes, links or
* filter pills. Checkboxes go in as `<label class="check"><input …>Text</label>`.
*/
interface Props {
legend: string;
/** Lay the contents out as a row of pills. */
pills?: boolean;
}
const { legend, pills } = Astro.props;
---
<fieldset class:list={["rail-group", { pills }]}>
<legend>{legend}</legend>
<div class="items"><slot /></div>
</fieldset>
<style>
.rail-group {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
legend {
padding: 0;
margin-bottom: 10px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.items {
display: flex;
flex-direction: column;
gap: 10px;
}
.pills .items {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.items :global(.check) {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
font-size: 14px;
color: var(--gk-soft);
cursor: pointer;
}
.items :global(.check input) {
flex: none;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--gk-teal);
cursor: pointer;
}
.items :global(.check:hover) {
color: var(--gk-text);
}
</style>
@@ -0,0 +1,27 @@
---
/** "STORE / TABLETOP": the gold trail above a store page's title. */
interface Props {
here: string;
}
const { here } = Astro.props;
---
<nav aria-label="Breadcrumb" class="crumb">
<a href="/store">Store</a> <span aria-hidden="true">/</span> <span aria-current="page">{here}</span>
</nav>
<style>
.crumb {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.crumb a {
color: inherit;
text-decoration: none;
}
.crumb a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,231 @@
---
/**
* A product line on the Store: its lead image (star chart fallback), a ringed
* medallion with its icon, title, summary, three specs, its status and the
* price it starts from. Lines sold elsewhere link out to their shop.
*/
import Button from "@gk/ui/components/Button.astro";
import Icon from "@gk/ui/components/Icon.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import { mediaSrc } from "../../lib/site.ts";
import { channelOf, hasCapacity, lineIcon, priceSummary, shopLink, statusText, type Line } from "../../lib/store.ts";
interface Props {
line: Line;
/** The category's orders are open. */
open: boolean;
}
const { line, open } = Astro.props;
const d = line.data;
const img = d.images?.[0];
const price = priceSummary(line);
const shop = shopLink(line);
const ch = channelOf(line);
const soon = ch === "coming soon";
const status = statusText(line);
const live = ch === "here" && open && hasCapacity(line);
const headId = `line-${line.slug}`;
---
<article class:list={["line", { soon }]} aria-labelledby={headId}>
{
!soon && (
<StarChartArt seal="craft-dice" ratio="16 / 9" src={mediaSrc(img?.file)} alt={img?.alt ?? ""} class="art" />
)
}
<div class="medal-row" aria-hidden="true">
<TickRing size={soon ? 60 : 76} medallion class="medal">
<Icon name={lineIcon(line)} size={soon ? 26 : 34} />
</TickRing>
</div>
<div class="body">
<div class="top">
<StatusPill kind={live ? "live" : "state"}>{status}</StatusPill>
</div>
<div class="head">
<h3 id={headId}>{line.title}</h3>
<span class="gk-underline" aria-hidden="true"></span>
</div>
<p class="summary">{d.summary}</p>
{
!!d.specs?.length && (
<ul class="specs">
{d.specs.slice(0, 3).map((sp) => (
<li>
<Star size={10} />
<span>
<span class="spec-label">{sp.label}:</span> {sp.value}
</span>
</li>
))}
</ul>
)
}
{(price || shop || !soon) && (
<div class="foot">
{
price ? (
<span class="price">
<span class="from">From</span>
<span class="amount">{price.from}</span>
<span class="more">{[price.note, ...price.more].filter(Boolean).join(" · ")}</span>
</span>
) : (
<span />
)
}
{
shop ? (
<Button variant="secondary" href={shop.href} rel="noopener" arrow>
{shop.label}
</Button>
) : (
!soon && (
<Button variant="secondary" href={`/store/${line.slug}`} arrow aria-label={`Details and pricing: ${line.title}`}>
Details and pricing
</Button>
)
)
}
</div>
)}
</div>
</article>
<style>
.line {
flex: 1 1 460px;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
overflow: hidden;
}
.line.soon {
flex-basis: 300px;
}
.line :global(.art) {
border-radius: 0;
border-bottom: 1px solid var(--gk-lines);
max-height: 260px;
width: 100%;
}
.medal-row {
position: relative;
height: 0;
}
.medal-row :global(.medal) {
position: absolute;
right: 24px;
top: -38px;
}
.soon .medal-row :global(.medal) {
top: 22px;
}
.soon .head {
padding-right: 72px;
}
.body {
padding: 28px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.top {
display: flex;
padding-right: 84px;
}
.head {
display: flex;
flex-direction: column;
gap: 8px;
}
h3 {
margin: 0;
font-size: 28px;
letter-spacing: 0.05em;
line-height: 1.15;
}
.soon h3 {
font-size: 22px;
}
.gk-underline {
display: block;
width: 180px;
max-width: 60%;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.summary {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-soft);
}
.specs {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
line-height: 1.5;
color: var(--gk-soft);
}
.specs li {
display: flex;
gap: 8px;
}
.specs li :global(svg) {
flex: none;
margin-top: 5px;
}
.spec-label {
color: var(--gk-muted);
}
.foot {
margin-top: auto;
padding-top: 12px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--gk-lines);
}
.soon .foot {
border-top: 0;
padding-top: 0;
}
.price {
display: flex;
flex-direction: column;
}
.from,
.more {
font-size: 12px;
color: var(--gk-muted);
}
.amount {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 28px;
color: var(--gk-gold-bright);
line-height: 1.1;
}
@media (max-width: 640px) {
.body {
padding: 22px 18px;
}
}
</style>
@@ -0,0 +1,47 @@
---
/**
* A line's status panel: the SlotMeter for a made to order line sold here,
* its stock for an in stock line, or where it is sold.
*/
import SlotMeter from "@gk/ui/components/SlotMeter.astro";
import { channelOf, statusText, type Line } from "../../lib/store.ts";
interface Props {
line: Line;
label?: string;
class?: string;
}
const { line, label = line.title, class: className } = Astro.props;
const ch = channelOf(line);
const slots = ch === "here" && line.data.mode === "made to order" ? line.data.slots : undefined;
---
{
slots ? (
<SlotMeter label={label} open={slots.open} total={slots.total} class={className} />
) : (
<div class:list={["box", className]}>
<span class="label">{label}</span>
<span class="count">{statusText(line)}</span>
</div>
)
}
<style>
.box {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.label {
font-size: 14px;
font-weight: 600;
}
.count {
font-size: 13px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,288 @@
---
/**
* The order request form. Posts to /api/orders. Field names:
* name, email, line (a line slug), option.<line slug>.<group> (an option
* name), quantity, details, notes, terms ("accepted").
* Without JS every line's options show, labelled with their line; a small
* script shows only the chosen line's options and terms.
*/
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import Star from "@gk/ui/components/Star.astro";
import { optionGroups, type Line, type Term } from "../../lib/store.ts";
import Terms from "./Terms.astro";
interface Props {
lines: Line[];
selected?: string;
storeTerms: Term[];
}
const { lines, selected, storeTerms } = Astro.props;
const chosen = lines.find((l) => l.slug === selected)?.slug ?? (lines.length === 1 ? lines[0]!.slug : undefined);
const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
---
<form method="post" action="/api/orders" class="order" aria-labelledby="order-title" data-order-form>
<h2 id="order-title" class="gk-visually-hidden">Order request form</h2>
<div class="row">
<Field label="Name" name="name" autocomplete="name" required class="half" />
<Field label="Email" name="email" type="email" autocomplete="email" required class="half" />
</div>
<fieldset>
<legend>What would you like?</legend>
<div class="choices">
{
lines.map((l) => (
<label class:list={["gk-filter", "choice", { on: l.slug === chosen }]}>
<input type="radio" name="line" value={l.slug} checked={l.slug === chosen} required />
<span class="gk-fs" aria-hidden="true"><Star size={10} /></span>
{l.title}
</label>
))
}
</div>
</fieldset>
{
lines.flatMap((l) =>
optionGroups(l).map(([group, opts]) => (
<fieldset class="opts" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined} disabled={chosen ? l.slug !== chosen : undefined}>
<legend>
{group} {lines.length > 1 && <span class="for">({l.title} only)</span>}
</legend>
<div class="choices">
{opts.map((o, i) => (
<label class:list={["gk-filter", "swatch", { out: !o.inStock }]}>
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
{o.hex && <span class="dot" style={`background:${o.hex}`} aria-hidden="true" />}
{o.name}
{!o.inStock && <span class="stock">(out of stock)</span>}
</label>
))}
</div>
</fieldset>
)),
)
}
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required />
<Field
label="Card list or deck box spec"
name="details"
as="textarea"
rows={6}
hint="For cards, paste your full card list, one per line."
class="big"
/>
<Field label="Notes (optional)" name="notes" as="textarea" rows={3} />
{
(lines.some((l) => l.data.terms?.length) || storeTerms.length > 0) && (
<details class="terms">
<summary>Order terms</summary>
{lines.map((l) =>
l.data.terms?.length ? (
<div class="line-terms" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined}>
{lines.length > 1 && <h3>{l.title}</h3>}
<Terms terms={l.data.terms} />
</div>
) : null,
)}
{storeTerms.length > 0 && (
<div class="line-terms">
{lines.length > 1 && <h3>Every order</h3>}
<Terms terms={storeTerms} />
</div>
)}
</details>
)
}
<label class="ack">
<input type="checkbox" name="terms" value="accepted" required />
<span>I have read and accept the order terms for this request.</span>
</label>
<div class="submit">
<Button type="submit">Send order request</Button>
</div>
</form>
<script>
for (const form of document.querySelectorAll<HTMLFormElement>("[data-order-form]")) {
const sync = () => {
const line = form.querySelector<HTMLInputElement>('input[name="line"]:checked')?.value;
for (const el of form.querySelectorAll<HTMLElement>("[data-line]")) {
const show = !line || el.dataset.line === line;
el.hidden = !show;
if (el instanceof HTMLFieldSetElement) el.disabled = !show;
}
for (const lab of form.querySelectorAll("label.choice")) {
lab.classList.toggle("on", !!lab.querySelector<HTMLInputElement>("input:checked"));
}
};
form.addEventListener("change", sync);
sync();
}
</script>
<style>
.order {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
padding: 32px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.row :global(.half) {
flex: 1 1 240px;
}
.order :global(.gk-label-text) {
font-size: 14px;
color: var(--gk-text);
}
.order :global(input.gk-field) {
height: var(--gk-control);
}
.order :global(textarea.gk-field) {
padding: 10px 14px;
resize: vertical;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
fieldset[hidden] {
display: none;
}
legend {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
padding: 0;
}
.for {
font-weight: 400;
color: var(--gk-muted);
}
.choices {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.choice {
flex: 1 1 200px;
min-height: var(--gk-control);
}
.gk-filter input {
accent-color: var(--gk-teal);
margin: 0;
}
.choice input,
.swatch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.choice,
.swatch {
position: relative;
}
.choice:focus-within,
.swatch:focus-within {
outline: 2px solid var(--gk-gold-bright);
outline-offset: 3px;
}
.choice:has(input:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.choice:has(input:checked) .gk-fs {
display: inline-flex;
}
.swatch {
gap: 8px;
}
.swatch.out {
opacity: 0.5;
cursor: not-allowed;
}
.swatch:has(input:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(236, 233, 226, 0.35);
flex: none;
}
.stock {
font-weight: 400;
color: var(--gk-muted);
}
.terms {
padding: 14px 16px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.terms summary {
font-weight: 700;
font-size: 14px;
cursor: pointer;
}
.line-terms {
margin-top: 12px;
}
.line-terms[hidden] {
display: none;
}
.line-terms h3 {
margin: 0 0 8px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.ack {
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 13px;
line-height: 1.55;
color: var(--gk-soft);
}
.ack input {
margin-top: 3px;
width: 18px;
height: 18px;
flex: none;
accent-color: var(--gk-teal);
}
.submit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
@media (max-width: 640px) {
.order {
padding: 22px 16px;
}
}
</style>
@@ -0,0 +1,94 @@
---
/**
* Color options as round swatches. With `names`, each swatch is listed with
* its name and stock; without, only the swatches show (names on hover and for
* screen readers).
*/
import type { Line } from "../../lib/store.ts";
type Option = NonNullable<Line["data"]["options"]>[number];
interface Props {
options: Option[];
names?: boolean;
class?: string;
}
const { options, names, class: className } = Astro.props;
---
{
names ? (
<ul class:list={["named", className]}>
{options.map((o) => (
<li class:list={{ out: !o.inStock }}>
{o.hex ? <span class="dot" style={`background:${o.hex}`} aria-hidden="true" /> : <span class="dot none" aria-hidden="true" />}
<span class="name">{o.name}</span>
{!o.inStock && <span class="stock">out of stock</span>}
</li>
))}
</ul>
) : (
<ul class:list={["swatches", className]}>
{options.map((o) => (
<li title={o.name} class:list={{ out: !o.inStock }}>
<span class="sw" style={o.hex ? `background:${o.hex}` : undefined} aria-hidden="true" />
<span class="gk-visually-hidden">
{o.name}
{!o.inStock && ", out of stock"}
</span>
</li>
))}
</ul>
)
}
<style>
ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
}
.swatches {
gap: 8px;
}
.sw {
display: block;
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--gk-lines);
box-shadow: 0 0 0 1px rgba(236, 233, 226, 0.25);
}
.named {
gap: 8px;
}
.named li {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 14px 0 10px;
border: 1px solid var(--gk-lines);
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--gk-soft);
}
.dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(236, 233, 226, 0.35);
flex: none;
}
.dot.none {
background: var(--gk-lines);
}
.out {
opacity: 0.5;
}
.stock {
font-weight: 400;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,49 @@
---
/** Order terms as ledger notes, each with its kind. */
import Notice from "@gk/ui/components/Notice.astro";
import type { Term } from "../../lib/store.ts";
interface Props {
terms: Term[];
}
const { terms } = Astro.props;
const kindLabel = { info: "Good to know", warning: "Please note", rule: "Rule" } as const;
---
<ul class="terms">
{
terms.map((t) => (
<li>
<Notice title={t.title} role="note" class={`term ${t.kind}`}>
<span class="gk-visually-hidden">{kindLabel[t.kind]}: </span>
{t.body}
</Notice>
</li>
))
}
</ul>
<style>
.terms {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.terms :global(.gk-note) {
align-items: flex-start;
font-size: 14px;
line-height: 1.55;
}
.terms :global(.gk-note > svg) {
margin-top: 5px;
flex: none;
}
.terms :global(.term.warning) {
background: rgba(201, 162, 74, 0.12);
}
.terms :global(.term.rule) {
border-left-color: var(--gk-gold-bright);
}
</style>
@@ -0,0 +1,75 @@
---
/**
* A single-choice question drawn as filter pills: real radio buttons inside
* gk-filter labels, so it works without a script and posts with the form.
*/
import Star from "@gk/ui/components/Star.astro";
interface Props {
legend: string;
name: string;
options: string[];
/** The option checked at first. */
checked?: string;
}
const { legend, name, options, checked } = Astro.props;
---
<fieldset class="pills">
<legend>{legend}</legend>
<div class="row">
{
options.map((o) => (
<label class="gk-filter pill">
<input type="radio" name={name} value={o} checked={o === checked} class="sr" />
<span class="gk-fs star"><Star size={9} /></span>
{o}
</label>
))
}
</div>
</fieldset>
<style>
.pills {
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
legend {
padding: 0;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sr {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pill {
position: relative;
}
.pill:has(:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.pill:has(:checked) .star {
display: inline-flex;
}
.pill:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
</style>
@@ -0,0 +1,209 @@
---
/**
* The one gilt panel on Work: the featured case study with its role, summary,
* tech, key facts and a link to the full study.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import type { Note } from "../../lib/content.ts";
import { workGroup, workKicker } from "../../lib/work.ts";
interface Props {
study: Note<"post">;
href: string | null;
core: Set<string>;
}
const { study, href, core } = Astro.props;
const d = study.data;
const facts = d.facts ?? [];
---
<section aria-labelledby="work-featured" class="featured-wrap" data-group={workGroup(study)}>
<article class="panel">
<GiltCorners inset={4} />
<div class="inner">
<Seal name="transmutation" size={340} class="ghost" />
<div class="main">
<span class="kicker"><Star size={14} />Featured · {workKicker(study)}</span>
<div class="heading">
<h2 id="work-featured">{d.title}</h2>
<span class="underline" aria-hidden="true"></span>
</div>
{d.role && <span class="role">{d.role}</span>}
{d.period && <span class="period">{d.period}</span>}
{d.summary && <p class="summary">{d.summary}</p>}
{
d.tech?.length ? (
<div class="chips">
{d.tech.map((t) => (
<Chip core={core.has(t.toLowerCase())}>{t}</Chip>
))}
</div>
) : null
}
{href && <Button href={href} arrow class="cta">Read the case study</Button>}
</div>
{
facts.length > 0 && (
<div class="side">
<dl class="facts">
{facts.map((f) => (
<div class="fact">
<dt>{f.label}</dt>
<dd>
<span class="value">{f.value}</span>
<TickScale width={110} height={10} count={22} class="ticks" />
</dd>
</div>
))}
</dl>
</div>
)
}
</div>
</article>
</section>
<style>
.featured-wrap {
padding: 24px 0 8px;
}
.panel {
position: relative;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid var(--gk-gold);
border-radius: var(--gk-radius);
padding: 8px;
}
.inner {
position: relative;
border: 1px solid rgba(138, 101, 22, 0.35);
border-radius: 6px;
padding: 36px;
display: flex;
flex-wrap: wrap;
gap: 36px;
}
.inner :global(.ghost) {
position: absolute;
right: -60px;
bottom: -80px;
opacity: 0.12;
pointer-events: none;
}
.main {
flex: 1 1 420px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
}
.kicker {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.heading {
display: flex;
flex-direction: column;
gap: 8px;
}
h2 {
margin: 0;
font-size: clamp(30px, 5vw, 40px);
letter-spacing: 0.06em;
line-height: 1.1;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.role {
font-size: 15px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.period {
margin-top: -8px;
font-size: 13px;
color: var(--gk-muted);
}
.summary {
margin: 0;
font-size: 16px;
line-height: 1.65;
color: var(--gk-muted);
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.main :global(.cta) {
align-self: flex-start;
margin-top: 6px;
}
.side {
flex: 1 1 360px;
min-width: 0;
position: relative;
}
.facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.fact {
flex: 1 1 140px;
display: flex;
flex-direction: column;
padding: 20px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
background: var(--gk-sky);
}
.fact dd {
margin: 0;
}
.value {
display: block;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 36px;
color: var(--gk-gold-bright);
line-height: 1;
}
.fact :global(.ticks) {
margin: 6px 0 2px;
}
.fact dt {
order: 1;
margin-top: 6px;
font-size: 14px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.inner {
padding: 24px 20px;
}
}
</style>
@@ -0,0 +1,93 @@
---
/** A case study in the Work grid: kicker, title, role, summary, tech and a link. */
import Chip from "@gk/ui/components/Chip.astro";
import type { Note } from "../../lib/content.ts";
import { workGroup, workKicker } from "../../lib/work.ts";
interface Props {
study: Note<"post">;
href: string | null;
core: Set<string>;
}
const { study, href, core } = Astro.props;
const d = study.data;
const Tag = href ? "a" : "article";
---
<li class="item" data-group={workGroup(study)}>
<Tag href={href ?? undefined} class:list={["gk-card", "study"]}>
<span class="kicker">{workKicker(study)}</span>
<h3 class="title">{d.title}</h3>
{d.role && <span class="role">{d.role}</span>}
{d.summary && <span class="summary">{d.summary}</span>}
{
d.tech?.length ? (
<span class="chips">
{d.tech.slice(0, 5).map((t) => (
<Chip core={core.has(t.toLowerCase())}>{t}</Chip>
))}
{d.tech.length > 5 && <Chip>+{d.tech.length - 5}</Chip>}
</span>
) : null
}
{
href && (
<span class="more">
Read the case study
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
)
}
</Tag>
</li>
<style>
.item {
display: flex;
min-width: 0;
}
.study {
flex: 1;
gap: 12px;
padding: 26px;
border-top: 3px solid var(--gk-gold);
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.title {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.05em;
}
.role {
font-size: 14px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.summary {
font-size: 15px;
line-height: 1.6;
color: var(--gk-muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
padding-top: 6px;
}
.more {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--gk-teal-bright);
}
</style>
@@ -0,0 +1,57 @@
---
/** The closing band on a world page: a line, a note and one or two buttons. */
interface Props {
title: string;
note?: string;
}
const { title, note } = Astro.props;
---
<aside class="cta" aria-label={title}>
<div class="words">
<p class="t">{title}</p>
{note && <p class="n">{note}</p>}
</div>
<div class="btns"><slot /></div>
</aside>
<style>
.cta {
margin-top: 48px;
padding: 32px 36px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.words {
display: flex;
flex-direction: column;
gap: 6px;
}
.t {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 24px;
letter-spacing: 0.05em;
}
.n {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.btns {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 640px) {
.cta {
padding: 24px 20px;
}
}
</style>
@@ -0,0 +1,129 @@
---
/**
* The "Right now" / "At the table now" strip: a gold kicker over a tick scale
* and a row of gold-framed cards, each with a thumbnail, a label, a name and
* a short note.
*/
import Kicker from "@gk/ui/components/Kicker.astro";
import Seal from "@gk/ui/components/Seal.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
export interface NowItem {
id?: string;
label: string;
title: string;
note?: string;
href?: string | null;
img?: string;
}
interface Props {
id: string;
heading: string;
items: NowItem[];
seal?: SealName;
}
const { id, heading, items, seal = "mini-astrolabe" } = Astro.props;
---
<section class="now" aria-labelledby={id}>
<h2 id={id} class="kick"><Kicker star>{heading}</Kicker></h2>
<ul class="row">
{
items.map((it) => {
const Tag = it.href ? "a" : "div";
return (
<li id={it.id}>
<Tag href={it.href ?? undefined} class="item">
{it.img ? (
<img src={it.img} alt="" class="thumb" loading="lazy" />
) : (
<span class="thumb blank" aria-hidden="true"><Seal name={seal} size={38} /></span>
)}
<span class="txt">
<span class="label">{it.label}</span>
<span class="title">{it.title}</span>
{it.note && <span class="note">{it.note}</span>}
</span>
</Tag>
</li>
);
})
}
</ul>
</section>
<style>
.now {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 28px;
}
.kick {
margin: 0;
font: inherit;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.row > li {
flex: 1 1 300px;
min-width: 0;
}
.item {
height: 100%;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
background: var(--gk-midnight);
border: 1px solid var(--gk-gold);
border-radius: var(--gk-radius);
color: var(--gk-text);
text-decoration: none;
transition: box-shadow var(--gk-ease);
}
a.item:hover {
box-shadow: 0 0 0 3px var(--gk-gold-tint);
}
.thumb {
flex: 0 0 64px;
width: 64px;
height: 64px;
border-radius: 8px;
object-fit: cover;
}
.blank {
display: flex;
align-items: center;
justify-content: center;
background: var(--gk-sky);
opacity: 0.8;
}
.txt {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.title {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 16px;
}
.note {
font-size: 13px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,70 @@
---
/**
* A page section on the world pages: a gold star, the Cinzel heading with the
* filament underline, an optional intro line, and an action at the right.
*/
import Star from "@gk/ui/components/Star.astro";
interface Props {
id: string;
title: string;
intro?: string;
class?: string;
}
const { id, title, intro, class: className } = Astro.props;
---
<section class:list={["ws", className]} aria-labelledby={id}>
<div class="head">
<div class="titles">
<h2 id={id}><Star size={14} />{title}</h2>
<span class="underline" aria-hidden="true"></span>
{intro && <p class="intro">{intro}</p>}
</div>
{Astro.slots.has("action") && <div class="action"><slot name="action" /></div>}
</div>
<slot />
</section>
<style>
.ws {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 32px;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.titles {
display: flex;
flex-direction: column;
gap: 8px;
}
h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.06em;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.intro {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
</style>