Badge
Source
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<span class:list={['inline-block rounded-sm bg-accent px-1.5 py-0.5 align-[2px] text-xs font-bold text-on-accent', className]}>
<slot />
</span>The tokens and components this site is built with. Every component with a story file shows up here on its own, rendered with the site's real CSS.
27 components
canvassurfacesurface-2lineline-strongfgfg-mutedaccentaccent-inkon-accentplateplate-fgxsJavier LougedosmJavier LougedobaseJavier LougedolgJavier LougedoxlJavier Lougedo2xlJavier Lougedo3xlJavier LougedodisplayJavier Lougedosmmdlgfull| Theme | Text / background | Ratio |
|---|---|---|
| dark | fg / canvas | 15.70:1 · AA |
| dark | fg / surface | 14.60:1 · AA |
| dark | fg-muted / canvas | 7.32:1 · AA |
| dark | fg-muted / surface | 6.80:1 · AA |
| dark | accent-ink / canvas | 7.17:1 · AA |
| dark | accent-ink / surface | 6.67:1 · AA |
| dark | on-accent / accent | 7.17:1 · AA |
| dark | plate-fg / plate | 15.70:1 · AA |
| light | fg / canvas | 17.33:1 · AA |
| light | fg / surface | 18.11:1 · AA |
| light | fg-muted / canvas | 5.94:1 · AA |
| light | fg-muted / surface | 6.21:1 · AA |
| light | accent-ink / canvas | 5.22:1 · AA |
| light | accent-ink / surface | 5.45:1 · AA |
| light | on-accent / accent | 6.93:1 · AA |
| light | plate-fg / plate | 15.70:1 · AA |
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<span class:list={['inline-block rounded-sm bg-accent px-1.5 py-0.5 align-[2px] text-xs font-bold text-on-accent', className]}>
<slot />
</span>---
import type { HTMLAttributes } from 'astro/types';
interface Props extends Omit<HTMLAttributes<'a'>, 'type'> {
variant?: 'primary' | 'secondary';
/** Without href it renders a <button type="button">. */
href?: string;
}
const { variant = 'primary', href, class: className, ...rest } = Astro.props;
const El = href ? 'a' : 'button';
const variants = {
primary: 'bg-accent text-on-accent hover:bg-accent/90',
secondary: 'border border-line-strong text-fg hover:border-fg-muted',
};
---
<El
href={href}
type={href ? undefined : 'button'}
class:list={[
'inline-flex h-11 items-center gap-2 rounded-md px-5 text-sm font-semibold transition-colors',
variants[variant],
className,
]}
{...rest}
>
<slot />
</El>---
interface Props {
name: string;
value: string;
checked?: boolean;
}
const { name, value, checked = false } = Astro.props;
---
<!-- A radio styled as a chip: native keyboard support (arrows), checked = neutral inverted, never orange. -->
<label
class="inline-flex cursor-pointer items-center rounded-md border border-line px-3 py-1 text-sm font-medium text-fg-muted transition-colors hover:border-line-strong hover:text-fg has-[:checked]:border-fg has-[:checked]:bg-fg has-[:checked]:text-canvas has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-3 has-[:focus-visible]:outline-accent-ink"
>
<input type="radio" class="sr-only" name={name} value={value} checked={checked} />
<slot />
</label>---
// Authored icons: one 1.6 stroke on a 24px grid, currentColor. Add a path here instead of pulling in a library.
const paths = {
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/>',
'arrow-right': '<path d="M5 12h14M13 6l6 6-6 6"/>',
external: '<path d="M14 5h5v5M19 5l-8 8M18 14v5H5V6h5"/>',
'chevron-down': '<path d="M6 9l6 6 6-6"/>',
};
interface Props {
name: keyof typeof paths;
class?: string;
}
const { name, class: className = 'size-4' } = Astro.props;
---
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
set:html={paths[name]}
/>---
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'button'> {
/** Accessible name: the button only shows an icon. */
label: string;
}
const { label, class: className, ...rest } = Astro.props;
---
<button
type="button"
aria-label={label}
class:list={[
'grid size-9 place-items-center rounded-md border border-line text-fg transition-colors hover:border-line-strong',
className,
]}
{...rest}
>
<slot />
</button>---
import type { HTMLAttributes } from 'astro/types';
import Icon from './Icon.astro';
interface Props extends HTMLAttributes<'a'> {
href: string;
/** Muted, no underline: secondary rows such as footer links. */
quiet?: boolean;
}
const { href, quiet = false, class: className, ...rest } = Astro.props;
const external = /^https?:\/\//.test(href);
---
<a
href={href}
class:list={[
'inline-flex items-center gap-1',
quiet
? 'text-fg-muted transition-colors hover:text-fg'
: 'text-accent-ink underline decoration-1 underline-offset-4 hover:decoration-2',
className,
]}
{...rest}
>
<slot />
{external && <Icon name="external" class="size-3.5" />}
</a>---
// The mark: two concentric squares, after the portrait's cells (Vera Molnár, (Dés)Ordres).
interface Props {
class?: string;
}
const { class: className = 'size-5' } = Astro.props;
---
<svg class={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="1.5" y="1.5" width="17" height="17" />
<rect x="6" y="6" width="8" height="8" />
</svg>---
// The plotter portrait as text (src/assets/portrait.txt, 120×60, generated from portrait.svg).
// One block of text (a <div>, not a <pre>: the parser would drop the first row, and Google honours data-nosnippet
// only on div/span/section); its colour is gradients clipped to the glyphs: an orange wave passes once on load (CSS),
// then the characters near the pointer turn orange (<portrait-hover>). Without JS: no wave, no hover, same portrait.
import text from '../../assets/portrait.txt?raw';
interface Props {
label: string;
class?: string;
}
const { label, class: className } = Astro.props;
---
<figure role="img" aria-label={label} class:list={['grid aspect-square place-items-center overflow-hidden rounded-lg bg-plate', className]}>
<portrait-hover>
<div aria-hidden="true" data-nosnippet class="art font-mono">{text}</div>
</portrait-hover>
</figure>
<script>
// Characters within 120px of the pointer turn orange; the radius eases in and out (see --r).
class PortraitHover extends HTMLElement {
connectedCallback() {
const pre = this.querySelector<HTMLElement>('.art');
if (!pre) return;
const at = (e: PointerEvent) => {
const box = pre.getBoundingClientRect();
pre.style.setProperty('--x', `${e.clientX - box.left}px`);
pre.style.setProperty('--y', `${e.clientY - box.top}px`);
};
pre.addEventListener('pointerenter', (e) => {
at(e);
pre.style.setProperty('--r', '120px');
});
pre.addEventListener('pointermove', at);
pre.addEventListener('pointerleave', () => pre.style.setProperty('--r', '0px'));
// The wave waits until the portrait is on screen (on phones it sits below the buttons).
const seen = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
this.dataset.visible = '';
seen.disconnect();
}, { threshold: 0.4 });
seen.observe(this);
}
}
customElements.define('portrait-hover', PortraitHover);
</script>
<style>
@property --wave {
syntax: '<length>';
inherits: false;
initial-value: -80px;
}
@property --r {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
figure {
container-type: inline-size;
}
.art {
font-size: calc(100cqi / 120 / 0.6); /* 120 columns of a 0.6em-wide monospace fill the plate */
font-weight: 600;
line-height: 1.2;
white-space: pre;
user-select: none;
color: transparent;
background-clip: text;
-webkit-background-clip: text;
background-image:
radial-gradient(circle var(--r, 0px) at var(--x, 50%) var(--y, 50%), var(--accent) 65%, transparent),
radial-gradient(
circle at 50% 40%,
transparent calc(var(--wave, -80px) - 70px),
var(--accent) calc(var(--wave, -80px) - 20px),
var(--accent) calc(var(--wave, -80px) + 20px),
transparent calc(var(--wave, -80px) + 70px)
),
linear-gradient(var(--plate-fg), var(--plate-fg));
animation: portrait-wave 1.8s cubic-bezier(0.3, 0, 0.4, 1) 0.2s both paused;
transition: --r 0.35s ease-out;
}
portrait-hover[data-visible] .art {
animation-play-state: running;
}
@keyframes portrait-wave {
from {
--wave: -80px;
}
to {
--wave: 130cqi;
}
}
</style>---
import type { HTMLAttributes } from 'astro/types';
import Icon from './Icon.astro';
type Props = HTMLAttributes<'select'>;
const { class: className, ...rest } = Astro.props;
---
<span class:list={['relative inline-flex max-w-full', className]}>
<select
class="max-w-full appearance-none rounded-md border border-line bg-surface py-1 pr-8 pl-3 text-sm text-fg transition-colors hover:border-line-strong"
{...rest}
>
<slot />
</select>
<Icon name="chevron-down" class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-fg-muted" />
</span>---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<span class:list={['inline-block rounded-sm border border-line px-2 py-0.5 text-xs font-medium text-fg-muted', className]}>
<slot />
</span>---
interface Props {
label: string;
/** Full trail. The last item is the current page and has no href. */
items: { label: string; href?: string }[];
}
const { label, items } = Astro.props;
---
<nav aria-label={label} class="pt-8 text-sm text-fg-muted">
<ol class="flex flex-wrap gap-2">
{
items.map((item) => (
<li class="flex gap-2 not-first:before:text-line-strong not-first:before:content-['/']">
{item.href ? (
<a href={item.href} class="transition-colors hover:text-fg">
{item.label}
</a>
) : (
<span aria-current="page" class="text-fg">
{item.label}
</span>
)}
</li>
))
}
</ol>
</nav>---
import Badge from '../atoms/Badge.astro';
import Tag from '../atoms/Tag.astro';
interface Props {
title: string;
href?: string;
meta?: string;
date?: string;
tags?: string[];
/** Orange badge, already translated (e.g. "New"). */
badge?: string;
/** Neutral status, already translated (e.g. "In progress"). */
status?: string;
}
const { title, href, meta, date, tags = [], badge, status } = Astro.props;
const El = href ? 'a' : 'article';
---
<El
href={href}
class:list={[
'group flex flex-col gap-3 rounded-lg border border-line bg-surface p-6 transition-colors',
href && 'hover:border-line-strong',
]}
>
{
Astro.slots.has('preview') && (
<div class="-mx-4 -mt-4 mb-1 aspect-[16/10] overflow-hidden rounded-md bg-plate text-plate-fg [&_svg]:size-full">
<slot name="preview" />
</div>
)
}
<div class="flex items-start justify-between gap-3">
<h3 class:list={['text-lg font-semibold', href && 'group-hover:text-accent-ink']}>{title}</h3>
{
(badge || status) && (
<span class="flex shrink-0 gap-1.5 pt-1">
{badge && <Badge>{badge}</Badge>}
{status && <Tag>{status}</Tag>}
</span>
)
}
</div>
{
(meta || date) && (
<p class="flex justify-between gap-4 text-sm text-fg-muted">
<span>{meta}</span>
{date && <time datetime={date}>{date}</time>}
</p>
)
}
<div class="text-sm text-fg-muted"><slot /></div>
{
tags.length > 0 && (
<ul class="mt-auto flex flex-wrap gap-1.5 pt-2">
{tags.map((tag) => (
<li>
<Tag>{tag}</Tag>
</li>
))}
</ul>
)
}
</El>
<style>
/* Lab previews: strokes marked pathLength="1" redraw while the card is hovered or focused. */
@media (prefers-reduced-motion: no-preference) {
.group:is(:hover, :focus-visible) :global([pathLength]) {
animation: redraw 1.6s cubic-bezier(0.3, 0.7, 0.2, 1) backwards;
}
}
@keyframes redraw {
from {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
to {
stroke-dasharray: 1;
stroke-dashoffset: 0;
}
}
</style>---
import Chip from '../atoms/Chip.astro';
import Select from '../atoms/Select.astro';
interface Option {
value: string;
label: string;
}
interface Props {
label: string;
typeLabel: string;
/** First option = "all", value ''. */
types: Option[];
subjectLabel?: string;
/** First option = "all subjects", value ''. Without it there is no subject select (a subject's own page). */
subjects?: Option[];
/** Template with {n} and {total}. */
count: string;
total: number;
}
const { label, typeLabel, types, subjectLabel, subjects, count, total } = Astro.props;
const summary = count.replace('{n}', String(total)).replace('{total}', String(total));
---
<form aria-label={label} class="flex flex-wrap items-center gap-x-6 gap-y-3">
<fieldset class="flex flex-wrap gap-2">
<legend class="sr-only">{typeLabel}</legend>
{
types.map((option, i) => (
<Chip name="type" value={option.value} checked={i === 0}>
{option.label}
</Chip>
))
}
</fieldset>
{
subjects && (
<Select name="subject" aria-label={subjectLabel}>
{subjects.map((option) => <option value={option.value}>{option.label}</option>)}
</Select>
)
}
<output class="text-sm text-fg-muted sm:ml-auto" data-template={count}>{summary}</output>
</form>---
import { locales, path, type Locale } from '../../i18n';
interface Props {
locale: Locale;
/** Path of the current page without locale prefix. */
route: string;
label: string;
}
const { locale, route, label } = Astro.props;
---
<lang-switch>
<ul aria-label={label} class="flex gap-2.5 text-sm">
{
locales.map((l) => (
<li>
<a
href={path(l, route)}
hreflang={l}
lang={l}
aria-current={l === locale ? 'true' : undefined}
class="uppercase text-fg-muted transition-colors hover:text-fg aria-[current=true]:font-bold aria-[current=true]:text-fg"
>
{l}
</a>
</li>
))
}
</ul>
</lang-switch>
<script>
// Remember an explicit language choice, so the home never redirects against it (see the script in Base.astro).
class LangSwitch extends HTMLElement {
connectedCallback() {
this.addEventListener('click', (event) => {
const link = (event.target as Element).closest('a[hreflang]');
try {
if (link) localStorage.setItem('lang', link.getAttribute('hreflang') ?? '');
} catch {}
});
}
}
customElements.define('lang-switch', LangSwitch);
</script>---
interface Item {
label: string;
/** Sections without a page yet have no href and render as "coming soon". */
href?: string;
current?: boolean;
}
interface Props {
items: Item[];
label: string;
soon: string;
class?: string;
}
const { items, label, soon, class: className } = Astro.props;
---
<nav aria-label={label} class={className}>
<ul class="-m-1.5 flex gap-6 overflow-x-auto p-1.5 text-sm font-medium [scrollbar-width:none]">
{
items.map((item) => (
<li class="shrink-0">
{item.href ? (
<a
href={item.href}
aria-current={item.current ? 'page' : undefined}
class="block py-1.5 text-fg-muted transition-colors hover:text-fg aria-[current=page]:text-fg aria-[current=page]:shadow-[inset_0_-2px_0_var(--accent)]"
>
{item.label}
</a>
) : (
<span aria-disabled="true" title={soon} class="block py-1.5 text-fg-muted/50">
{item.label}
</span>
)}
</li>
))
}
</ul>
</nav>---
interface Item {
text: string;
muted?: string;
href?: string;
}
interface Props {
items: Item[];
label: string;
class?: string;
}
const { items, label, class: className } = Astro.props;
---
<ul aria-label={label} class:list={['grid gap-2 text-sm', className]}>
{
items.map(({ text, muted, href }) => (
<li class="flex items-baseline gap-3 before:size-[7px] before:shrink-0 before:-translate-y-px before:border-[1.5px] before:border-accent">
<span>
{href ? (
<a href={href} class="text-accent-ink underline decoration-1 underline-offset-4">
{text}
</a>
) : (
text
)}
{muted && <span class="text-fg-muted"> {muted}</span>}
</span>
</li>
))
}
</ul>---
interface Props {
title: string;
href?: string;
/** Left column, e.g. years. */
lead?: string;
/** Right column, e.g. status or stack. */
aside?: string;
/** Language of the title when it differs from the page (e.g. Spanish session titles on the English page). */
lang?: string;
}
const { title, href, lead, aside, lang } = Astro.props;
const El = href ? 'a' : 'div';
---
<El
href={href}
class:list={[
'group grid gap-x-6 gap-y-1 border-b border-line py-4 sm:items-baseline',
lead ? 'sm:grid-cols-[11ch_minmax(0,1fr)_auto]' : 'sm:grid-cols-[minmax(0,1fr)_auto]',
]}
>
{lead && <span class="text-sm text-fg-muted max-sm:order-last">{lead}</span>}
<span>
<span lang={lang} class:list={['font-semibold text-fg', href && 'group-hover:text-accent-ink']}>{title}</span>
<slot />
</span>
{aside && <span class="text-sm text-fg-muted">{aside}</span>}
</El>---
import Link from '../atoms/Link.astro';
interface Props {
title: string;
href?: string;
link?: string;
}
const { title, href, link } = Astro.props;
---
<div class="mb-6 flex items-baseline justify-between gap-6">
<h2 class="text-2xl font-bold">{title}</h2>
{href && link && <Link href={href}>{link}</Link>}
</div>---
import Icon from '../atoms/Icon.astro';
import IconButton from '../atoms/IconButton.astro';
interface Props {
toLight: string;
toDark: string;
}
const { toLight, toDark } = Astro.props;
---
<theme-toggle data-to-light={toLight} data-to-dark={toDark}>
<IconButton label={toLight}>
<Icon name="sun" class="size-4 light:hidden" />
<Icon name="moon" class="hidden size-4 light:block" />
</IconButton>
</theme-toggle>
<script>
// The page has one theme state: html[data-theme]. Toggles only write it; what depends on it follows it here.
const root = document.documentElement;
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
const toggles = document.getElementsByTagName('theme-toggle');
const render = () => {
const theme = root.dataset.theme === 'light' ? 'light' : 'dark';
if (meta) meta.content = meta.dataset[theme] ?? meta.content;
for (const toggle of toggles) {
const { toLight, toDark } = (toggle as HTMLElement).dataset;
toggle.querySelector('button')?.setAttribute('aria-label', (theme === 'dark' ? toLight : toDark) ?? '');
}
};
new MutationObserver(render).observe(root, { attributeFilter: ['data-theme'] });
// Another tab changed the theme.
addEventListener('storage', (event) => {
if (event.key === 'theme') root.dataset.theme = event.newValue === 'light' ? 'light' : 'dark';
});
class ThemeToggle extends HTMLElement {
connectedCallback() {
this.querySelector('button')?.addEventListener('click', () => {
const next = root.dataset.theme === 'light' ? 'dark' : 'light';
root.dataset.theme = next;
try {
localStorage.setItem('theme', next);
} catch {}
});
render();
}
}
customElements.define('theme-toggle', ThemeToggle);
</script>
<style>
/* Without JS the toggle would do nothing: hide it until the element is defined. */
theme-toggle:not(:defined) {
display: none;
}
</style>---
import { t, type Locale } from '../../i18n';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const a = t(locale).ask;
const errors = JSON.stringify({ rate: a.rateLimited, bad: a.badQuestion, unavailable: a.unavailable });
---
<ask-me data-locale={locale} data-errors={errors} class="block max-w-[62ch]">
<form class="grid gap-3">
<label for="ask-question" class="text-sm font-semibold">{a.label}</label>
<textarea
id="ask-question"
name="question"
rows="3"
maxlength="500"
required
placeholder={a.placeholder}
class="rounded-md border border-line bg-surface p-3 text-base text-fg placeholder:text-fg-muted hover:border-line-strong"
></textarea>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<button
type="submit"
class="inline-flex h-11 items-center rounded-md bg-accent px-5 text-sm font-semibold text-on-accent transition-colors hover:bg-accent/90 disabled:opacity-60"
>
{a.send}
</button>
<p class="text-xs text-fg-muted">{a.disclaimer}</p>
</div>
</form>
<div
role="status"
aria-live="polite"
hidden
class="mt-6 rounded-md border border-line bg-surface p-4 text-fg marker:text-line-strong [&_a]:text-accent-ink [&_a]:underline [&_a]:decoration-1 [&_a]:underline-offset-4 [&_a:hover]:decoration-2 [&_li+li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&>*+*]:mt-3"
>
</div>
</ask-me>
<script>
import { knownLink } from '../../lib/links';
import { answerBlocks, type Block, type Inline } from '../../lib/markdown';
type Check = (href: string) => string | null;
// Answers arrive as Markdown; they become DOM nodes, never HTML. Links go through `check` (the site's own links only).
const inline = (items: Inline[], check: Check): Node[] =>
items.map((item) => {
if (typeof item === 'string') return document.createTextNode(item);
if ('href' in item) {
const href = check(item.href);
if (!href) return document.createTextNode(item.text);
const a = document.createElement('a');
a.href = href;
a.textContent = item.text === item.href ? href : item.text;
return a;
}
const el = document.createElement('strong' in item ? 'strong' : 'em');
el.append(...inline('strong' in item ? item.strong : item.em, check));
return el;
});
const block = (b: Block, check: Check) => {
if ('p' in b) {
const p = document.createElement('p');
p.append(...inline(b.p, check));
return p;
}
const list = document.createElement('ul' in b ? 'ul' : 'ol');
for (const item of 'ul' in b ? b.ul : b.ol) {
const li = document.createElement('li');
li.append(...inline(item, check));
list.append(li);
}
return list;
};
// The links the facts hold, fetched once, alongside the first question.
let facts: Promise<string> | undefined;
// Sends the question to the Worker at /api/ask and streams the answer in. Previews have no /api: they say so.
class AskMe extends HTMLElement {
connectedCallback() {
const form = this.querySelector('form');
const field = this.querySelector('textarea');
const button = this.querySelector('button');
const answer = this.querySelector<HTMLElement>('[role="status"]');
if (!form || !field || !button || !answer) return;
const errors = JSON.parse(this.dataset.errors ?? '{}') as Record<'rate' | 'bad' | 'unavailable', string>;
const ask = async () => {
const question = field.value.trim();
if (!question || button.disabled) return;
button.disabled = true;
answer.hidden = false;
// Screen readers wait for the whole answer instead of re-reading it on every chunk.
answer.setAttribute('aria-busy', 'true');
answer.textContent = '…';
facts ??= fetch('/about.md')
.then((r) => (r.ok ? r.text() : ''))
.catch(() => '');
try {
const res = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, locale: this.dataset.locale }),
});
const type = res.headers.get('content-type') ?? '';
if (!res.ok || !res.body || !type.startsWith('text/plain')) {
answer.textContent = res.status === 429 ? errors.rate : res.status === 400 ? errors.bad : errors.unavailable;
return;
}
const known = new Set((await facts).match(/https?:\/\/[^\s)<>]+/g));
const checked = new Map<string, string | null>();
const check = (href: string) => {
if (!checked.has(href)) checked.set(href, knownLink(href, known));
return checked.get(href) ?? null;
};
let text = '';
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
text += value;
answer.replaceChildren(...answerBlocks(text).map((b) => block(b, check)));
}
if (!text.trim()) answer.textContent = errors.unavailable;
} catch {
answer.textContent = errors.unavailable;
} finally {
answer.removeAttribute('aria-busy');
button.disabled = false;
}
};
form.addEventListener('submit', (event) => {
event.preventDefault();
ask();
});
field.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
ask();
}
});
}
}
customElements.define('ask-me', AskMe);
</script>
<style>
/* Without JS the form could not send anything. */
ask-me:not(:defined) form {
display: none;
}
</style>---
import Row from '../molecules/Row.astro';
import SectionHead from '../molecules/SectionHead.astro';
import { experience } from '../../data/profile';
import { t, type Locale } from '../../i18n';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const i18n = t(locale).experience;
---
<section id="experience" class="pt-24">
<SectionHead title={i18n.title} />
<ul class="border-t border-line">
{
experience.map((role) => (
<li>
<Row lead={`${role.from}–${role.to ?? i18n.now}`} title={`${role.title[locale]} · ${role.org}`}>
<span class="block text-sm text-fg-muted">{role.note[locale]}</span>
</Row>
</li>
))
}
</ul>
</section>---
import Button from '../atoms/Button.astro';
import Link from '../atoms/Link.astro';
import Portrait from '../atoms/Portrait.astro';
import NowList from '../molecules/NowList.astro';
import { path, t, type Locale } from '../../i18n';
import { profile } from '../../data/profile';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const home = t(locale).home;
---
<section class="grid items-center gap-12 pt-12 md:grid-cols-[minmax(0,6fr)_minmax(0,5fr)] md:gap-20 md:pt-24">
<div>
<h1 class="text-display font-bold">{profile.name}</h1>
<p class="mt-6 max-w-[34ch] text-lg text-pretty text-fg-muted">
<strong class="font-semibold text-fg">{home.leadStrong}</strong>{' '}
{home.lead}
</p>
<NowList class="mt-8" label={home.nowLabel} items={home.now} />
<div class="mt-8 flex flex-wrap gap-3">
<Button href={profile.linkedin} rel="me">{home.connect}</Button>
<Button variant="secondary" href={path(locale, '/cv/')}>{home.cv}</Button>
</div>
<p class="mt-6 flex flex-wrap gap-5 text-sm">
<Link quiet href="#projects">{home.projects}</Link>
<Link quiet href={profile.github} rel="me">GitHub</Link>
<Link quiet href={`mailto:${profile.email}`}>{home.email}</Link>
</p>
</div>
<Portrait label={home.portrait} />
</section>---
import { getCollection } from 'astro:content';
import Badge from '../atoms/Badge.astro';
import Icon from '../atoms/Icon.astro';
import FilterBar from '../molecules/FilterBar.astro';
import { materialTypes, subjects } from '../../data/university';
import { isNew } from '../../lib/dates';
import { t, type Locale } from '../../i18n';
interface Props {
locale: Locale;
/** Only this subject's material, without the subject filter and column (a subject's own page). */
subject?: (typeof subjects)[number];
}
const { locale, subject } = Astro.props;
const i18n = t(locale);
const u = i18n.university;
const all = await getCollection('materials');
// file() only logs a YAML parse error and loads nothing: fail the build instead of shipping an empty table.
if (all.length === 0) throw new Error('src/content/university/materials.yaml has no valid entries');
const materials = all.filter(({ data }) => !subject || data.subject === subject).sort(
(a, b) =>
b.data.date.valueOf() - a.data.date.valueOf() ||
subjects.indexOf(a.data.subject) - subjects.indexOf(b.data.subject) ||
(a.data.session ?? 0) - (b.data.session ?? 0),
);
const has = (key: 'type' | 'subject', value: string) => materials.some(({ data }) => data[key] === value);
// YAML dates are UTC midnight: format them in UTC or they slip to the previous day west of Greenwich.
const month = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' });
---
<filter-table class="block">
<FilterBar
label={u.filters}
typeLabel={u.columns.type}
types={[{ value: '', label: u.all }, ...materialTypes.filter((v) => has('type', v)).map((v) => ({ value: v, label: u.types[v] }))]}
subjectLabel={u.columns.subject}
subjects={
subject ? undefined : [{ value: '', label: u.allSubjects }, ...subjects.filter((v) => has('subject', v)).map((v) => ({ value: v, label: u.subjects[v].name }))]
}
count={u.count}
total={materials.length}
/>
<table class="mt-6 w-full text-sm">
<thead class="max-sm:sr-only">
<tr class="border-b border-line text-left text-fg-muted">
<th class="py-2 pr-4 font-medium">{u.columns.material}</th>
<th class="py-2 pr-4 font-medium max-sm:hidden">{u.columns.type}</th>
{!subject && <th class="py-2 pr-4 font-medium max-sm:hidden">{u.columns.subject}</th>}
<th class="py-2 pr-4 font-medium max-sm:hidden">{u.columns.session}</th>
<th class="py-2 font-medium max-sm:hidden">{u.columns.date}</th>
</tr>
</thead>
<tbody>
{
materials.map(({ data: m }) => (
<tr data-type={m.type} data-subject={m.subject} class="border-b border-line align-baseline">
<td class="py-3 pr-4">
<a href={m.url} lang="es" class="font-medium text-fg transition-colors hover:text-accent-ink">
{m.title}
<Icon name="external" class="ml-0.5 inline size-3.5 align-[-0.125em] text-fg-muted" />
</a>
{isNew(m.date) && (
<>
{' '}
<Badge>{i18n.status.new}</Badge>
</>
)}
<span class="mt-1 block text-fg-muted sm:hidden">
{[u.types[m.type], !subject && m.subject.toUpperCase(), m.session && `S${m.session}`, month.format(m.date)].filter(Boolean).join(' · ')}
</span>
</td>
<td class="py-3 pr-4 text-fg-muted max-sm:hidden">{u.types[m.type]}</td>
{!subject && (
<td class="py-3 pr-4 text-fg-muted max-sm:hidden">
<abbr title={u.subjects[m.subject].name} class="no-underline">
{m.subject.toUpperCase()}
</abbr>
</td>
)}
<td class="py-3 pr-4 text-fg-muted max-sm:hidden">{m.session ? `S${m.session}` : '—'}</td>
<td class="py-3 whitespace-nowrap text-fg-muted max-sm:hidden">
<time datetime={m.date.toISOString().slice(0, 10)}>{month.format(m.date)}</time>
</td>
</tr>
))
}
</tbody>
</table>
<p data-empty hidden class="py-6 text-sm text-fg-muted">{u.empty}</p>
</filter-table>
<script>
// Each form field filters the rows by the data-* attribute of the same name; an empty value lets everything through.
class FilterTable extends HTMLElement {
connectedCallback() {
const form = this.querySelector('form');
const output = this.querySelector('output');
const empty = this.querySelector<HTMLElement>('[data-empty]');
const rows = [...this.querySelectorAll<HTMLElement>('tbody tr')];
if (!form || !output || !empty) return;
const names = new Set([...form.elements].map((el) => (el as HTMLInputElement).name).filter(Boolean));
// A shared link (?type=notebook&subject=iasp) restores the filters; values the form does not offer are ignored.
for (const [name, value] of new URLSearchParams(location.search)) {
const field = form.elements.namedItem(name);
const offered =
field instanceof HTMLSelectElement ? [...field.options] : field instanceof RadioNodeList ? [...field] : [];
if (field && offered.some((o) => (o as HTMLInputElement | HTMLOptionElement).value === value)) {
(field as HTMLSelectElement | RadioNodeList).value = value;
}
}
const apply = () => {
const filters = [...new FormData(form)].filter(([, v]) => v) as [string, string][];
let shown = 0;
for (const row of rows) {
row.hidden = !filters.every(([k, v]) => row.dataset[k] === v);
if (!row.hidden) shown++;
}
output.value = (output.dataset.template ?? '').replace('{n}', String(shown)).replace('{total}', String(rows.length));
empty.hidden = shown > 0;
// Mirror the filters in the URL, keeping any other parameter and the #hash; untouched URLs stay untouched.
const url = new URL(location.href);
for (const name of names) url.searchParams.delete(name);
for (const [k, v] of filters) url.searchParams.set(k, v);
if (url.href !== location.href) history.replaceState(history.state, '', url);
};
form.addEventListener('change', apply);
apply();
}
}
customElements.define('filter-table', FilterTable);
</script>
<style>
/* Without JS the filters would do nothing: the full table stays, the form goes (the form is FilterBar's, hence :global). */
filter-table:not(:defined) :global(form) {
display: none;
}
</style>---
import { getCollection } from 'astro:content';
import Card from '../molecules/Card.astro';
import SectionHead from '../molecules/SectionHead.astro';
import { isNew } from '../../lib/dates';
import { t, type Locale } from '../../i18n';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const i18n = t(locale);
const projects = (
await getCollection('projects', ({ id, data }) => id.startsWith(`${locale}/`) && data.featured && !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
{
projects.length > 0 && (
<section id="projects" class="pt-24">
<SectionHead title={i18n.projects.title} />
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map(({ data }) => (
<Card
title={data.title}
meta={data.area}
date={String(data.date.getFullYear())}
tags={data.tags}
href={data.demo ?? data.repo}
badge={isNew(data.date) ? i18n.status.new : undefined}
status={data.status && i18n.status[data.status]}
>
{data.description}
</Card>
))}
</div>
</section>
)
}---
import { getCollection } from 'astro:content';
import Badge from '../atoms/Badge.astro';
import Row from '../molecules/Row.astro';
import SectionHead from '../molecules/SectionHead.astro';
import type { subjects } from '../../data/university';
import { upcoming } from '../../lib/dates';
import { t, type Locale } from '../../i18n';
interface Props {
locale: Locale;
/** Only this subject's sessions, without repeating its name under each one. */
subject?: (typeof subjects)[number];
}
const { locale, subject } = Astro.props;
const u = t(locale).university;
const all = (await getCollection('sessions')).map(({ data }) => data);
// file() only logs a YAML parse error; past sessions stay in the file, so empty means broken: fail the build.
if (all.length === 0) throw new Error('src/content/university/sessions.yaml has no valid entries');
const sessions = upcoming(all.filter((s) => !subject || s.subject === subject));
// Classes happen in Madrid; the build runs in UTC.
const zone = { timeZone: 'Europe/Madrid' } as const;
const day = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', ...zone });
const time = new Intl.DateTimeFormat(locale, { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZoneName: 'short', ...zone });
---
{
sessions.length > 0 && (
<section class="pt-20">
<SectionHead title={u.sessions} />
<ul class="border-t border-line">
{sessions.map((s, i) => (
<li>
<Row lead={day.format(s.start)} title={`S${s.number} · ${s.title}`} lang="es" aside={time.format(s.start)}>
{i === 0 && (
<>
{' '}
<Badge>{u.next}</Badge>
</>
)}
{!subject && <span class="block text-sm text-fg-muted">{u.subjects[s.subject].name}</span>}
</Row>
</li>
))}
</ul>
</section>
)
}---
import Link from '../atoms/Link.astro';
import { t, type Locale } from '../../i18n';
import { profile } from '../../data/profile';
import { lastCommit } from '../../lib/git';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const i18n = t(locale).footer;
---
<footer class="mt-24 border-t border-line">
<div class="wrap flex flex-wrap justify-between gap-x-8 gap-y-3 py-6 text-sm text-fg-muted">
<p>{profile.name} · {i18n.madeWith}</p>
<p class="flex flex-wrap gap-x-5 gap-y-2">
<Link quiet href={profile.linkedin} rel="me">LinkedIn</Link>
<Link quiet href={profile.github} rel="me">GitHub</Link>
<Link quiet href={`mailto:${profile.email}`}>{profile.email}</Link>
</p>
<p>
{i18n.updated}{' '}
<time datetime={lastCommit.toISOString().slice(0, 10)}>
{new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'Europe/Madrid' }).format(lastCommit)}
</time>
</p>
</div>
</footer>---
import Logo from '../atoms/Logo.astro';
import LangSwitch from '../molecules/LangSwitch.astro';
import NavMenu from '../molecules/NavMenu.astro';
import ThemeToggle from '../molecules/ThemeToggle.astro';
import { path, t, type Locale } from '../../i18n';
import { profile } from '../../data/profile';
interface Props {
locale: Locale;
route: string;
}
const { locale, route } = Astro.props;
const i18n = t(locale);
const home = path(locale);
// Sections get an href when their page exists. Until then: projects point at the home section; the blog waits.
const items = [
{ label: i18n.nav.projects, href: `${home}#projects` },
{ label: i18n.nav.blog },
{ label: i18n.nav.university, href: path(locale, '/university/'), current: route.startsWith('/university/') },
{ label: i18n.nav.lab, href: path(locale, '/lab/'), current: route.startsWith('/lab/') },
{ label: i18n.nav.cv, href: path(locale, '/cv/'), current: route.startsWith('/cv/') },
];
---
<header class="sticky top-0 z-10 border-b border-line bg-canvas">
<div class="wrap flex h-16 items-center gap-8 max-md:h-auto max-md:flex-wrap max-md:gap-y-2 max-md:py-3">
<a href={home} class="mr-auto flex items-center gap-2.5 font-bold">
<Logo class="size-5 text-accent" />
{profile.name}
</a>
<NavMenu items={items} label={i18n.nav.label} soon={i18n.nav.soon} class="max-md:order-last max-md:w-full" />
<LangSwitch locale={locale} route={route} label={i18n.lang.label} />
<ThemeToggle toLight={i18n.theme.toLight} toDark={i18n.theme.toDark} />
</div>
</header>