Javier Lougedo

Design system

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

Tokens

Colour

  • canvas
  • surface
  • surface-2
  • line
  • line-strong
  • fg
  • fg-muted
  • accent
  • accent-ink
  • on-accent
  • plate
  • plate-fg

Type scale

  • xsJavier Lougedo
  • smJavier Lougedo
  • baseJavier Lougedo
  • lgJavier Lougedo
  • xlJavier Lougedo
  • 2xlJavier Lougedo
  • 3xlJavier Lougedo
  • displayJavier Lougedo

Radius

  • sm
  • md
  • lg
  • full

Text contrast (WCAG AA, at least 4.5:1)

ThemeText / backgroundRatio
darkfg / canvas15.70:1 · AA
darkfg / surface14.60:1 · AA
darkfg-muted / canvas7.32:1 · AA
darkfg-muted / surface6.80:1 · AA
darkaccent-ink / canvas7.17:1 · AA
darkaccent-ink / surface6.67:1 · AA
darkon-accent / accent7.17:1 · AA
darkplate-fg / plate15.70:1 · AA
lightfg / canvas17.33:1 · AA
lightfg / surface18.11:1 · AA
lightfg-muted / canvas5.94:1 · AA
lightfg-muted / surface6.21:1 · AA
lightaccent-ink / canvas5.22:1 · AA
lightaccent-ink / surface5.45:1 · AA
lighton-accent / accent6.93:1 · AA
lightplate-fg / plate15.70:1 · AA

Atoms

Badge

status
NewNext
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>

Button

primary · secondary (links)
as buttons
Source
---
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>

Chip

one group, one checked · Tab to focus, arrow keys to move
Sample filter
Source
---
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>

Icon

sun · moon · arrow-right · external
Source
---
// 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]}
/>

IconButton

icon only, with an accessible name
Source
---
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>

Portrait

text portrait: a wave on load, orange around the pointer (reload to replay the wave)
Source
---
// 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>

Select

native select, site tokens, authored chevron
Source
---
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>

Tag

stack labels
TypeScriptReactGo
Source
---
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>

Molecules

Card

link card with tags · badge and status on the title line · static card

Sample project

Area

One or two lines on what was built and why it mattered.
  • TypeScript
  • Go

Recent project

NewIn use

Area

At most one orange element per card.

Static card

A card without a link is an article: no hover state.

Lab card with a preview

In progress
Hover it: the strokes redraw.
Source
---
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>

FilterBar

chips + select + live count (no JS of its own: MaterialsTable drives it)
Type
12 of 12
Source
---
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>

LangSwitch

current locale in bold
Source
---
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>

NowList

what is true right now
  • Senior SDE & Tech Lead at Typeform · Blocks
  • Professor of applied AI at UNIR
  • Building unsaid a daily game with AI
Source
---
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>

Row

lead · title · aside; as a link; with a badge
Source
---
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>

SectionHead

with and without a link

Projects

All projects

Experience

Source
---
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>

ThemeToggle

switches the whole page; the choice is remembered
Source
---
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>

Organisms

AskForm

question box streaming from /api/ask (here it answers that the chat is not available)

Generated by AI from this site's facts; it can be wrong. I don't store your question; it is sent to Ollama Cloud to write the answer.

Source
---
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>

ExperienceList

career from src/data/profile.ts (real content)

Experience

  • 2025–nowSenior SDE & Tech Lead · Blocks · TypeformPayments and the shared platform behind the form builder and renderer
  • 2024–2025SDE · Site team · TypeformPublic site and growth experiments, then Blocks and payments
  • 2024–nowProfessor of data analytics and applied AI · UNIRMaster's in Business Innovation and Transformation with AI · cohorts of ~110
  • 2024–2025Freelance developer · MIKEAI legal advisor for real estate
  • 2023–2024Web analyst & developer · OpenbankLaunched and led the cards vertical
  • 2021–2023Frontend developer · HeyTradeWeb and mobile trading apps with React and React Native
Source
---
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>

Hero

first viewport of the home (English)

Javier Lougedo

Software engineer with an MBA. I ended up spending most of my time where products make money, and the rest as a professor explaining AI to lawyers and business people.

  • Senior SDE & Tech Lead at Typeform · Blocks
  • Professor of applied AI at UNIR
  • Building unsaid · a daily game with AI

ProjectsGitHubEmail

Source
---
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>

MaterialsTable

real data · filters in the URL · one column under 640px
Type
34 of 34
MaterialTypeSubjectSessionDate
Adivina el número: una a una frente a por la mitad NewNotebook · FPA · S1 · Sep 2026NotebookFPAS1
Una CNN por dentro, con radiografías NewNotebook · IASP · S7 · Sep 2026NotebookIASPS7
De una sentada frente a paso a paso: pedirle el trabajo entero a la IA NewGuide · IASP · S7 · Sep 2026GuideIASPS7
Contar personas en un vídeo NewNotebook · IASP · S7 · Sep 2026NotebookIASPS7
Demo multiagente: cinco revisores critican la solución NewGuide · IASP · S7 · Sep 2026GuideIASPS7
Hoja del alumno: los dos cuadernos en una página NewGuide · IASP · S7 · Sep 2026GuideIASPS7
Método: resolver las actividades con IA (design thinking, siete pasos y prompts) NewGuide · IASP · S7 · Sep 2026GuideIASPS7
Las actividades, paso a paso, y una CNN por dentro NewSlides · IASP · S7 · Sep 2026SlidesIASPS7
Make: la torre de control de envíos NewGuide · IASP · S6 · Sep 2026GuideIASPS6
IA en Transporte y Logística, y cierre de la asignatura NewSlides · IASP · S6 · Sep 2026SlidesIASPS6
Prompts para que Claude monte la torre de control en Make NewGuide · IASP · S6 · Sep 2026GuideIASPS6
Escenario Make: segunda vuelta con tres jueces, responsable y registro NewWorkflow · IASP · S6 · Sep 2026WorkflowIASPS6
Escenario Make: torre de control de envíos (con plantilla CSV) NewWorkflow · IASP · S6 · Sep 2026WorkflowIASPS6
RAG de cumplimiento en n8n: montaje, cinco preguntas, trampas y evals NewGuide · IASP · S5 · Sep 2026GuideIASPS5
IA en Industria y Agricultura, y un RAG de cumplimiento NewSlides · IASP · S5 · Sep 2026SlidesIASPS5
Flujo n8n: RAG de cumplimiento industrial NewWorkflow · IASP · S5 · Sep 2026WorkflowIASPS5
RAG de cumplimiento NEXORA Industrial: 100 documentos, montaje y evaluación NewWorkflow · IASP · S5 · Sep 2026WorkflowIASPS5
RAG paso a paso en n8n: dos guías de diabetes y cinco experimentos NewGuide · IASP · S4 · Sep 2026GuideIASPS4
Monta tu RAG: guía completa (n8n + Qdrant, tus documentos y costes) NewGuide · IASP · S4 · Sep 2026GuideIASPS4
IA en Salud y un RAG en n8n NewSlides · IASP · S4 · Sep 2026SlidesIASPS4
Flujo n8n: RAG sobre guías clínicas (práctico de clase) NewWorkflow · IASP · S4 · Sep 2026WorkflowIASPS4
RAG multimotor NEXORA Salud: 984 documentos, flujos n8n y Flowise NewWorkflow · IASP · S4 · Sep 2026WorkflowIASPS4
Fuentes: el ensayo de Amodei y las reacciones NewGuide · IASP · S3 · Sep 2026GuideIASPS3
Fuentes para el cuaderno de Educación (Gemini Notebook) NewGuide · IASP · S3 · Sep 2026GuideIASPS3
Make: feedback automático de entregas (diseño, rúbrica y prompt) NewGuide · IASP · S3 · Sep 2026GuideIASPS3
Educación, Gemini Notebook y RAG NewSlides · IASP · S3 · Sep 2026SlidesIASPS3
Make paso a paso: del correo a la hoja, y la IA que clasifica NewGuide · IASP · S2 · Sep 2026GuideIASPS2
Banca (cierre) y Retail NewSlides · IASP · S2 · Sep 2026SlidesIASPS2
IA en Banca y Seguros NewSlides · IASP · S1 · Sep 2026SlidesIASPS1
¿Contratará este cliente? Scoring bancario, fuga de datos y explicabilidad NewNotebook · IASP · S1 · Sep 2026NotebookIASPS1
Visualizaciones avanzadas con Python: SuperstoreNotebook · TAAVD · May 2026NotebookTAAVD—
Análisis descriptivo en profundidad: SuperstoreNotebook · TAAVD · S1 · May 2026NotebookTAAVDS1
Churn en telecomunicaciones: cinco modelos y exportación a KNIME y Power BINotebook · TAAVD · S5 · Mar 2026NotebookTAAVDS5
Segmentación de clientes de un centro comercial (K-Means)Notebook · TAAVD · Mar 2026NotebookTAAVD—
Source
---
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>

ProjectGrid

featured projects from the collection (real content)

Projects

CNN and people-counting notebooks

New

UNIR · IASP

Two Colab notebooks for students who do not code. Design and train a CNN on chest X-rays (PneumoniaMNIST) by moving form controls, and count people in a video with YOLO by zone and at a line, exporting counts only. Generated by script and verified with zero errors after two adversarial reviews.
  • Colab
  • CNN
  • YOLO

Teaching RAGs with n8n

New

UNIR · IASP

Two RAGs built in n8n and Qdrant for class. Clinical guidelines (984 documents, 128,787 chunks, interchangeable drafting models) and industrial compliance (100 documents, three answer profiles, 33 of 35 on the evaluation). Both corpora carry planted traps, so students watch a RAG fail.
  • n8n
  • Qdrant
  • RAG

Class prep with Claude Code

NewIn use

UNIR

A Claude Code workflow for preparing each class. Nine fixed rules (a theory ceiling, one tangible deliverable, 120 minutes that add up, never invented data), four phases I sign off one by one and a nine-check quality gate. It builds the deck with speaker notes (python-pptx) and generates and verifies the notebooks by script.
  • Claude Code
  • python-pptx
  • Teaching

Subscription payments with Stripe

Typeform · Blocks

The foundation for Stripe subscriptions in Typeform forms, end to end. Plan selection, Stripe Elements, 3DS, server-side creation and verification, behind a provider abstraction that keeps PayPal viable without a rewrite.
  • TypeScript
  • React
  • Go
  • Stripe

Multi-agent feedback pipeline

UNIR

Reviews every submission against the module's rubric and drafts individual, criterion-by-criterion feedback for a cohort of ~110 students. I review and sign off every grade; the system removes the bottleneck.
  • Claude Code
  • Multi-agent
  • Education

MIKE, an AI legal advisor

Freelance

Legal-advice SaaS for real estate professionals, built freelance between November 2024 and February 2025.
  • AI
  • SaaS
Source
---
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>
  )
}

SessionList

real data: only sessions that have not started, the first one marked (renders nothing when none are left)

Upcoming sessions

  • Sep 30S1 · Qué es un algoritmo NextProgramming and Algorithm FundamentalsWed, 19:00 GMT+2
  • Oct 7S2 · La pirámide: de la máquina a Python y la IAProgramming and Algorithm FundamentalsWed, 19:00 GMT+2
  • Oct 14S3 · Estructuras de datosProgramming and Algorithm FundamentalsWed, 19:00 GMT+2
  • Oct 21S4 · Complejidad: notación O, buscar y ordenarProgramming and Algorithm FundamentalsWed, 19:00 GMT+2
  • Oct 28S5 · Estrategias: fuerza bruta, divide y vencerás, voraz y programación dinámicaProgramming and Algorithm FundamentalsWed, 19:00 GMT+1
Source
---
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>
  )
}