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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 109 additions & 5 deletions src/components/blog/BlogImageSVG.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,58 @@ interface Props {
const { slug, title } = Astro.props;
const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
/**
* Where the gradient's two stops sit, in the rect's own coordinates.
*
* The artwork is scaled inside its frame, so the frame shows the middle slice
* of the SVG and never its top or bottom edge. Stops at 0 and 1 would put both
* ends of the gradient in the part that is cropped away, and the visible slice
* would show neither the light colour nor the dark one. So they sit on the
* edges of the visible slice instead, and `spreadMethod` (pad, the default)
* fills the cropped remainder with the end colours.
*
* Both numbers stay inside 0..1. Moving the gradient VECTOR out of range
* instead — y1 below zero — is valid SVG and renders correctly in Chromium,
* but not on iOS.
*
* COVER_SCALE has to match the `scale()` in the stylesheet below.
*/
const COVER_SCALE = 1.35;
const visible = 1 / COVER_SCALE;
const stopTop = +((1 - visible) / 2).toFixed(4);
const stopFoot = +(1 - stopTop).toFixed(4);
---

{svgContent && (
<div class="svg-host" role="img" aria-label={title} set:html={svgContent} />
<>
{/*
The gradient the cover's `.bg` rect is filled with. It has to be a real
SVG paint server rather than a CSS gradient, because `fill` on an SVG
shape takes `url(#id)` and nothing else.
One def per cover, keyed by slug: SVG ids are document-global, and a page
can carry more than one cover — the blog index shows several — so a shared
id would paint them all from whichever parsed first. The id reaches the
stylesheet through --cover-bg on the host, since a scoped rule cannot
interpolate the slug itself.
*/}
<svg class="cover-grad" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={`blog-grad-${slug}`} x1="0" y1="0" x2="0" y2="1">
<stop offset={stopTop} class="g-top" />
<stop offset={stopFoot} class="g-foot" />
</linearGradient>
</defs>
</svg>
<div
class="svg-host"
role="img"
aria-label={title}
style={`--cover-bg: url(#blog-grad-${slug})`}
set:html={svgContent}
/>
</>
)}

<style>
Expand All @@ -26,11 +74,68 @@ const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
transform-origin: center center;
}

/* The def carries no picture of its own; it only has to be in the document
for the fill to resolve. Taken out of flow so it cannot add a line box. */
.cover-grad {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}

/* The cover opens a step down the brand ramp rather than at full brand-500,
so the gradient has somewhere to travel before it reaches brand-900 at the
foot. Mixed in sRGB, which is what the SVG gradient between these two
stops interpolates in, so the top stop sits on that same curve rather than
beside it. */
.cover-grad .g-top {
stop-color: color-mix(in srgb, var(--color-brand-900) 14%, var(--color-brand-500));
}
.cover-grad .g-foot {
stop-color: var(--color-brand-900);
}

.svg-host {
position: relative;
}

/* One rule, both modes. A flat fill for light and another for dark would sit
after this one at equal specificity and win on source order, handing light
mode its flat background back. The gradient does not flip between modes. */
.svg-host :global(.bg) {
fill: var(--cover-bg);
}

/* The grid, scaled to the picture rather than to the screen.

`background-size` in percentages rather than pixels: this grid is part of
the image, sixteen columns at every width, so a cover looks the same in a
card as it does across an article hero.

The cells stay square without being told to, because the frame's aspect
ratio is the artwork's 1200x630 at every size. 6.25% is a sixteenth of the
width; 11.9048% is the same number of pixels down a box 630/1200 as tall.

Over the artwork rather than under it: the cover opens with an opaque
`.bg` rect covering the whole canvas, so anything beneath it is hidden. */
.svg-host::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgb(255 255 255 / 0.09) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 0.09) 1px, transparent 1px);
background-size: 6.25% 11.9048%;
-webkit-mask-image: radial-gradient(ellipse at center, #000 45%, transparent 100%);
mask-image: radial-gradient(ellipse at center, #000 45%, transparent 100%);
}

/* ── Light mode ─────────────────────────────────────────────────────────
Brand-600 background — the saturated mid-tone brand colour. Light
icons and text sit on top for vivid, colourful images in both modes.
Light icons and text on the gradient above, for vivid images in both
modes. `.bg` is not here: it carries the gradient, set once further up
and the same in both modes.
── */
.svg-host :global(.bg) { fill: var(--brand-600); }
.svg-host :global(.ico) { stroke: var(--brand-50); }
.svg-host :global(.txt) { fill: var(--brand-50); }
.svg-host :global(.ln) { stroke: var(--brand-200); stroke-opacity: 0.5; }
Expand All @@ -42,7 +147,6 @@ const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
/* ── Dark mode ───────────────────────────────────────────────────────────
Deep background with all brand colors at full opacity — vivid, not faded.
── */
:global(html.dark) .svg-host :global(.bg) { fill: var(--brand-600); }
:global(html.dark) .svg-host :global(.ico) { stroke: var(--brand-200); }
:global(html.dark) .svg-host :global(.txt) { fill: var(--brand-50); }
:global(html.dark) .svg-host :global(.ln) { stroke: var(--brand-300); stroke-opacity: 0.5; }
Expand Down
66 changes: 42 additions & 24 deletions src/components/blog/Pagination.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,38 @@ function buildWindow(current: number, total: number, max = 5): number[] {
const pages = buildWindow(currentPage, totalPages);
const prevHref = currentPage > 1 ? href(currentPage - 1) : null;
const nextHref = currentPage < totalPages ? href(currentPage + 1) : null;
/**
* Shared shape of the two arrows, so the link and the spent version cannot
* drift apart visually.
*/
const arrowBase =
'inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-foreground-muted transition-colors';
const arrowLive = 'hover:border-brand-500 hover:text-brand-500';
const arrowSpent = 'cursor-not-allowed opacity-40';
---

{totalPages > 1 && (
<nav class="mt-12 flex items-center justify-center gap-2" aria-label={t('pagination.label', locale)}>
<a
href={prevHref ?? undefined}
class:list={[
'inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-foreground-muted transition-colors',
prevHref ? 'hover:border-brand-500 hover:text-brand-500' : 'cursor-not-allowed opacity-40 pointer-events-none',
]}
aria-label={t('pagination.previousPage', locale)}
aria-disabled={prevHref ? undefined : 'true'}
rel={prevHref ? 'prev' : undefined}
>
<Icon name="arrow-left" size="sm" />
</a>
{/* An anchor without an href is not a link — it falls back to a generic
role, and `aria-label` and `aria-disabled` are both prohibited there.
So the spent arrow is a span, hidden from assistive technology: it
offers nothing to act on, and the live arrow still names the direction
that is available. */}
{prevHref ? (
<a
href={prevHref}
class:list={[arrowBase, arrowLive]}
aria-label={t('pagination.previousPage', locale)}
rel="prev"
>
<Icon name="arrow-left" size="sm" />
</a>
) : (
<span class:list={[arrowBase, arrowSpent]} aria-hidden="true">
<Icon name="arrow-left" size="sm" />
</span>
)}

<ol class="flex items-center gap-1">
{pages[0] > 1 && (
Expand Down Expand Up @@ -107,17 +123,19 @@ const nextHref = currentPage < totalPages ? href(currentPage + 1) : null;
)}
</ol>

<a
href={nextHref ?? undefined}
class:list={[
'inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background text-foreground-muted transition-colors',
nextHref ? 'hover:border-brand-500 hover:text-brand-500' : 'cursor-not-allowed opacity-40 pointer-events-none',
]}
aria-label={t('pagination.nextPage', locale)}
aria-disabled={nextHref ? undefined : 'true'}
rel={nextHref ? 'next' : undefined}
>
<Icon name="arrow-right" size="sm" />
</a>
{nextHref ? (
<a
href={nextHref}
class:list={[arrowBase, arrowLive]}
aria-label={t('pagination.nextPage', locale)}
rel="next"
>
<Icon name="arrow-right" size="sm" />
</a>
) : (
<span class:list={[arrowBase, arrowSpent]} aria-hidden="true">
<Icon name="arrow-right" size="sm" />
</span>
)}
</nav>
)}
2 changes: 1 addition & 1 deletion src/components/blog/TagList.astro
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const { tags, active, locale = defaultLocale, class: extraClass } = Astro.props;
class:list={[
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors',
isActive
? 'border-brand-500 bg-brand-500 text-white'
? 'border-brand-700 bg-brand-700 text-white'
: 'border-brand-500/40 bg-transparent text-brand-700 dark:text-brand-400 hover:border-brand-500/80',
]}
aria-current={isActive ? 'page' : undefined}
Expand Down
8 changes: 7 additions & 1 deletion src/components/projects/ProjectCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ const { data } = project;
-->
<div class="mb-3 flex flex-col items-center gap-2 text-center">
<Icon name={data.icon ?? 'layers'} class="w-10 h-10 text-brand-500 shrink-0" />
<h3 class="font-display text-lg font-bold text-foreground">{data.title}</h3>
{/* h2, as BlogCard's title already is. The projects index and the tag
archives put these cards straight under the page's h1 with no
section heading between, so an h3 skipped a level there. On the home
page they still sit below the section's own h2, which a repeat of
the same level does not break. The size comes from the classes, so
the level carries no visual weight of its own. */}
<h2 class="font-display text-lg font-bold text-foreground">{data.title}</h2>
</div>

<p class="text-sm text-foreground-muted leading-relaxed mb-4 flex-1 text-center">{data.description}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/components/projects/ProjectTagList.astro
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const { tags, active, locale = defaultLocale, class: extraClass } = Astro.props;
class:list={[
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors',
isActive
? 'border-brand-500 bg-brand-500 text-white'
? 'border-brand-700 bg-brand-700 text-white'
: 'border-brand-500/40 bg-transparent text-brand-700 dark:text-brand-400 hover:border-brand-500/80',
]}
aria-current={isActive ? 'page' : undefined}
Expand Down
Loading