test: vitest unit + playwright e2e suites; fix a11y, contrast, no-JS reveal
- vitest: dictionary parity, slug-map bijection, content-schema validation, JSON-LD (23) - playwright: both locales, language-switch mapping (incl. no-JS), CV downloads, form happy-path + honeypot, axe a11y on 5 templates x 2 themes (23) - fix: raise ink-faint + light accent to meet WCAG AA 4.5:1 (axe-verified) - fix: gate reveal animations behind html.js so content is visible without JS (progressive enhancement - was hidden at opacity 0); scan reduced-motion path - fix: validate contact form before the anti-bot time-trap (no false success) - chore: ESLint node globals, typed diagram props, resvg fontFiles, prettier pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -19,6 +19,18 @@ export default [
|
|||||||
{
|
{
|
||||||
// Behaviour modules run in the browser.
|
// Behaviour modules run in the browser.
|
||||||
files: ['src/scripts/**/*.ts'],
|
files: ['src/scripts/**/*.ts'],
|
||||||
languageOptions: { globals: { window: 'readonly', document: 'readonly', localStorage: 'readonly' } },
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
window: 'readonly',
|
||||||
|
document: 'readonly',
|
||||||
|
localStorage: 'readonly',
|
||||||
|
navigator: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Node config files.
|
||||||
|
files: ['*.config.{js,mjs,ts}'],
|
||||||
|
languageOptions: { globals: { process: 'readonly', URL: 'readonly' } },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-1
@@ -28,17 +28,18 @@
|
|||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
"@eslint/js": "^9.20.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.13.1",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.20.1",
|
||||||
"eslint-plugin-astro": "^1.3.1",
|
"eslint-plugin-astro": "^1.3.1",
|
||||||
"typescript-eslint": "^8.24.0",
|
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.1",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.24.0",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+623
-20
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ const nodeStroke = {
|
|||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<figure class:list={['overflow-hidden rounded-md border border-line bg-surface-1 p-4', cls]}>
|
<figure class:list={['border-line bg-surface-1 overflow-hidden rounded-md border p-4', cls]}>
|
||||||
<svg
|
<svg
|
||||||
viewBox={diagram.viewBox}
|
viewBox={diagram.viewBox}
|
||||||
role="img"
|
role="img"
|
||||||
|
|||||||
@@ -28,12 +28,20 @@ const projectType = id === 'inboxintel' ? 'SoftwareSourceCode' : 'SoftwareApplic
|
|||||||
title={m.title}
|
title={m.title}
|
||||||
description={m.description}
|
description={m.description}
|
||||||
ogType="article"
|
ogType="article"
|
||||||
jsonLdOpts={isCapability ? { projectName: project.name } : { projectName: project.name, projectType }}
|
jsonLdOpts={isCapability
|
||||||
|
? { projectName: project.name }
|
||||||
|
: { projectName: project.name, projectType }}
|
||||||
>
|
>
|
||||||
<article class="mx-auto max-w-[--content-max] px-6 py-14">
|
<article class="mx-auto max-w-[--content-max] px-6 py-14">
|
||||||
<CsHeader locale={locale} project={project} />
|
<CsHeader locale={locale} project={project} />
|
||||||
|
|
||||||
{hero && <div class="mt-8"><FramedImage media={hero} priority index={0} url={`${id}.local`} /></div>}
|
{
|
||||||
|
hero && (
|
||||||
|
<div class="mt-8">
|
||||||
|
<FramedImage media={hero} priority index={0} url={`${id}.local`} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div class:list={['mt-12 grid gap-12', !isCapability && 'xl:grid-cols-[1fr_240px]']}>
|
<div class:list={['mt-12 grid gap-12', !isCapability && 'xl:grid-cols-[1fr_240px]']}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const linkLabel: Record<string, string> = {
|
|||||||
<h1 class="text-h1">{project.name}</h1>
|
<h1 class="text-h1">{project.name}</h1>
|
||||||
<Chip kind="status" status={project.status} label={d.project.status[project.status]} />
|
<Chip kind="status" status={project.status} label={d.project.status[project.status]} />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 max-w-[60ch] text-body-lg text-ink-muted">{project.valueProp}</p>
|
<p class="text-body-lg text-ink-muted mt-4 max-w-[60ch]">{project.valueProp}</p>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||||
{project.stack.map((s) => <Chip kind="stack" label={s.name} tooltip={s.context} />)}
|
{project.stack.map((s) => <Chip kind="stack" label={s.name} tooltip={s.context} />)}
|
||||||
@@ -41,7 +41,7 @@ const linkLabel: Record<string, string> = {
|
|||||||
<a
|
<a
|
||||||
href={l.url}
|
href={l.url}
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-1 text-body text-accent hover:underline"
|
class="text-body text-accent inline-flex items-center gap-1 hover:underline"
|
||||||
>
|
>
|
||||||
{linkLabel[l.type] ?? l.type} ↗
|
{linkLabel[l.type] ?? l.type} ↗
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ const { decisions } = Astro.props;
|
|||||||
<ol class="flex flex-col gap-5">
|
<ol class="flex flex-col gap-5">
|
||||||
{
|
{
|
||||||
decisions.map((dec) => (
|
decisions.map((dec) => (
|
||||||
<li class="rounded-md border border-line bg-surface-1 p-5">
|
<li class="border-line bg-surface-1 rounded-md border p-5">
|
||||||
<p class="flex gap-3">
|
<p class="flex gap-3">
|
||||||
<span class="font-mono text-body font-semibold text-accent">
|
<span class="text-body text-accent font-mono font-semibold">
|
||||||
{String(dec.n).padStart(2, '0')}
|
{String(dec.n).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-semibold text-ink">{dec.choice}</span>
|
<span class="text-ink font-semibold">{dec.choice}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 pl-8 text-small text-ink-faint">
|
<p class="text-small text-ink-faint mt-2 pl-8">
|
||||||
<span class="font-mono">alt:</span> {dec.alternative}
|
<span class="font-mono">alt:</span> {dec.alternative}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 pl-8 text-body text-ink-muted">{dec.rationale}</p>
|
<p class="text-body text-ink-muted mt-2 pl-8">{dec.rationale}</p>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { media, startIndex = 0 } = Astro.props;
|
|||||||
media.map((m, i) => (
|
media.map((m, i) => (
|
||||||
<figure>
|
<figure>
|
||||||
<FramedImage media={m} index={startIndex + i} />
|
<FramedImage media={m} index={startIndex + i} />
|
||||||
<figcaption class="mt-2 font-mono text-mono-meta text-ink-faint">{m.caption}</figcaption>
|
<figcaption class="text-mono-meta text-ink-faint mt-2 font-mono">{m.caption}</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ const d = useDict(locale);
|
|||||||
|
|
||||||
<nav class="sticky top-24 hidden xl:block" aria-label={d.project.onThisPage}>
|
<nav class="sticky top-24 hidden xl:block" aria-label={d.project.onThisPage}>
|
||||||
<p class="mono-label mb-3">{d.project.onThisPage}</p>
|
<p class="mono-label mb-3">{d.project.onThisPage}</p>
|
||||||
<ul class="flex flex-col gap-2 border-l border-line">
|
<ul class="border-line flex flex-col gap-2 border-l">
|
||||||
{
|
{
|
||||||
sections.map((s) => (
|
sections.map((s) => (
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={`#${s.anchorId}`}
|
href={`#${s.anchorId}`}
|
||||||
data-spy-link={s.anchorId}
|
data-spy-link={s.anchorId}
|
||||||
class="toc-link -ml-px block border-l border-transparent py-1 pl-4 text-small text-ink-muted transition-colors hover:text-ink"
|
class="toc-link text-small text-ink-muted hover:text-ink -ml-px block border-l border-transparent py-1 pl-4 transition-colors"
|
||||||
>
|
>
|
||||||
{s.heading}
|
{s.heading}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ const next = idx < cards.length - 1 ? cards[idx + 1] : undefined;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="mt-20 flex flex-col gap-4 border-t border-line pt-8 sm:flex-row sm:items-center sm:justify-between"
|
class="border-line mt-20 flex flex-col gap-4 border-t pt-8 sm:flex-row sm:items-center sm:justify-between"
|
||||||
aria-label={d.project.all}
|
aria-label={d.project.all}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{
|
{
|
||||||
prev && (
|
prev && (
|
||||||
<a href={prev.path} class="group inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink">
|
<a
|
||||||
|
href={prev.path}
|
||||||
|
class="group text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
<span class="transition-transform group-hover:-translate-x-1">←</span>
|
<span class="transition-transform group-hover:-translate-x-1">←</span>
|
||||||
<span>
|
<span>
|
||||||
<span class="mono-label block">{d.project.prev}</span>
|
<span class="mono-label block">{d.project.prev}</span>
|
||||||
@@ -41,7 +44,10 @@ const next = idx < cards.length - 1 ? cards[idx + 1] : undefined;
|
|||||||
<div class="sm:text-right">
|
<div class="sm:text-right">
|
||||||
{
|
{
|
||||||
next && (
|
next && (
|
||||||
<a href={next.path} class="group inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink">
|
<a
|
||||||
|
href={next.path}
|
||||||
|
class="group text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
<span class="mono-label block">{d.project.next}</span>
|
<span class="mono-label block">{d.project.next}</span>
|
||||||
{next.name}
|
{next.name}
|
||||||
|
|||||||
@@ -23,12 +23,29 @@ interface Media {
|
|||||||
alt: string;
|
alt: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
}
|
}
|
||||||
|
interface DiagramNode {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
kind: 'internal' | 'primary' | 'external';
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
interface DiagramEdge {
|
||||||
|
d: string;
|
||||||
|
kind: 'flow' | 'external';
|
||||||
|
label?: string;
|
||||||
|
labelX?: number;
|
||||||
|
labelY?: number;
|
||||||
|
}
|
||||||
interface Diagram {
|
interface Diagram {
|
||||||
viewBox: string;
|
viewBox: string;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
nodes: any[];
|
nodes: DiagramNode[];
|
||||||
edges: any[];
|
edges: DiagramEdge[];
|
||||||
}
|
}
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -48,12 +65,12 @@ const galleryMedia = media.slice(1); // media[0] is the hero shown above the art
|
|||||||
<div class="prose-measure mt-4 flex flex-col gap-4">
|
<div class="prose-measure mt-4 flex flex-col gap-4">
|
||||||
{section.blocks.map((b) =>
|
{section.blocks.map((b) =>
|
||||||
b.type === 'p' ? (
|
b.type === 'p' ? (
|
||||||
<p class="text-body leading-relaxed text-ink-muted">{b.text}</p>
|
<p class="text-body text-ink-muted leading-relaxed">{b.text}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
{b.items?.map((it) => (
|
{b.items?.map((it) => (
|
||||||
<li class="flex gap-3 text-body text-ink-muted">
|
<li class="text-body text-ink-muted flex gap-3">
|
||||||
<span class="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
<span class="bg-accent mt-2.5 h-1 w-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||||
<span>{it}</span>
|
<span>{it}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -66,14 +83,20 @@ const galleryMedia = media.slice(1); // media[0] is the hero shown above the art
|
|||||||
|
|
||||||
{section.kind === 'architecture' && diagram && <ArchDiagram diagram={diagram} class="mt-6" />}
|
{section.kind === 'architecture' && diagram && <ArchDiagram diagram={diagram} class="mt-6" />}
|
||||||
|
|
||||||
{section.kind === 'decisions' && section.decisions && (
|
{
|
||||||
<div class="mt-6"><DecisionList decisions={section.decisions} /></div>
|
section.kind === 'decisions' && section.decisions && (
|
||||||
)}
|
<div class="mt-6">
|
||||||
|
<DecisionList decisions={section.decisions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
section.kind === 'screenshots' &&
|
section.kind === 'screenshots' &&
|
||||||
(galleryMedia.length > 0 ? (
|
(galleryMedia.length > 0 ? (
|
||||||
<div class="mt-6"><Gallery media={galleryMedia} startIndex={1} /></div>
|
<div class="mt-6">
|
||||||
|
<Gallery media={galleryMedia} startIndex={1} />
|
||||||
|
</div>
|
||||||
) : null)
|
) : null)
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ const rows: [string, string][] = [
|
|||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="relative overflow-hidden rounded-md bg-surface-2 p-6 pl-7">
|
<div class="bg-surface-2 relative overflow-hidden rounded-md p-6 pl-7">
|
||||||
<span class="absolute inset-y-0 left-0 w-[3px] bg-accent" aria-hidden="true"></span>
|
<span class="bg-accent absolute inset-y-0 left-0 w-[3px]" aria-hidden="true"></span>
|
||||||
<p class="mono-label mb-4">TL;DR</p>
|
<p class="mono-label mb-4">TL;DR</p>
|
||||||
<dl class="flex flex-col gap-2.5">
|
<dl class="flex flex-col gap-2.5">
|
||||||
{
|
{
|
||||||
rows.map(([term, val]) => (
|
rows.map(([term, val]) => (
|
||||||
<div class="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
<div class="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||||
<dt class="w-16 shrink-0 font-semibold text-ink">{term}</dt>
|
<dt class="text-ink w-16 shrink-0 font-semibold">{term}</dt>
|
||||||
<dd class="text-ink-muted">{val}</dd>
|
<dd class="text-ink-muted">{val}</dd>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -22,21 +22,21 @@ const nav: { id: PageId; label: string }[] = [
|
|||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="mt-24 border-t border-line bg-surface-1">
|
<footer class="border-line bg-surface-1 mt-24 border-t">
|
||||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 py-14 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 py-14 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<p class="font-display text-h4 font-semibold text-ink">Connor Babbington</p>
|
<p class="font-display text-h4 text-ink font-semibold">Connor Babbington</p>
|
||||||
<p class="mt-2 max-w-sm text-small text-ink-muted">{d.footer.tagline}</p>
|
<p class="text-small text-ink-muted mt-2 max-w-sm">{d.footer.tagline}</p>
|
||||||
<p class="mt-1 max-w-sm text-small text-ink-muted">{d.footer.availability}</p>
|
<p class="text-small text-ink-muted mt-1 max-w-sm">{d.footer.availability}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav aria-label={d.a11y.primaryNav}>
|
<nav aria-label={d.a11y.primaryNav}>
|
||||||
<p class="mono-label mb-3">{d.nav.projects}</p>
|
<p class="mono-label mb-3">{d.nav.projects}</p>
|
||||||
<ul class="flex flex-col gap-2 text-small text-ink-muted">
|
<ul class="text-small text-ink-muted flex flex-col gap-2">
|
||||||
{
|
{
|
||||||
nav.map((item) => (
|
nav.map((item) => (
|
||||||
<li>
|
<li>
|
||||||
<a class="transition-colors hover:text-ink" href={pathFor(item.id, locale)}>
|
<a class="hover:text-ink transition-colors" href={pathFor(item.id, locale)}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -47,33 +47,41 @@ const nav: { id: PageId; label: string }[] = [
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="mono-label mb-3">Contact</p>
|
<p class="mono-label mb-3">Contact</p>
|
||||||
<ul class="flex flex-col gap-2 font-mono text-mono-meta text-ink-muted">
|
<ul class="text-mono-meta text-ink-muted flex flex-col gap-2 font-mono">
|
||||||
<li>
|
<li>
|
||||||
<a class="transition-colors hover:text-ink" href={`mailto:${p.links.email}`}
|
<a class="hover:text-ink transition-colors" href={`mailto:${p.links.email}`}
|
||||||
>{p.links.email}</a
|
>{p.links.email}</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li><a class="transition-colors hover:text-ink" href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a></li>
|
|
||||||
<li>
|
<li>
|
||||||
<a class="transition-colors hover:text-ink" href={p.links.gitea} rel="me noopener"
|
<a class="hover:text-ink transition-colors" href={p.links.linkedin} rel="me noopener"
|
||||||
|
>LinkedIn ↗</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="hover:text-ink transition-colors" href={p.links.gitea} rel="me noopener"
|
||||||
>git.cesnimda.uk ↗</a
|
>git.cesnimda.uk ↗</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li><a class="transition-colors hover:text-ink" href={pathFor('cv', locale)}>{d.cv.download}</a></li>
|
<li>
|
||||||
|
<a class="hover:text-ink transition-colors" href={pathFor('cv', locale)}
|
||||||
|
>{d.cv.download}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex max-w-[--content-max] flex-col items-start justify-between gap-4 border-t border-line px-6 py-6 sm:flex-row sm:items-center"
|
class="border-line mx-auto flex max-w-[--content-max] flex-col items-start justify-between gap-4 border-t px-6 py-6 sm:flex-row sm:items-center"
|
||||||
>
|
>
|
||||||
<p class="font-mono text-mono-meta text-ink-faint">
|
<p class="text-mono-meta text-ink-faint font-mono">
|
||||||
{interpolate(d.footer.copyright, { year })}
|
{interpolate(d.footer.copyright, { year })}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href={pathFor('colophon', locale)}
|
href={pathFor('colophon', locale)}
|
||||||
class="font-mono text-mono-meta text-ink-muted transition-colors hover:text-ink"
|
class="text-mono-meta text-ink-muted hover:text-ink font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{d.footer.colophon}
|
{d.footer.colophon}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ const navItems: { id: PageId; label: string }[] = [
|
|||||||
{ id: 'contact', label: d.nav.contact },
|
{ id: 'contact', label: d.nav.contact },
|
||||||
];
|
];
|
||||||
const isCurrent = (id: PageId) =>
|
const isCurrent = (id: PageId) =>
|
||||||
id === pageId ||
|
id === pageId || (id === 'projects' && (PROJECT_PAGE_IDS as readonly string[]).includes(pageId));
|
||||||
(id === 'projects' && (PROJECT_PAGE_IDS as readonly string[]).includes(pageId));
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<header
|
<header
|
||||||
id="site-header"
|
id="site-header"
|
||||||
class="sticky top-0 z-40 border-b border-transparent bg-surface-0/80 backdrop-blur-md"
|
class="bg-surface-0/80 sticky top-0 z-40 border-b border-transparent backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
||||||
@@ -35,11 +34,11 @@ const isCurrent = (id: PageId) =>
|
|||||||
{/* Monogram → home */}
|
{/* Monogram → home */}
|
||||||
<a
|
<a
|
||||||
href={pathFor('home', locale)}
|
href={pathFor('home', locale)}
|
||||||
class="group relative inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line-strong bg-surface-1 font-mono text-small font-bold text-ink"
|
class="group border-line-strong bg-surface-1 text-small text-ink relative inline-flex h-8 w-8 items-center justify-center rounded-sm border font-mono font-bold"
|
||||||
aria-label="Connor Babbington — home"
|
aria-label="Connor Babbington — home"
|
||||||
>
|
>
|
||||||
CB
|
CB
|
||||||
<span class="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-accent"></span>
|
<span class="bg-accent absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
@@ -50,7 +49,7 @@ const isCurrent = (id: PageId) =>
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={pathFor(item.id, locale)}
|
href={pathFor(item.id, locale)}
|
||||||
class="nav-link text-small text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
class="nav-link text-small text-ink-muted hover:text-ink transition-colors duration-[--dur-quick]"
|
||||||
aria-current={isCurrent(item.id) ? 'page' : undefined}
|
aria-current={isCurrent(item.id) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -72,7 +71,7 @@ const isCurrent = (id: PageId) =>
|
|||||||
<div class="flex items-center gap-2 md:hidden">
|
<div class="flex items-center gap-2 md:hidden">
|
||||||
<a
|
<a
|
||||||
href={pathFor('cv', locale)}
|
href={pathFor('cv', locale)}
|
||||||
class="inline-flex h-9 items-center rounded-sm bg-accent px-3 text-small font-semibold text-accent-ink"
|
class="bg-accent text-small text-accent-ink inline-flex h-9 items-center rounded-sm px-3 font-semibold"
|
||||||
aria-label={d.cv.download}
|
aria-label={d.cv.download}
|
||||||
>
|
>
|
||||||
CV
|
CV
|
||||||
@@ -80,13 +79,17 @@ const isCurrent = (id: PageId) =>
|
|||||||
<button
|
<button
|
||||||
id="mobile-nav-toggle"
|
id="mobile-nav-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-sm border border-line text-ink"
|
class="border-line text-ink inline-flex h-9 w-9 items-center justify-center rounded-sm border"
|
||||||
aria-label={d.a11y.openMenu}
|
aria-label={d.a11y.openMenu}
|
||||||
aria-controls="mobile-nav"
|
aria-controls="mobile-nav"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path>
|
<path
|
||||||
|
d="M4 7h16M4 12h16M4 17h16"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke-linecap="round"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ const { locale, pageId } = Astro.props;
|
|||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="lang-hint" hidden class="border-b border-line bg-surface-2">
|
<div id="lang-hint" hidden class="border-line bg-surface-2 border-b">
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex max-w-[--content-max] items-center gap-4 px-6 py-2.5 text-small text-ink"
|
class="text-small text-ink mx-auto flex max-w-[--content-max] items-center gap-4 px-6 py-2.5"
|
||||||
>
|
>
|
||||||
<span>{d.hint.text}</span>
|
<span>{d.hint.text}</span>
|
||||||
<a
|
<a
|
||||||
href={pathFor(pageId, 'no')}
|
href={pathFor(pageId, 'no')}
|
||||||
class="rounded-sm bg-accent px-3 py-1 text-mono-label font-semibold text-accent-ink"
|
class="bg-accent text-mono-label text-accent-ink rounded-sm px-3 py-1 font-semibold"
|
||||||
>
|
>
|
||||||
{d.hint.action}
|
{d.hint.action}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-hint-dismiss
|
data-hint-dismiss
|
||||||
class="ml-auto text-ink-muted hover:text-ink"
|
class="text-ink-muted hover:text-ink ml-auto"
|
||||||
aria-label={d.hint.dismiss}
|
aria-label={d.hint.dismiss}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -30,24 +30,28 @@ const navItems: { id: PageId; label: string }[] = [
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="mobile-nav"
|
id="mobile-nav"
|
||||||
class="fixed inset-0 z-50 hidden flex-col bg-surface-1 px-6 pb-8 pt-4 data-[open]:flex md:hidden"
|
class="bg-surface-1 fixed inset-0 z-50 hidden flex-col px-6 pt-4 pb-8 data-[open]:flex md:hidden"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={d.a11y.primaryNav}
|
aria-label={d.a11y.primaryNav}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line-strong bg-surface-2 font-mono text-small font-bold text-ink"
|
class="border-line-strong bg-surface-2 text-small text-ink inline-flex h-8 w-8 items-center justify-center rounded-sm border font-mono font-bold"
|
||||||
>CB</span
|
>CB</span
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
id="mobile-nav-close"
|
id="mobile-nav-close"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-sm border border-line text-ink"
|
class="border-line text-ink inline-flex h-9 w-9 items-center justify-center rounded-sm border"
|
||||||
aria-label={d.a11y.closeMenu}
|
aria-label={d.a11y.closeMenu}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path>
|
<path
|
||||||
|
d="M6 6l12 12M18 6L6 18"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke-linecap="round"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +66,7 @@ const navItems: { id: PageId; label: string }[] = [
|
|||||||
navItems.map((item) => (
|
navItems.map((item) => (
|
||||||
<a
|
<a
|
||||||
href={pathFor(item.id, locale)}
|
href={pathFor(item.id, locale)}
|
||||||
class="border-b border-line py-4 font-display text-h3 font-semibold text-ink"
|
class="border-line font-display text-h3 text-ink border-b py-4 font-semibold"
|
||||||
aria-current={item.id === pageId ? 'page' : undefined}
|
aria-current={item.id === pageId ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -75,20 +79,20 @@ const navItems: { id: PageId; label: string }[] = [
|
|||||||
<a
|
<a
|
||||||
href={p.cv.en.path}
|
href={p.cv.en.path}
|
||||||
download
|
download
|
||||||
class="rounded-sm bg-accent px-4 py-3 text-center text-body font-semibold text-accent-ink"
|
class="bg-accent text-body text-accent-ink rounded-sm px-4 py-3 text-center font-semibold"
|
||||||
>
|
>
|
||||||
{d.cv.download} — {d.cv.english}
|
{d.cv.download} — {d.cv.english}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={p.cv.no.path}
|
href={p.cv.no.path}
|
||||||
download
|
download
|
||||||
class="rounded-sm border border-line-strong px-4 py-3 text-center text-body text-ink"
|
class="border-line-strong text-body text-ink rounded-sm border px-4 py-3 text-center"
|
||||||
>
|
>
|
||||||
{d.cv.download} — {d.cv.norsk}
|
{d.cv.download} — {d.cv.norsk}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto flex flex-col gap-2 pt-8 font-mono text-mono-meta text-ink-muted">
|
<div class="text-mono-meta text-ink-muted mt-auto flex flex-col gap-2 pt-8 font-mono">
|
||||||
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
||||||
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
||||||
<a href={p.links.gitea} rel="me noopener">git.cesnimda.uk ↗</a>
|
<a href={p.links.gitea} rel="me noopener">git.cesnimda.uk ↗</a>
|
||||||
|
|||||||
@@ -50,8 +50,4 @@ const jsonLd = buildJsonLd(pageId, locale, origin, jsonLdOpts);
|
|||||||
<meta name="robots" content="index,follow,max-image-preview:large" />
|
<meta name="robots" content="index,follow,max-image-preview:large" />
|
||||||
<meta http-equiv="content-language" content={HTML_LANG[locale]} />
|
<meta http-equiv="content-language" content={HTML_LANG[locale]} />
|
||||||
|
|
||||||
{
|
{jsonLd.map((obj) => <script type="application/ld+json" set:html={JSON.stringify(obj)} />)}
|
||||||
jsonLd.map((obj) => (
|
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(obj)} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,13 +8,16 @@
|
|||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(function () {
|
||||||
|
// Mark that JS is available; reveal animations are gated behind html.js so
|
||||||
|
// content stays visible without JavaScript.
|
||||||
|
document.documentElement.classList.add('js');
|
||||||
try {
|
try {
|
||||||
var t = localStorage.getItem('theme');
|
let t = localStorage.getItem('theme');
|
||||||
if (t !== 'light' && t !== 'dark') {
|
if (t !== 'light' && t !== 'dark') {
|
||||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
}
|
}
|
||||||
document.documentElement.dataset.theme = t;
|
document.documentElement.dataset.theme = t;
|
||||||
} catch (e) {
|
} catch {
|
||||||
document.documentElement.dataset.theme = 'dark';
|
document.documentElement.dataset.theme = 'dark';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -13,21 +13,25 @@ const { locale, profile } = Astro.props;
|
|||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={homeAnchor('about', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
<section
|
||||||
|
id={homeAnchor('about', locale)}
|
||||||
|
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||||
|
data-reveal
|
||||||
|
>
|
||||||
<SectionLabel number="04" text={d.home.aboutLabel} />
|
<SectionLabel number="04" text={d.home.aboutLabel} />
|
||||||
<div class="mt-4 grid gap-8 lg:grid-cols-12">
|
<div class="mt-4 grid gap-8 lg:grid-cols-12">
|
||||||
<div class="lg:col-span-8">
|
<div class="lg:col-span-8">
|
||||||
<h2 class="text-h2">{profile.aboutHeading}</h2>
|
<h2 class="text-h2">{profile.aboutHeading}</h2>
|
||||||
<p class="mt-4 max-w-[64ch] text-body-lg text-ink-muted">{profile.aboutParagraphs[0]}</p>
|
<p class="text-body-lg text-ink-muted mt-4 max-w-[64ch]">{profile.aboutParagraphs[0]}</p>
|
||||||
<a
|
<a
|
||||||
href={pathFor('about', locale)}
|
href={pathFor('about', locale)}
|
||||||
class="mt-6 inline-flex items-center gap-2 text-body text-accent hover:underline"
|
class="text-body text-accent mt-6 inline-flex items-center gap-2 hover:underline"
|
||||||
>
|
>
|
||||||
{profile.aboutHeading} →
|
{profile.aboutHeading} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:col-span-4">
|
<div class="lg:col-span-4">
|
||||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
<p class="text-mono-label text-ink-faint font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
{d.about.languages}
|
{d.about.languages}
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-3 flex flex-col gap-2">
|
<ul class="mt-3 flex flex-col gap-2">
|
||||||
|
|||||||
@@ -12,23 +12,27 @@ const { locale, profile } = Astro.props;
|
|||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={homeAnchor('contact', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
<section
|
||||||
<div class="rounded-lg border border-line bg-surface-2 p-8 md:p-12">
|
id={homeAnchor('contact', locale)}
|
||||||
<h2 class="max-w-[24ch] text-h2">{profile.contactHeading}</h2>
|
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||||
<p class="mt-3 text-body-lg text-ink-muted">{profile.contactBody}</p>
|
data-reveal
|
||||||
|
>
|
||||||
|
<div class="border-line bg-surface-2 rounded-lg border p-8 md:p-12">
|
||||||
|
<h2 class="text-h2 max-w-[24ch]">{profile.contactHeading}</h2>
|
||||||
|
<p class="text-body-lg text-ink-muted mt-3">{profile.contactBody}</p>
|
||||||
<div class="mt-7 flex flex-wrap items-center gap-4">
|
<div class="mt-7 flex flex-wrap items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href={`mailto:${profile.links.email}`}
|
href={`mailto:${profile.links.email}`}
|
||||||
data-copy={profile.links.email}
|
data-copy={profile.links.email}
|
||||||
data-copied-label={d.form.copied}
|
data-copied-label={d.form.copied}
|
||||||
class="inline-flex items-center gap-2 rounded-sm bg-accent px-5 py-3 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
class="bg-accent text-body text-accent-ink inline-flex items-center gap-2 rounded-sm px-5 py-3 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
>
|
>
|
||||||
<span data-copy-label>{profile.links.email}</span>
|
<span data-copy-label>{profile.links.email}</span>
|
||||||
<span aria-hidden="true">⧉</span>
|
<span aria-hidden="true">⧉</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={pathFor('contact', locale)}
|
href={pathFor('contact', locale)}
|
||||||
class="inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink"
|
class="text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{d.nav.contact} →
|
{d.nav.contact} →
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -26,42 +26,48 @@ const period = (from: number, to: number | null) => `${from}–${to ?? d.home.pr
|
|||||||
eyebrow && (
|
eyebrow && (
|
||||||
<>
|
<>
|
||||||
<SectionLabel number="03" text={d.home.experienceLabel} />
|
<SectionLabel number="03" text={d.home.experienceLabel} />
|
||||||
<h2 class="mt-4 text-h2">{d.home.experienceHeading}</h2>
|
<h2 class="text-h2 mt-4">{d.home.experienceHeading}</h2>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="relative mt-10 pl-8">
|
<div class="relative mt-10 pl-8">
|
||||||
<span
|
<span
|
||||||
class="timeline-spine absolute bottom-2 left-[5px] top-2 w-px origin-top bg-line-strong"
|
class="timeline-spine bg-line-strong absolute top-2 bottom-2 left-[5px] w-px origin-top"
|
||||||
aria-hidden="true"></span>
|
aria-hidden="true"></span>
|
||||||
|
|
||||||
{
|
{
|
||||||
items.map((item, i) => (
|
items.map((item, i) => (
|
||||||
<>
|
<>
|
||||||
{i === firstAlongside && (
|
{i === firstAlongside && (
|
||||||
<p class="mb-3 mt-6 font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
<p class="text-mono-label text-ink-faint mt-6 mb-3 font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
{d.home.earlierRoles}
|
{d.home.earlierRoles}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.emphasis === 'featured' ? (
|
{item.emphasis === 'featured' ? (
|
||||||
<div class="relative pb-10">
|
<div class="relative pb-10">
|
||||||
<span class="absolute -left-8 top-1.5 h-2.5 w-2.5 rounded-full bg-accent ring-4 ring-surface-0" aria-hidden="true" />
|
<span
|
||||||
<h3 class="text-h4 font-semibold text-ink">
|
class="bg-accent ring-surface-0 absolute top-1.5 -left-8 h-2.5 w-2.5 rounded-full ring-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 class="text-h4 text-ink font-semibold">
|
||||||
{item.role} — {item.employer}
|
{item.role} — {item.employer}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 font-mono text-mono-meta text-ink-faint">
|
<p class="text-mono-meta text-ink-faint mt-1 font-mono">
|
||||||
{period(item.period.from, item.period.to)} · {item.location}
|
{period(item.period.from, item.period.to)} · {item.location}
|
||||||
{item.progression &&
|
{item.progression &&
|
||||||
` · ${item.progression.map((p) => `${p.from}–${p.to} ${p.label}`).join(' → ')}`}
|
` · ${item.progression.map((p) => `${p.from}–${p.to} ${p.label}`).join(' → ')}`}
|
||||||
</p>
|
</p>
|
||||||
{item.summary && <p class="mt-3 text-body text-ink-muted">{item.summary}</p>}
|
{item.summary && <p class="text-body text-ink-muted mt-3">{item.summary}</p>}
|
||||||
{item.highlights && (
|
{item.highlights && (
|
||||||
<ul class="mt-3 flex flex-col gap-2">
|
<ul class="mt-3 flex flex-col gap-2">
|
||||||
{item.highlights.map((h) => (
|
{item.highlights.map((h) => (
|
||||||
<li class="flex gap-3 text-body text-ink-muted">
|
<li class="text-body text-ink-muted flex gap-3">
|
||||||
<span class="mt-2 h-1 w-1 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
<span
|
||||||
|
class="bg-accent mt-2 h-1 w-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<span>{h}</span>
|
<span>{h}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -70,9 +76,14 @@ const period = (from: number, to: number | null) => `${from}–${to ?? d.home.pr
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="relative py-2.5">
|
<div class="relative py-2.5">
|
||||||
<span class="absolute -left-8 top-4 h-2 w-2 rounded-full bg-ink-faint ring-4 ring-surface-0" aria-hidden="true" />
|
<span
|
||||||
|
class="bg-ink-faint ring-surface-0 absolute top-4 -left-8 h-2 w-2 rounded-full ring-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<p class="text-small text-ink-muted">
|
<p class="text-small text-ink-muted">
|
||||||
<span class="font-mono text-ink-faint">{period(item.period.from, item.period.to)}</span>
|
<span class="text-ink-faint font-mono">
|
||||||
|
{period(item.period.from, item.period.to)}
|
||||||
|
</span>
|
||||||
<span class="mx-2">·</span>
|
<span class="mx-2">·</span>
|
||||||
<span class="text-ink">{item.role}</span>
|
<span class="text-ink">{item.role}</span>
|
||||||
<span class="mx-2">·</span>
|
<span class="mx-2">·</span>
|
||||||
|
|||||||
@@ -23,35 +23,38 @@ const d = useDict(locale);
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 pb-14 pt-16 lg:grid-cols-12 lg:pt-24">
|
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 pt-16 pb-14 lg:grid-cols-12 lg:pt-24">
|
||||||
<div class="lg:col-span-7">
|
<div class="lg:col-span-7">
|
||||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-accent">
|
<p class="text-mono-label text-accent font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
{profile.eyebrow}
|
{profile.eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-4 text-display tracking-[-0.01em]">{profile.name}</h1>
|
<h1 class="text-display mt-4 tracking-[-0.01em]">{profile.name}</h1>
|
||||||
<p class="mt-4 font-display text-h3 font-medium text-ink">{profile.heroTagline}</p>
|
<p class="font-display text-h3 text-ink mt-4 font-medium">{profile.heroTagline}</p>
|
||||||
<p class="mt-4 max-w-[52ch] text-body-lg text-ink-muted">{profile.heroSummary}</p>
|
<p class="text-body-lg text-ink-muted mt-4 max-w-[52ch]">{profile.heroSummary}</p>
|
||||||
|
|
||||||
<TraceMotif statusLine={profile.statusLine} class="mt-8" />
|
<TraceMotif statusLine={profile.statusLine} class="mt-8" />
|
||||||
|
|
||||||
<ul class="mt-7 flex flex-wrap gap-2">
|
<ul class="mt-7 flex flex-wrap gap-2">
|
||||||
{profile.chips.map((chip) => <li><Chip kind="fact" label={chip.label} /></li>)}
|
{
|
||||||
|
profile.chips.map((chip) => (
|
||||||
|
<li>
|
||||||
|
<Chip kind="fact" label={chip.label} />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<Button variant="primary" href={pathFor('projects', locale)}>{d.hero.viewProjects}</Button>
|
<Button variant="primary" href={pathFor('projects', locale)}>{d.hero.viewProjects}</Button>
|
||||||
<Button variant="secondary" href={pathFor('cv', locale)}>
|
<Button variant="secondary" href={pathFor('cv', locale)}>
|
||||||
{d.cv.download} <span class="font-mono text-mono-label opacity-70">· pdf</span>
|
{d.cv.download}
|
||||||
|
<span class="text-mono-label font-mono opacity-70">· pdf</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:col-span-5">
|
<div class="lg:col-span-5">
|
||||||
<Portrait
|
<Portrait src={profile.photo.src} alt={profile.photo.alt} caption={profile.photo.caption} />
|
||||||
src={profile.photo.src}
|
|
||||||
alt={profile.photo.alt}
|
|
||||||
caption={profile.photo.caption}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const capabilities = projects.filter((p) => p.template === 'capability');
|
|||||||
header && (
|
header && (
|
||||||
<>
|
<>
|
||||||
<SectionLabel number={number} text={d.home.projectsLabel} />
|
<SectionLabel number={number} text={d.home.projectsLabel} />
|
||||||
<h2 class="mt-4 text-h2">{heading ?? d.home.projectsHeading}</h2>
|
<h2 class="text-h2 mt-4">{heading ?? d.home.projectsHeading}</h2>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ const capabilities = projects.filter((p) => p.template === 'capability');
|
|||||||
caseStudies.map((p) => (
|
caseStudies.map((p) => (
|
||||||
<a
|
<a
|
||||||
href={p.path}
|
href={p.path}
|
||||||
class="group block overflow-hidden rounded-md border border-line bg-surface-1 transition-colors duration-[--dur-standard] hover:border-line-strong"
|
class="group border-line bg-surface-1 hover:border-line-strong block overflow-hidden rounded-md border transition-colors duration-[--dur-standard]"
|
||||||
aria-label={`${p.name} — ${d.project.readCaseStudy}`}
|
aria-label={`${p.name} — ${d.project.readCaseStudy}`}
|
||||||
>
|
>
|
||||||
<div class="relative overflow-hidden">
|
<div class="relative overflow-hidden">
|
||||||
@@ -48,13 +48,13 @@ const capabilities = projects.filter((p) => p.template === 'capability');
|
|||||||
<FramedImage media={p.heroMedia} noZoom class="rounded-none border-0" />
|
<FramedImage media={p.heroMedia} noZoom class="rounded-none border-0" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div class="absolute right-4 top-4">
|
<div class="absolute top-4 right-4">
|
||||||
<Chip kind="status" status={p.status} label={d.project.status[p.status]} />
|
<Chip kind="status" status={p.status} label={d.project.status[p.status]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h3 class="text-h3 font-semibold text-ink">{p.name}</h3>
|
<h3 class="text-h3 text-ink font-semibold">{p.name}</h3>
|
||||||
<p class="mt-2 max-w-prose text-body text-ink-muted">{p.cardTeaser}</p>
|
<p class="text-body text-ink-muted mt-2 max-w-prose">{p.cardTeaser}</p>
|
||||||
<ul class="mt-4 flex flex-wrap gap-2">
|
<ul class="mt-4 flex flex-wrap gap-2">
|
||||||
{p.stack.slice(0, 5).map((s) => (
|
{p.stack.slice(0, 5).map((s) => (
|
||||||
<li>
|
<li>
|
||||||
@@ -62,9 +62,11 @@ const capabilities = projects.filter((p) => p.template === 'capability');
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-5 inline-flex items-center gap-2 text-body text-accent">
|
<p class="text-body text-accent mt-5 inline-flex items-center gap-2">
|
||||||
{d.project.readCaseStudy}
|
{d.project.readCaseStudy}
|
||||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">→</span>
|
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -76,23 +78,52 @@ const capabilities = projects.filter((p) => p.template === 'capability');
|
|||||||
capabilities.map((p) => (
|
capabilities.map((p) => (
|
||||||
<a
|
<a
|
||||||
href={p.path}
|
href={p.path}
|
||||||
class="group mt-6 flex flex-col items-start gap-4 rounded-md border border-line bg-surface-1 p-6 transition-colors duration-[--dur-standard] hover:border-line-strong sm:flex-row sm:items-center"
|
class="group border-line bg-surface-1 hover:border-line-strong mt-6 flex flex-col items-start gap-4 rounded-md border p-6 transition-colors duration-[--dur-standard] sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="border-accent/40 text-accent grid h-12 w-12 shrink-0 place-items-center rounded-sm border"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-sm border border-accent/40 text-accent" aria-hidden="true">
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
<rect
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
x="3"
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
y="3"
|
||||||
|
width="7"
|
||||||
|
height="7"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="14"
|
||||||
|
width="7"
|
||||||
|
height="7"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="14"
|
||||||
|
y="14"
|
||||||
|
width="7"
|
||||||
|
height="7"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
<path d="M13.5 6.5H18a2 2 0 0 1 2 2V11" stroke="currentColor" stroke-width="1.5" />
|
<path d="M13.5 6.5H18a2 2 0 0 1 2 2V11" stroke="currentColor" stroke-width="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="text-h4 font-semibold text-ink">{p.name}</h3>
|
<h3 class="text-h4 text-ink font-semibold">{p.name}</h3>
|
||||||
<p class="mt-1 text-small text-ink-muted">{p.cardTeaser}</p>
|
<p class="text-small text-ink-muted mt-1">{p.cardTeaser}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center gap-2 text-body text-accent">
|
<span class="text-body text-accent inline-flex items-center gap-2">
|
||||||
{d.project.explore}
|
{d.project.explore}
|
||||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">→</span>
|
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const { profile } = Astro.props;
|
|||||||
<ul class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<ul class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
{
|
{
|
||||||
profile.proof.map((tile) => (
|
profile.proof.map((tile) => (
|
||||||
<li class="rounded-md border border-line bg-surface-1 p-5">
|
<li class="border-line bg-surface-1 rounded-md border p-5">
|
||||||
<p class="font-display text-[22px] font-semibold leading-tight text-ink">{tile.stat}</p>
|
<p class="font-display text-ink text-[22px] leading-tight font-semibold">{tile.stat}</p>
|
||||||
<p class="mt-2 font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
<p class="text-mono-label text-ink-faint mt-2 font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
{tile.label}
|
{tile.label}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -15,19 +15,23 @@ const groups = getSkills(locale);
|
|||||||
const num = ['01', '02', '03'];
|
const num = ['01', '02', '03'];
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={homeAnchor('skills', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
<section
|
||||||
|
id={homeAnchor('skills', locale)}
|
||||||
|
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||||
|
data-reveal
|
||||||
|
>
|
||||||
<SectionLabel number="01" text={d.home.skillsLabel} />
|
<SectionLabel number="01" text={d.home.skillsLabel} />
|
||||||
<h2 class="mt-4 text-h2">{d.home.skillsHeading}</h2>
|
<h2 class="text-h2 mt-4">{d.home.skillsHeading}</h2>
|
||||||
<p class="mt-3 max-w-[60ch] text-body text-ink-muted">{d.home.skillsIntro}</p>
|
<p class="text-body text-ink-muted mt-3 max-w-[60ch]">{d.home.skillsIntro}</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-5 md:grid-cols-3">
|
<div class="mt-8 grid gap-5 md:grid-cols-3">
|
||||||
{
|
{
|
||||||
groups.map((group, i) => (
|
groups.map((group, i) => (
|
||||||
<div class="rounded-md border border-line bg-surface-1 p-6 transition-colors duration-[--dur-standard] hover:border-line-strong">
|
<div class="border-line bg-surface-1 hover:border-line-strong rounded-md border p-6 transition-colors duration-[--dur-standard]">
|
||||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
<p class="text-mono-label text-ink-faint font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
{num[i]} — {group.title}
|
{num[i]} — {group.title}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-3 text-small text-ink-muted">{group.context}</p>
|
<p class="text-small text-ink-muted mt-3">{group.context}</p>
|
||||||
<ul class="mt-4 flex flex-wrap gap-2">
|
<ul class="mt-4 flex flex-wrap gap-2">
|
||||||
{group.skills.map((s) => (
|
{group.skills.map((s) => (
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ const m = getMeta('about', locale);
|
|||||||
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||||
<div class="lg:col-span-7">
|
<div class="lg:col-span-7">
|
||||||
<SectionLabel text={d.nav.about} />
|
<SectionLabel text={d.nav.about} />
|
||||||
<h1 class="mt-4 text-h1">{profile.aboutHeading}</h1>
|
<h1 class="text-h1 mt-4">{profile.aboutHeading}</h1>
|
||||||
<div class="mt-6 flex flex-col gap-4">
|
<div class="mt-6 flex flex-col gap-4">
|
||||||
{profile.aboutParagraphs.map((para) => <p class="max-w-[64ch] text-body-lg text-ink-muted">{para}</p>)}
|
{
|
||||||
|
profile.aboutParagraphs.map((para) => (
|
||||||
|
<p class="text-body-lg text-ink-muted max-w-[64ch]">{para}</p>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-8">
|
<p class="mt-8">
|
||||||
<span class="mono-label mb-2 block">{d.about.interests}</span>
|
<span class="mono-label mb-2 block">{d.about.interests}</span>
|
||||||
|
|||||||
@@ -20,21 +20,51 @@ const copy = {
|
|||||||
en: {
|
en: {
|
||||||
lead: 'This site is a small work sample in its own right. A few of the decisions behind it:',
|
lead: 'This site is a small work sample in its own right. A few of the decisions behind it:',
|
||||||
points: [
|
points: [
|
||||||
['Static by default', 'Built with Astro and served as static HTML — no client framework runtime, so the largest content paint is just text and CSS. The three interactive parts (menu, lightbox, contact form) are tiny progressive-enhancement modules.'],
|
[
|
||||||
['Bilingual from the ground up', 'English and Norwegian are equal citizens: a typed slug map is the single source of truth for routes, the language switch and hreflang, so they can never drift apart.'],
|
'Static by default',
|
||||||
['Motion that confirms, never performs', 'One signature animation, everything else is restraint. Every effect has a reduced-motion variant, and content is never gated behind an animation.'],
|
'Built with Astro and served as static HTML — no client framework runtime, so the largest content paint is just text and CSS. The three interactive parts (menu, lightbox, contact form) are tiny progressive-enhancement modules.',
|
||||||
['Self-hosted', 'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.'],
|
],
|
||||||
['No tracking', 'No analytics beacons, no third-party fonts, no cookies — which is also why there is no cookie banner.'],
|
[
|
||||||
|
'Bilingual from the ground up',
|
||||||
|
'English and Norwegian are equal citizens: a typed slug map is the single source of truth for routes, the language switch and hreflang, so they can never drift apart.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Motion that confirms, never performs',
|
||||||
|
'One signature animation, everything else is restraint. Every effect has a reduced-motion variant, and content is never gated behind an animation.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Self-hosted',
|
||||||
|
'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'No tracking',
|
||||||
|
'No analytics beacons, no third-party fonts, no cookies — which is also why there is no cookie banner.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
lead: 'Denne siden er også et lite arbeidsprøve i seg selv. Noen av valgene bak den:',
|
lead: 'Denne siden er også et lite arbeidsprøve i seg selv. Noen av valgene bak den:',
|
||||||
points: [
|
points: [
|
||||||
['Statisk som standard', 'Bygget med Astro og servert som statisk HTML — ingen klient-rammeverk i drift, så det første innholdet som vises er bare tekst og CSS. De tre interaktive delene (meny, lightbox, kontaktskjema) er små moduler for progressiv forbedring.'],
|
[
|
||||||
['Tospråklig fra bunnen', 'Engelsk og norsk er likestilt: et typet slug-kart er én kilde til sannhet for ruter, språkbytte og hreflang, så de kan aldri komme i utakt.'],
|
'Statisk som standard',
|
||||||
['Bevegelse som bekrefter, aldri opptrer', 'Én signaturanimasjon, ellers tilbakeholdenhet. Hver effekt har en variant for redusert bevegelse, og innhold er aldri sperret bak en animasjon.'],
|
'Bygget med Astro og servert som statisk HTML — ingen klient-rammeverk i drift, så det første innholdet som vises er bare tekst og CSS. De tre interaktive delene (meny, lightbox, kontaktskjema) er små moduler for progressiv forbedring.',
|
||||||
['Egendriftet', 'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.'],
|
],
|
||||||
['Ingen sporing', 'Ingen analyse-beacons, ingen tredjeparts-fonter, ingen informasjonskapsler — som også er grunnen til at det ikke finnes noe cookie-banner.'],
|
[
|
||||||
|
'Tospråklig fra bunnen',
|
||||||
|
'Engelsk og norsk er likestilt: et typet slug-kart er én kilde til sannhet for ruter, språkbytte og hreflang, så de kan aldri komme i utakt.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Bevegelse som bekrefter, aldri opptrer',
|
||||||
|
'Én signaturanimasjon, ellers tilbakeholdenhet. Hver effekt har en variant for redusert bevegelse, og innhold er aldri sperret bak en animasjon.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Egendriftet',
|
||||||
|
'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Ingen sporing',
|
||||||
|
'Ingen analyse-beacons, ingen tredjeparts-fonter, ingen informasjonskapsler — som også er grunnen til at det ikke finnes noe cookie-banner.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}[locale];
|
}[locale];
|
||||||
@@ -43,15 +73,15 @@ const copy = {
|
|||||||
<Base locale={locale} pageId="colophon" title={m.title} description={m.description}>
|
<Base locale={locale} pageId="colophon" title={m.title} description={m.description}>
|
||||||
<div class="mx-auto max-w-3xl px-6 py-16">
|
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||||
<SectionLabel text={d.footer.colophon} />
|
<SectionLabel text={d.footer.colophon} />
|
||||||
<h1 class="mt-4 text-h1">{d.footer.colophon}</h1>
|
<h1 class="text-h1 mt-4">{d.footer.colophon}</h1>
|
||||||
<p class="mt-4 text-body-lg text-ink-muted">{copy.lead}</p>
|
<p class="text-body-lg text-ink-muted mt-4">{copy.lead}</p>
|
||||||
|
|
||||||
<dl class="mt-8 flex flex-col gap-6">
|
<dl class="mt-8 flex flex-col gap-6">
|
||||||
{
|
{
|
||||||
copy.points.map(([term, desc]) => (
|
copy.points.map(([term, desc]) => (
|
||||||
<div class="rounded-md border border-line bg-surface-1 p-5">
|
<div class="border-line bg-surface-1 rounded-md border p-5">
|
||||||
<dt class="text-h4 font-semibold text-ink">{term}</dt>
|
<dt class="text-h4 text-ink font-semibold">{term}</dt>
|
||||||
<dd class="mt-2 text-body text-ink-muted">{desc}</dd>
|
<dd class="text-body text-ink-muted mt-2">{desc}</dd>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const fieldClass =
|
|||||||
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||||
<div class="lg:col-span-7">
|
<div class="lg:col-span-7">
|
||||||
<SectionLabel text={d.nav.contact} />
|
<SectionLabel text={d.nav.contact} />
|
||||||
<h1 class="mt-4 text-h1">{profile.contactHeading}</h1>
|
<h1 class="text-h1 mt-4">{profile.contactHeading}</h1>
|
||||||
<p class="mt-3 text-body-lg text-ink-muted">{profile.contactBody}</p>
|
<p class="text-body-lg text-ink-muted mt-3">{profile.contactBody}</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="contact-form"
|
id="contact-form"
|
||||||
@@ -32,21 +32,27 @@ const fieldClass =
|
|||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label for="cf-name" class="mb-1.5 block text-small font-medium text-ink">{d.form.name}</label>
|
<label for="cf-name" class="text-small text-ink mb-1.5 block font-medium"
|
||||||
|
>{d.form.name}</label
|
||||||
|
>
|
||||||
<input id="cf-name" name="name" type="text" autocomplete="name" class={fieldClass} />
|
<input id="cf-name" name="name" type="text" autocomplete="name" class={fieldClass} />
|
||||||
<p data-error-for="name" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
<p data-error-for="name" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="cf-email" class="mb-1.5 block text-small font-medium text-ink">{d.form.email}</label>
|
<label for="cf-email" class="text-small text-ink mb-1.5 block font-medium"
|
||||||
|
>{d.form.email}</label
|
||||||
|
>
|
||||||
<input id="cf-email" name="email" type="email" autocomplete="email" class={fieldClass} />
|
<input id="cf-email" name="email" type="email" autocomplete="email" class={fieldClass} />
|
||||||
<p data-error-for="email" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
<p data-error-for="email" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="cf-message" class="mb-1.5 block text-small font-medium text-ink">{d.form.message}</label>
|
<label for="cf-message" class="text-small text-ink mb-1.5 block font-medium"
|
||||||
|
>{d.form.message}</label
|
||||||
|
>
|
||||||
<textarea id="cf-message" name="message" rows="5" class={fieldClass}></textarea>
|
<textarea id="cf-message" name="message" rows="5" class={fieldClass}></textarea>
|
||||||
<p data-error-for="message" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
<p data-error-for="message" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Honeypot — hidden from users and assistive tech */}
|
{/* Honeypot — hidden from users and assistive tech */}
|
||||||
@@ -58,27 +64,39 @@ const fieldClass =
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-sending={d.form.sending}
|
data-sending={d.form.sending}
|
||||||
class="inline-flex w-fit items-center justify-center rounded-sm bg-accent px-5 py-2.5 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
class="bg-accent text-body text-accent-ink inline-flex w-fit items-center justify-center rounded-sm px-5 py-2.5 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
>
|
>
|
||||||
<span data-label>{d.form.send}</span>
|
<span data-label>{d.form.send}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="form-success" hidden class="mt-8 rounded-md border border-accent/40 bg-surface-2 p-6">
|
<div
|
||||||
<p class="flex items-center gap-2 text-body font-semibold text-accent">
|
id="form-success"
|
||||||
|
hidden
|
||||||
|
class="border-accent/40 bg-surface-2 mt-8 rounded-md border p-6"
|
||||||
|
>
|
||||||
|
<p class="text-body text-accent flex items-center gap-2 font-semibold">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<path class="check-path" d="M5 12l5 5 9-11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path
|
||||||
|
class="check-path"
|
||||||
|
d="M5 12l5 5 9-11"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{d.form.successTitle}
|
{d.form.successTitle}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-body text-ink-muted">{d.form.successBody}</p>
|
<p class="text-body text-ink-muted mt-2">{d.form.successBody}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="form-error" hidden class="mt-8 rounded-md border border-danger/40 bg-surface-2 p-6">
|
<div id="form-error" hidden class="border-danger/40 bg-surface-2 mt-8 rounded-md border p-6">
|
||||||
<p class="text-body font-semibold text-danger">{d.form.errorTitle}</p>
|
<p class="text-body text-danger font-semibold">{d.form.errorTitle}</p>
|
||||||
<p class="mt-2 text-body text-ink-muted">
|
<p class="text-body text-ink-muted mt-2">
|
||||||
{d.form.errorBody}
|
{d.form.errorBody}
|
||||||
<a class="text-accent hover:underline" href={`mailto:${profile.links.email}`}>{profile.links.email}</a>
|
<a class="text-accent hover:underline" href={`mailto:${profile.links.email}`}
|
||||||
|
>{profile.links.email}</a
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,15 +109,31 @@ const fieldClass =
|
|||||||
href={`mailto:${profile.links.email}`}
|
href={`mailto:${profile.links.email}`}
|
||||||
data-copy={profile.links.email}
|
data-copy={profile.links.email}
|
||||||
data-copied-label={d.form.copied}
|
data-copied-label={d.form.copied}
|
||||||
class="inline-flex items-center gap-2 text-body text-ink hover:text-accent"
|
class="text-body text-ink hover:text-accent inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span data-copy-label>{profile.links.email}</span>
|
<span data-copy-label>{profile.links.email}</span>
|
||||||
<span class="text-ink-faint" aria-hidden="true">⧉</span>
|
<span class="text-ink-faint" aria-hidden="true">⧉</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a href={profile.links.linkedin} rel="me noopener" class="text-body text-ink hover:text-accent">LinkedIn ↗</a></li>
|
<li>
|
||||||
<li><a href={profile.links.gitea} rel="me noopener" class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a></li>
|
<a
|
||||||
<li><a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent">{profile.links.phone}</a></li>
|
href={profile.links.linkedin}
|
||||||
|
rel="me noopener"
|
||||||
|
class="text-body text-ink hover:text-accent">LinkedIn ↗</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={profile.links.gitea}
|
||||||
|
rel="me noopener"
|
||||||
|
class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent"
|
||||||
|
>{profile.links.phone}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,26 +22,27 @@ const cards = [
|
|||||||
<Base locale={locale} pageId="cv" title={m.title} description={m.description}>
|
<Base locale={locale} pageId="cv" title={m.title} description={m.description}>
|
||||||
<div class="mx-auto max-w-3xl px-6 py-16">
|
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||||
<SectionLabel text="CV" />
|
<SectionLabel text="CV" />
|
||||||
<h1 class="mt-4 text-h1">{d.cvPage.heading}</h1>
|
<h1 class="text-h1 mt-4">{d.cvPage.heading}</h1>
|
||||||
<p class="mt-4 text-body-lg text-ink-muted">{d.cvPage.intro}</p>
|
<p class="text-body-lg text-ink-muted mt-4">{d.cvPage.intro}</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
{
|
{
|
||||||
cards.map((c) => (
|
cards.map((c) => (
|
||||||
<div class="flex flex-col rounded-md border border-line bg-surface-1 p-6">
|
<div class="border-line bg-surface-1 flex flex-col rounded-md border p-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="rounded-sm bg-surface-2 px-2 py-1 font-mono text-mono-label text-accent">
|
<span class="bg-surface-2 text-mono-label text-accent rounded-sm px-2 py-1 font-mono">
|
||||||
{c.code}
|
{c.code}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-body font-semibold text-ink">{c.label}</span>
|
<span class="text-body text-ink font-semibold">{c.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 font-mono text-mono-meta text-ink-faint">
|
<p class="text-mono-meta text-ink-faint mt-2 font-mono">
|
||||||
{interpolate(d.cv.fileMeta, { size: c.cv.sizeKb })} · {d.cvPage.updated} {c.cv.updated}
|
{interpolate(d.cv.fileMeta, { size: c.cv.sizeKb })} · {d.cvPage.updated}{' '}
|
||||||
|
{c.cv.updated}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={c.cv.path}
|
href={c.cv.path}
|
||||||
download
|
download
|
||||||
class="mt-5 inline-flex items-center justify-center gap-2 rounded-sm bg-accent px-4 py-2.5 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
class="bg-accent text-body text-accent-ink mt-5 inline-flex items-center justify-center gap-2 rounded-sm px-4 py-2.5 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
>
|
>
|
||||||
{d.cv.download}
|
{d.cv.download}
|
||||||
</a>
|
</a>
|
||||||
@@ -50,6 +51,6 @@ const cards = [
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 font-mono text-mono-meta text-ink-faint">{d.cvPage.atsNote}</p>
|
<p class="text-mono-meta text-ink-faint mt-6 font-mono">{d.cvPage.atsNote}</p>
|
||||||
</div>
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const m = getMeta('experience', locale);
|
|||||||
<Base locale={locale} pageId="experience" title={m.title} description={m.description}>
|
<Base locale={locale} pageId="experience" title={m.title} description={m.description}>
|
||||||
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||||
<SectionLabel text={d.home.experienceLabel} />
|
<SectionLabel text={d.home.experienceLabel} />
|
||||||
<h1 class="mt-4 text-h1">{d.home.experienceHeading}</h1>
|
<h1 class="text-h1 mt-4">{d.home.experienceHeading}</h1>
|
||||||
<p class="mt-4 max-w-[62ch] text-body-lg text-ink-muted">{m.description}</p>
|
<p class="text-body-lg text-ink-muted mt-4 max-w-[62ch]">{m.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<ExperienceTimeline locale={locale} eyebrow={false} />
|
<ExperienceTimeline locale={locale} eyebrow={false} />
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const m = getMeta('projects', locale);
|
|||||||
<Base locale={locale} pageId="projects" title={m.title} description={m.description}>
|
<Base locale={locale} pageId="projects" title={m.title} description={m.description}>
|
||||||
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||||
<SectionLabel text={d.home.projectsLabel} />
|
<SectionLabel text={d.home.projectsLabel} />
|
||||||
<h1 class="mt-4 text-h1">{d.home.projectsHeading}</h1>
|
<h1 class="text-h1 mt-4">{d.home.projectsHeading}</h1>
|
||||||
<p class="mt-4 max-w-[62ch] text-body-lg text-ink-muted">{m.description}</p>
|
<p class="text-body-lg text-ink-muted mt-4 max-w-[62ch]">{m.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<ProjectCards locale={locale} header={false} />
|
<ProjectCards locale={locale} header={false} />
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ const dotColor: Record<Status, string> = {
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span class:list={[base, 'border-line bg-surface-1 text-ink-muted', cls]} title={tooltip}>
|
||||||
class:list={[base, 'border-line bg-surface-1 text-ink-muted', cls]}
|
|
||||||
title={tooltip}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ const zoomable = !isPlaceholder && !noZoom && index !== undefined;
|
|||||||
const ratio = `${media.width} / ${media.height}`;
|
const ratio = `${media.width} / ${media.height}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<figure class:list={['overflow-hidden rounded-lg border border-line bg-surface-2', cls]}>
|
<figure class:list={['border-line bg-surface-2 overflow-hidden rounded-lg border', cls]}>
|
||||||
{/* Browser chrome */}
|
{/* Browser chrome */}
|
||||||
<div class="flex items-center gap-2 border-b border-line bg-surface-1 px-4 py-2.5">
|
<div class="border-line bg-surface-1 flex items-center gap-2 border-b px-4 py-2.5">
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||||
{
|
{
|
||||||
url && (
|
url && (
|
||||||
<span class="mx-auto rounded-full bg-surface-2 px-4 py-0.5 font-mono text-[11px] text-ink-faint">
|
<span class="bg-surface-2 text-ink-faint mx-auto rounded-full px-4 py-0.5 font-mono text-[11px]">
|
||||||
{url}
|
{url}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -44,8 +44,11 @@ const ratio = `${media.width} / ${media.height}`;
|
|||||||
|
|
||||||
{
|
{
|
||||||
isPlaceholder ? (
|
isPlaceholder ? (
|
||||||
<div class="dot-grid flex items-center justify-center px-6 text-center" style={`aspect-ratio:${ratio}`}>
|
<div
|
||||||
<span class="mono-meta max-w-sm text-ink-faint">{media.caption}</span>
|
class="dot-grid flex items-center justify-center px-6 text-center"
|
||||||
|
style={`aspect-ratio:${ratio}`}
|
||||||
|
>
|
||||||
|
<span class="mono-meta text-ink-faint max-w-sm">{media.caption}</span>
|
||||||
</div>
|
</div>
|
||||||
) : !zoomable ? (
|
) : !zoomable ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const segments: { code: Locale; href: string; label: string }[] = [
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class:list={['inline-flex rounded-full border border-line bg-surface-1 p-[3px]', cls]}
|
class:list={['border-line bg-surface-1 inline-flex rounded-full border p-[3px]', cls]}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={d.lang.switchTo}
|
aria-label={d.lang.switchTo}
|
||||||
>
|
>
|
||||||
@@ -32,7 +32,7 @@ const segments: { code: Locale; href: string; label: string }[] = [
|
|||||||
segments.map((s) =>
|
segments.map((s) =>
|
||||||
s.code === locale ? (
|
s.code === locale ? (
|
||||||
<span
|
<span
|
||||||
class="rounded-full border border-line-strong bg-surface-2 px-3 py-1 font-mono text-mono-label text-ink"
|
class="border-line-strong bg-surface-2 text-mono-label text-ink rounded-full border px-3 py-1 font-mono"
|
||||||
aria-current="true"
|
aria-current="true"
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
@@ -40,7 +40,7 @@ const segments: { code: Locale; href: string; label: string }[] = [
|
|||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href={s.href}
|
href={s.href}
|
||||||
class="rounded-full px-3 py-1 font-mono text-mono-label text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
class="text-mono-label text-ink-muted hover:text-ink rounded-full px-3 py-1 font-mono transition-colors duration-[--dur-quick]"
|
||||||
aria-label={d.lang.switchTo}
|
aria-label={d.lang.switchTo}
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ const isPlaceholder = src.startsWith('placeholder:');
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div class:list={['mx-auto max-w-sm', cls]}>
|
<div class:list={['mx-auto max-w-sm', cls]}>
|
||||||
<div class="overflow-hidden rounded-lg border border-line-strong bg-surface-2">
|
<div class="border-line-strong bg-surface-2 overflow-hidden rounded-lg border">
|
||||||
{
|
{
|
||||||
isPlaceholder ? (
|
isPlaceholder ? (
|
||||||
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
||||||
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={src} alt={alt} width="800" height="1000" class="aspect-[4/5] w-full object-cover" />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width="800"
|
||||||
|
height="1000"
|
||||||
|
class="aspect-[4/5] w-full object-cover"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 font-mono text-mono-meta text-ink-faint">{caption}</p>
|
<p class="text-mono-meta text-ink-faint mt-2 font-mono">{caption}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ interface Props {
|
|||||||
const { number, text, id } = Astro.props;
|
const { number, text, id } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<p
|
<p id={id} class="text-mono-label text-accent font-mono font-medium tracking-[0.08em] uppercase">
|
||||||
id={id}
|
|
||||||
class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-accent"
|
|
||||||
>
|
|
||||||
{number ? `${number} — ${text}` : text}
|
{number ? `${number} — ${text}` : text}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk;
|
|||||||
<a
|
<a
|
||||||
href={current.path}
|
href={current.path}
|
||||||
download
|
download
|
||||||
class="inline-flex items-center rounded-l-sm bg-accent px-4 py-2 text-small font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
class="bg-accent text-small text-accent-ink inline-flex items-center rounded-l-sm px-4 py-2 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
>
|
>
|
||||||
{d.cv.download}
|
{d.cv.download}
|
||||||
{!compact && <span class="ml-2 font-mono text-mono-label opacity-70">EN/NO</span>}
|
{!compact && <span class="text-mono-label ml-2 font-mono opacity-70">EN/NO</span>}
|
||||||
</a>
|
</a>
|
||||||
<details class="cv-menu">
|
<details class="cv-menu">
|
||||||
<summary
|
<summary
|
||||||
class="flex cursor-pointer list-none items-center rounded-r-sm border-l border-accent-ink/20 bg-accent px-2 text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
class="border-accent-ink/20 bg-accent text-accent-ink flex cursor-pointer list-none items-center rounded-r-sm border-l px-2 transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
aria-label={d.cv.download}
|
aria-label={d.cv.download}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
@@ -40,22 +40,30 @@ const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk;
|
|||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 top-[calc(100%+8px)] z-30 w-64 overflow-hidden rounded-md border border-line-strong bg-surface-2 shadow-[var(--shadow-overlay)]"
|
class="border-line-strong bg-surface-2 absolute top-[calc(100%+8px)] right-0 z-30 w-64 overflow-hidden rounded-md border shadow-[var(--shadow-overlay)]"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={current.path}
|
href={current.path}
|
||||||
download
|
download
|
||||||
class="flex items-center justify-between gap-2 px-4 py-3 text-small text-ink hover:bg-surface-1"
|
class="text-small text-ink hover:bg-surface-1 flex items-center justify-between gap-2 px-4 py-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><span class="text-mono-label text-accent mr-2 font-mono">{d.lang[locale]}</span>{
|
||||||
|
currentLabel
|
||||||
|
}</span
|
||||||
>
|
>
|
||||||
<span><span class="mr-2 font-mono text-mono-label text-accent">{d.lang[locale]}</span>{currentLabel}</span>
|
|
||||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: current.sizeKb })}</span>
|
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: current.sizeKb })}</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={other.path}
|
href={other.path}
|
||||||
download
|
download
|
||||||
class="flex items-center justify-between gap-2 border-t border-line px-4 py-3 text-small text-ink-muted hover:bg-surface-1 hover:text-ink"
|
class="border-line text-small text-ink-muted hover:bg-surface-1 hover:text-ink flex items-center justify-between gap-2 border-t px-4 py-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><span class="text-mono-label mr-2 font-mono">{locale === 'en' ? 'NO' : 'EN'}</span>{
|
||||||
|
otherLabel
|
||||||
|
}</span
|
||||||
>
|
>
|
||||||
<span><span class="mr-2 font-mono text-mono-label">{locale === 'en' ? 'NO' : 'EN'}</span>{otherLabel}</span>
|
|
||||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: other.sizeKb })}</span>
|
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: other.sizeKb })}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const d = useDict(locale);
|
|||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
class="border-line text-ink-muted hover:text-ink inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-[--dur-quick]"
|
||||||
aria-label={d.theme.toLight}
|
aria-label={d.theme.toLight}
|
||||||
data-to-light={d.theme.toLight}
|
data-to-light={d.theme.toLight}
|
||||||
data-to-dark={d.theme.toDark}
|
data-to-dark={d.theme.toDark}
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ const { statusLine, class: cls } = Astro.props;
|
|||||||
d="M2 12 H150 l10 10 H194"
|
d="M2 12 H150 l10 10 H194"
|
||||||
stroke="var(--accent)"
|
stroke="var(--accent)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
style="--trace-len: 212"
|
style="--trace-len: 212"></path>
|
||||||
></path>
|
|
||||||
<circle class="trace-dot status-dot--active" cx="196" cy="22" r="4" fill="var(--accent)"
|
<circle class="trace-dot status-dot--active" cx="196" cy="22" r="4" fill="var(--accent)"
|
||||||
></circle>
|
></circle>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ export const homelab: Project = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
valueProp: 'Der jeg drifter, ødelegger og fikser ekte infrastruktur — ikke bare ruller den ut.',
|
valueProp:
|
||||||
|
'Der jeg drifter, ødelegger og fikser ekte infrastruktur — ikke bare ruller den ut.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'Ubuntu · Docker-tjenester · reverse proxy · auth · overvåking · Gitea (git-remoten til denne siden).',
|
'Ubuntu · Docker-tjenester · reverse proxy · auth · overvåking · Gitea (git-remoten til denne siden).',
|
||||||
tldr: {
|
tldr: {
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export const inboxintel: Project = {
|
|||||||
],
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
valueProp: 'Gmail analytics and safe bulk cleanup, built as a disciplined Clean Architecture.',
|
valueProp:
|
||||||
|
'Gmail analytics and safe bulk cleanup, built as a disciplined Clean Architecture.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'Gmail analytics and safe bulk cleanup — Clean Architecture, encrypted OAuth tokens, preview-before-delete by design.',
|
'Gmail analytics and safe bulk cleanup — Clean Architecture, encrypted OAuth tokens, preview-before-delete by design.',
|
||||||
tldr: {
|
tldr: {
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export const en: Dictionary = {
|
|||||||
heading: 'Download my CV',
|
heading: 'Download my CV',
|
||||||
intro:
|
intro:
|
||||||
'For a full overview of my experience, skills and work history, download my CV in either language.',
|
'For a full overview of my experience, skills and work history, download my CV in either language.',
|
||||||
atsNote:
|
atsNote: 'These are plain, ATS-friendly PDFs — single column, real text, standard headings.',
|
||||||
'These are plain, ATS-friendly PDFs — single column, real text, standard headings.',
|
|
||||||
updated: 'Updated',
|
updated: 'Updated',
|
||||||
},
|
},
|
||||||
lang: {
|
lang: {
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export const HOME_SECTIONS = [
|
|||||||
{ id: 'contact', en: 'contact', no: 'kontakt' },
|
{ id: 'contact', en: 'contact', no: 'kontakt' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function homeAnchor(sectionId: (typeof HOME_SECTIONS)[number]['id'], locale: Locale): string {
|
export function homeAnchor(
|
||||||
|
sectionId: (typeof HOME_SECTIONS)[number]['id'],
|
||||||
|
locale: Locale,
|
||||||
|
): string {
|
||||||
const s = HOME_SECTIONS.find((x) => x.id === sectionId);
|
const s = HOME_SECTIONS.find((x) => x.id === sectionId);
|
||||||
return s ? s[locale] : sectionId;
|
return s ? s[locale] : sectionId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ const { locale, pageId, title, description, ogType, jsonLdOpts, mainClass } = As
|
|||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="scroll-sentinel" aria-hidden="true" class="pointer-events-none absolute top-0 h-24 w-px"></div>
|
<div
|
||||||
|
id="scroll-sentinel"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute top-0 h-24 w-px"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<SkipLink locale={locale} />
|
<SkipLink locale={locale} />
|
||||||
<Header locale={locale} pageId={pageId} />
|
<Header locale={locale} pageId={pageId} />
|
||||||
{locale === 'en' && <HintBar locale={locale} pageId={pageId} />}
|
{locale === 'en' && <HintBar locale={locale} pageId={pageId} />}
|
||||||
|
|||||||
+10
-11
@@ -22,33 +22,32 @@ import { no } from '@i18n/dictionary.no';
|
|||||||
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6">
|
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6">
|
||||||
{/* Trace motif frays at the break (A10) */}
|
{/* Trace motif frays at the break (A10) */}
|
||||||
<svg width="320" height="40" viewBox="0 0 320 40" fill="none" aria-hidden="true">
|
<svg width="320" height="40" viewBox="0 0 320 40" fill="none" aria-hidden="true">
|
||||||
<path d="M2 20 H140" stroke="var(--accent)" stroke-width="1.5" />
|
<path d="M2 20 H140" stroke="var(--accent)" stroke-width="1.5"></path>
|
||||||
<path
|
<path
|
||||||
d="M150 20 H318"
|
d="M150 20 H318"
|
||||||
stroke="var(--accent)"
|
stroke="var(--accent)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-dasharray="2 6"
|
stroke-dasharray="2 6"
|
||||||
opacity="0.6"
|
opacity="0.6"></path>
|
||||||
></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="mt-6 font-mono text-mono-label uppercase tracking-[0.08em] text-accent">
|
<p class="text-mono-label text-accent mt-6 font-mono tracking-[0.08em] uppercase">
|
||||||
{en.notFound.code} · {no.notFound.code}
|
{en.notFound.code} · {no.notFound.code}
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-3 text-h1">{en.notFound.title}</h1>
|
<h1 class="text-h1 mt-3">{en.notFound.title}</h1>
|
||||||
<p class="mt-2 text-body-lg text-ink-muted">{en.notFound.body}</p>
|
<p class="text-body-lg text-ink-muted mt-2">{en.notFound.body}</p>
|
||||||
|
|
||||||
<h2 class="mt-8 text-h3 text-ink-muted">{no.notFound.title}</h2>
|
<h2 class="text-h3 text-ink-muted mt-8">{no.notFound.title}</h2>
|
||||||
<p class="mt-1 text-body text-ink-muted">{no.notFound.body}</p>
|
<p class="text-body text-ink-muted mt-1">{no.notFound.body}</p>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<a href="/" class="rounded-sm bg-accent px-4 py-2.5 font-semibold text-accent-ink">
|
<a href="/" class="bg-accent text-accent-ink rounded-sm px-4 py-2.5 font-semibold">
|
||||||
{en.notFound.home}
|
{en.notFound.home}
|
||||||
</a>
|
</a>
|
||||||
<a href="/no/" class="rounded-sm border border-line-strong px-4 py-2.5 text-ink">
|
<a href="/no/" class="border-line-strong text-ink rounded-sm border px-4 py-2.5">
|
||||||
{no.notFound.home}
|
{no.notFound.home}
|
||||||
</a>
|
</a>
|
||||||
<a href="/projects/" class="rounded-sm border border-line-strong px-4 py-2.5 text-ink">
|
<a href="/projects/" class="border-line-strong text-ink rounded-sm border px-4 py-2.5">
|
||||||
{en.notFound.projects}
|
{en.notFound.projects}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { Resvg } from '@resvg/resvg-js';
|
import { Resvg } from '@resvg/resvg-js';
|
||||||
import { PAGE_IDS, type PageId } from '@i18n/slugMap';
|
import { PAGE_IDS, type PageId } from '@i18n/slugMap';
|
||||||
@@ -15,8 +14,9 @@ import { getProfile, getMeta } from '@lib/content';
|
|||||||
Fonts read from the project source (cwd = site/ at build).
|
Fonts read from the project source (cwd = site/ at build).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fontBuffers = ['SpaceGrotesk.ttf', 'Inter.ttf', 'JetBrainsMono.ttf'].map((n) =>
|
const fontDir = resolve(process.cwd(), 'src/assets/og-fonts');
|
||||||
readFileSync(resolve(process.cwd(), 'src/assets/og-fonts', n)),
|
const fontFiles = ['SpaceGrotesk.ttf', 'Inter.ttf', 'JetBrainsMono.ttf'].map((n) =>
|
||||||
|
resolve(fontDir, n),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
@@ -29,8 +29,7 @@ export function getStaticPaths() {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
const esc = (s: string) =>
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
export const GET: APIRoute = ({ props }) => {
|
export const GET: APIRoute = ({ props }) => {
|
||||||
const { pageId, locale } = props as { pageId: PageId; locale: Locale };
|
const { pageId, locale } = props as { pageId: PageId; locale: Locale };
|
||||||
@@ -62,12 +61,12 @@ export const GET: APIRoute = ({ props }) => {
|
|||||||
|
|
||||||
const png = new Resvg(svg, {
|
const png = new Resvg(svg, {
|
||||||
fitTo: { mode: 'width', value: 1200 },
|
fitTo: { mode: 'width', value: 1200 },
|
||||||
font: { fontBuffers, loadSystemFonts: false, defaultFontFamily: 'Inter' },
|
font: { fontFiles, loadSystemFonts: false, defaultFontFamily: 'Inter' },
|
||||||
})
|
})
|
||||||
.render()
|
.render()
|
||||||
.asPng();
|
.asPng();
|
||||||
|
|
||||||
return new Response(png, {
|
return new Response(new Uint8Array(png), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import type { APIRoute } from 'astro';
|
|||||||
/* robots.txt (SEO_SPEC §2): allow all, point to the sitemap. */
|
/* robots.txt (SEO_SPEC §2): allow all, point to the sitemap. */
|
||||||
export const GET: APIRoute = ({ site }) => {
|
export const GET: APIRoute = ({ site }) => {
|
||||||
const origin = site?.href ?? 'https://cesnimda.co.uk/';
|
const origin = site?.href ?? 'https://cesnimda.co.uk/';
|
||||||
const body = ['User-agent: *', 'Allow: /', '', `Sitemap: ${new URL('sitemap.xml', origin).href}`, ''].join(
|
const body = [
|
||||||
'\n',
|
'User-agent: *',
|
||||||
);
|
'Allow: /',
|
||||||
|
'',
|
||||||
|
`Sitemap: ${new URL('sitemap.xml', origin).href}`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ if (form) {
|
|||||||
company: String(fd.get('company') ?? ''), // honeypot
|
company: String(fd.get('company') ?? ''), // honeypot
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validate first, so a real person who submits an incomplete form always sees
|
||||||
|
// the field errors (never a false "success").
|
||||||
|
if (!validate(data)) return;
|
||||||
|
|
||||||
// Honeypot filled or submitted implausibly fast → treat as bot, feign success.
|
// Honeypot filled or submitted implausibly fast → treat as bot, feign success.
|
||||||
if (data.company || Date.now() - loadedAt < 2000) {
|
if (data.company || Date.now() - loadedAt < 2000) {
|
||||||
form.hidden = true;
|
form.hidden = true;
|
||||||
@@ -73,8 +77,6 @@ if (form) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validate(data)) return;
|
|
||||||
|
|
||||||
submitBtn?.setAttribute('disabled', 'true');
|
submitBtn?.setAttribute('disabled', 'true');
|
||||||
if (submitLabel) submitLabel.textContent = sendingText;
|
if (submitLabel) submitLabel.textContent = sendingText;
|
||||||
submitBtn?.classList.add('is-sending');
|
submitBtn?.classList.add('is-sending');
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const closeBtn = document.getElementById('mobile-nav-close');
|
|||||||
|
|
||||||
function focusable(el: HTMLElement): HTMLElement[] {
|
function focusable(el: HTMLElement): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
el.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'),
|
el.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
).filter((n) => n.offsetParent !== null);
|
).filter((n) => n.offsetParent !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ const btn = document.getElementById('theme-toggle');
|
|||||||
|
|
||||||
function labelFor(theme: string): string {
|
function labelFor(theme: string): string {
|
||||||
if (!btn) return '';
|
if (!btn) return '';
|
||||||
return theme === 'dark'
|
return theme === 'dark' ? (btn.dataset.toLight ?? '') : (btn.dataset.toDark ?? '');
|
||||||
? (btn.dataset.toLight ?? '')
|
|
||||||
: (btn.dataset.toDark ?? '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
Reduced-motion is a single global override — not per-component JS (ANIMATION_SPEC §1).
|
Reduced-motion is a single global override — not per-component JS (ANIMATION_SPEC §1).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ---- Reveal (A2): opacity + 12px rise, staggered by --reveal-index ---- */
|
/* ---- Reveal (A2): opacity + 12px rise, staggered by --reveal-index ----
|
||||||
[data-reveal] {
|
Gated behind html.js so content stays visible with no JavaScript
|
||||||
|
(progressive enhancement — content is never hidden by animation). */
|
||||||
|
html.js [data-reveal] {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px);
|
transform: translateY(12px);
|
||||||
transition:
|
transition:
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
transform var(--dur-entrance) var(--ease-out);
|
transform var(--dur-entrance) var(--ease-out);
|
||||||
transition-delay: calc(var(--reveal-index, 0) * 60ms);
|
transition-delay: calc(var(--reveal-index, 0) * 60ms);
|
||||||
}
|
}
|
||||||
[data-reveal].is-revealed {
|
html.js [data-reveal].is-revealed {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Timeline spine grow (A8) ---- */
|
/* ---- Timeline spine grow (A8) ---- */
|
||||||
.timeline-spine {
|
html.js .timeline-spine {
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
transition: transform var(--dur-entrance) var(--ease-out);
|
transition: transform var(--dur-entrance) var(--ease-out);
|
||||||
@@ -118,14 +120,14 @@
|
|||||||
transition-duration: 1ms !important;
|
transition-duration: 1ms !important;
|
||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
[data-reveal] {
|
html.js [data-reveal] {
|
||||||
opacity: 1;
|
opacity: 1 !important;
|
||||||
transform: none;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
.trace-line {
|
.trace-line {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
.timeline-spine {
|
.timeline-spine {
|
||||||
transform: scaleY(1);
|
transform: scaleY(1) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
--surface-2: #171c26;
|
--surface-2: #171c26;
|
||||||
--ink: #e8ecf2;
|
--ink: #e8ecf2;
|
||||||
--ink-muted: #9aa4b2;
|
--ink-muted: #9aa4b2;
|
||||||
--ink-faint: #5c6675;
|
--ink-faint: #828b98; /* raised to WCAG AA (≥4.5:1) — used for small labels/captions */
|
||||||
--border: rgba(232, 236, 242, 0.08);
|
--border: rgba(232, 236, 242, 0.08);
|
||||||
--border-strong: rgba(232, 236, 242, 0.16);
|
--border-strong: rgba(232, 236, 242, 0.16);
|
||||||
|
|
||||||
@@ -96,11 +96,11 @@
|
|||||||
--surface-2: #f1f2ef;
|
--surface-2: #f1f2ef;
|
||||||
--ink: #171a20;
|
--ink: #171a20;
|
||||||
--ink-muted: #555e6b;
|
--ink-muted: #555e6b;
|
||||||
--ink-faint: #8a93a0;
|
--ink-faint: #626b77; /* raised to WCAG AA (≥4.5:1) */
|
||||||
--border: rgba(23, 26, 32, 0.1);
|
--border: rgba(23, 26, 32, 0.1);
|
||||||
--border-strong: rgba(23, 26, 32, 0.2);
|
--border-strong: rgba(23, 26, 32, 0.2);
|
||||||
|
|
||||||
--accent: #0e8c72;
|
--accent: #0b7a63; /* white text ≥5:1; also passes as link/label on light surfaces */
|
||||||
--accent-ink: #ffffff;
|
--accent-ink: #ffffff;
|
||||||
--amber: #8a6114;
|
--amber: #8a6114;
|
||||||
--amber-chip-bg: #f5e5c2;
|
--amber-chip-bg: #f5e5c2;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getProfile,
|
||||||
|
getSkills,
|
||||||
|
getExperience,
|
||||||
|
getProjectCards,
|
||||||
|
getProject,
|
||||||
|
getProjectIds,
|
||||||
|
getMeta,
|
||||||
|
} from '@lib/content';
|
||||||
|
import { PAGE_IDS } from '@i18n/slugMap';
|
||||||
|
import { LOCALES } from '@i18n/locales';
|
||||||
|
|
||||||
|
// Importing @lib/content runs every zod schema parse at module load; a schema
|
||||||
|
// violation would throw here and fail the suite (DATA_MODEL §8).
|
||||||
|
|
||||||
|
describe('content loads and validates', () => {
|
||||||
|
it('profile resolves in both locales', () => {
|
||||||
|
for (const l of LOCALES) {
|
||||||
|
const p = getProfile(l);
|
||||||
|
expect(p.name).toBe('Connor Babbington');
|
||||||
|
expect(p.chips.length).toBe(4);
|
||||||
|
expect(p.aboutParagraphs.length).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exactly three projects, correctly ordered and typed', () => {
|
||||||
|
const cards = getProjectCards('en');
|
||||||
|
expect(cards.map((c) => c.id)).toEqual(['jobtrack', 'inboxintel', 'homelab']);
|
||||||
|
expect(cards.find((c) => c.id === 'inboxintel')?.status).toBe('in-development');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every case-study project has decisions and a security section (promise to P2)', () => {
|
||||||
|
for (const id of getProjectIds()) {
|
||||||
|
const p = getProject(id, 'en');
|
||||||
|
if (p.template !== 'case-study') continue;
|
||||||
|
const kinds = p.sections.map((s) => s.kind);
|
||||||
|
expect(kinds, id).toContain('security');
|
||||||
|
const decisions = p.sections.find((s) => s.kind === 'decisions');
|
||||||
|
expect(decisions?.decisions?.length ?? 0, id).toBeGreaterThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skills expose three groups', () => {
|
||||||
|
expect(getSkills('no').map((g) => g.id)).toEqual([
|
||||||
|
'development',
|
||||||
|
'devops-infrastructure',
|
||||||
|
'practices',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('experience features the council role with progression', () => {
|
||||||
|
const w = getExperience('en').find((i) => i.id === 'warwickshire');
|
||||||
|
expect(w?.emphasis).toBe('featured');
|
||||||
|
expect(w?.progression?.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page meta exists for every page in both locales, within length limits', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
for (const l of LOCALES) {
|
||||||
|
const m = getMeta(id, l);
|
||||||
|
expect(m.title.length, `${id}.${l} title`).toBeLessThanOrEqual(70);
|
||||||
|
expect(m.description.length, `${id}.${l} desc`).toBeLessThanOrEqual(160);
|
||||||
|
expect(m.description.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { en } from '@i18n/dictionary.en';
|
||||||
|
import { no } from '@i18n/dictionary.no';
|
||||||
|
import { interpolate } from '@i18n/t';
|
||||||
|
|
||||||
|
/** Collect all leaf key paths of a nested object. */
|
||||||
|
function keyPaths(obj: unknown, prefix = ''): string[] {
|
||||||
|
if (obj === null || typeof obj !== 'object') return [prefix];
|
||||||
|
return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
|
||||||
|
keyPaths(v, prefix ? `${prefix}.${k}` : k),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dictionary parity (I18N_SPEC §4.1)', () => {
|
||||||
|
it('EN and NO expose exactly the same key paths', () => {
|
||||||
|
const enKeys = keyPaths(en).sort();
|
||||||
|
const noKeys = keyPaths(no).sort();
|
||||||
|
expect(noKeys).toEqual(enKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no dictionary value is empty', () => {
|
||||||
|
for (const [dict, name] of [
|
||||||
|
[en, 'en'],
|
||||||
|
[no, 'no'],
|
||||||
|
] as const) {
|
||||||
|
for (const path of keyPaths(dict)) {
|
||||||
|
const value = path
|
||||||
|
.split('.')
|
||||||
|
.reduce<unknown>((acc, k) => (acc as Record<string, unknown>)[k], dict);
|
||||||
|
expect(typeof value, `${name}.${path}`).toBe('string');
|
||||||
|
expect((value as string).length, `${name}.${path}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interpolate', () => {
|
||||||
|
it('replaces named tokens', () => {
|
||||||
|
expect(interpolate('© {year} X', { year: 2026 })).toBe('© 2026 X');
|
||||||
|
expect(interpolate('pdf · {size} kB', { size: 132 })).toBe('pdf · 132 kB');
|
||||||
|
});
|
||||||
|
it('leaves unknown tokens untouched', () => {
|
||||||
|
expect(interpolate('{a} {b}', { a: '1' })).toBe('1 {b}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { alternates, buildJsonLd, absUrl, ogImagePath } from '@lib/seo';
|
||||||
|
import { PAGE_IDS } from '@i18n/slugMap';
|
||||||
|
|
||||||
|
const ORIGIN = 'https://cesnimda.co.uk/';
|
||||||
|
|
||||||
|
describe('SEO builders (SEO_SPEC §2–4)', () => {
|
||||||
|
it('alternates include en, nb and x-default for every page', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
const alts = alternates(id, ORIGIN);
|
||||||
|
const langs = alts.map((a) => a.hreflang);
|
||||||
|
expect(langs).toContain('en');
|
||||||
|
expect(langs).toContain('nb');
|
||||||
|
expect(langs).toContain('x-default');
|
||||||
|
for (const a of alts) expect(a.href.startsWith('https://')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x-default points at the English URL', () => {
|
||||||
|
const alts = alternates('projects', ORIGIN);
|
||||||
|
const xd = alts.find((a) => a.hreflang === 'x-default');
|
||||||
|
const en = alts.find((a) => a.hreflang === 'en');
|
||||||
|
expect(xd?.href).toBe(en?.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('home JSON-LD is a valid Person graph', () => {
|
||||||
|
const graph = buildJsonLd('home', 'en', ORIGIN);
|
||||||
|
const person = graph.find((g) => (g as { '@type': string })['@type'] === 'Person') as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(person).toBeTruthy();
|
||||||
|
expect(person.name).toBe('Connor Babbington');
|
||||||
|
expect(person.jobTitle).toBe('Systems Developer');
|
||||||
|
expect(() => JSON.stringify(graph)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Norwegian home uses the localised job title', () => {
|
||||||
|
const graph = buildJsonLd('home', 'no', ORIGIN);
|
||||||
|
const person = graph.find((g) => (g as { '@type': string })['@type'] === 'Person') as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(person.jobTitle).toBe('Systemutvikler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('case-study JSON-LD carries the project name and author', () => {
|
||||||
|
const graph = buildJsonLd('jobtrack', 'en', ORIGIN, {
|
||||||
|
projectName: 'JobTrack',
|
||||||
|
projectType: 'SoftwareApplication',
|
||||||
|
});
|
||||||
|
const work = graph[0] as Record<string, unknown>;
|
||||||
|
expect(work['@type']).toBe('SoftwareApplication');
|
||||||
|
expect(work.name).toBe('JobTrack');
|
||||||
|
expect((work.author as Record<string, unknown>)['@type']).toBe('Person');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('helpers build absolute URLs and OG paths', () => {
|
||||||
|
expect(absUrl('/projects/', ORIGIN)).toBe('https://cesnimda.co.uk/projects/');
|
||||||
|
expect(ogImagePath('home', 'no')).toBe('/og/home-no.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
PAGE_IDS,
|
||||||
|
ROUTES,
|
||||||
|
pathFor,
|
||||||
|
pageIdFromPath,
|
||||||
|
localeFromPath,
|
||||||
|
twinPath,
|
||||||
|
allRoutes,
|
||||||
|
} from '@i18n/slugMap';
|
||||||
|
import { LOCALES } from '@i18n/locales';
|
||||||
|
|
||||||
|
describe('slug map contract (ROUTING_SPEC §1, ARCHITECTURE A5)', () => {
|
||||||
|
it('every pageId has a path in both locales', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
const p = ROUTES[id][locale];
|
||||||
|
expect(p, `${id}.${locale}`).toMatch(/^\/.*\/$|^\/$/); // trailing slash canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all paths are unique (bijection — no two routes collide)', () => {
|
||||||
|
const paths = allRoutes().map((r) => r.path);
|
||||||
|
expect(new Set(paths).size).toBe(paths.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NO paths are prefixed with /no and use localised slugs', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
expect(ROUTES[id].no.startsWith('/no/') || ROUTES[id].no === '/no/').toBe(true);
|
||||||
|
}
|
||||||
|
expect(ROUTES.projects.no).toBe('/no/prosjekter/');
|
||||||
|
expect(ROUTES.homelab.no).toBe('/no/prosjekter/hjemmelab/');
|
||||||
|
expect(ROUTES.about.no).toBe('/no/om-meg/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageIdFromPath round-trips for every route', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
expect(pageIdFromPath(pathFor(id, locale))).toBe(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localeFromPath detects the locale', () => {
|
||||||
|
expect(localeFromPath('/projects/jobtrack/')).toBe('en');
|
||||||
|
expect(localeFromPath('/no/prosjekter/jobtrack/')).toBe('no');
|
||||||
|
expect(localeFromPath('/')).toBe('en');
|
||||||
|
expect(localeFromPath('/no/')).toBe('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('twinPath maps a page to its twin locale and back', () => {
|
||||||
|
for (const id of PAGE_IDS) {
|
||||||
|
const en = pathFor(id, 'en');
|
||||||
|
const no = pathFor(id, 'no');
|
||||||
|
expect(twinPath(en, 'no')).toBe(no);
|
||||||
|
expect(twinPath(no, 'en')).toBe(en);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('twinPath falls back to the target home for unmapped paths', () => {
|
||||||
|
expect(twinPath('/nonsense/', 'no')).toBe('/no/');
|
||||||
|
expect(twinPath('/no/tull/', 'en')).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const r = (p: string) => fileURLToPath(new URL(p, import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': r('./src'),
|
||||||
|
'@i18n': r('./src/i18n'),
|
||||||
|
'@lib': r('./src/lib'),
|
||||||
|
'@components': r('./src/components'),
|
||||||
|
'@layouts': r('./src/layouts'),
|
||||||
|
'@styles': r('./src/styles'),
|
||||||
|
'@scripts': r('./src/scripts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Accessibility gate (TECH_SPEC §9.4): zero critical/serious violations on the main
|
||||||
|
templates, both locales and both themes. wcag2a/2aa + best-practice rules.
|
||||||
|
*/
|
||||||
|
const pages = [
|
||||||
|
{ name: 'home EN', url: '/' },
|
||||||
|
{ name: 'home NO', url: '/no/' },
|
||||||
|
{ name: 'case study', url: '/projects/inboxintel/' },
|
||||||
|
{ name: 'contact', url: '/contact/' },
|
||||||
|
{ name: 'experience', url: '/experience/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of pages) {
|
||||||
|
for (const theme of ['dark', 'light'] as const) {
|
||||||
|
test(`${p.name} (${theme}) has no serious a11y violations`, async ({ page }) => {
|
||||||
|
// Scan the reduced-motion rendering: it's the accessible path and shows all
|
||||||
|
// reveal content immediately (entrance animations are otherwise mid-flight).
|
||||||
|
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
|
||||||
|
await page.addInitScript((t) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('theme', t);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, theme);
|
||||||
|
await page.goto(p.url);
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||||
|
.analyze();
|
||||||
|
const serious = results.violations.filter(
|
||||||
|
(v) => v.impact === 'serious' || v.impact === 'critical',
|
||||||
|
);
|
||||||
|
expect(serious, JSON.stringify(serious.map((v) => ({ id: v.id, nodes: v.nodes.length })))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('CV downloads (J1)', () => {
|
||||||
|
test('both CV PDFs resolve with the right filenames', async ({ request }) => {
|
||||||
|
const en = await request.get('/cv/connor-babbington-cv-en.pdf');
|
||||||
|
const no = await request.get('/cv/connor-babbington-cv-no.pdf');
|
||||||
|
expect(en.status()).toBe(200);
|
||||||
|
expect(no.status()).toBe(200);
|
||||||
|
expect(en.headers()['content-type']).toContain('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header CV button defaults to the current locale', async ({ page }) => {
|
||||||
|
await page.goto('/no/');
|
||||||
|
const primary = page.locator('.cv-split a[download]').first();
|
||||||
|
await expect(primary).toHaveAttribute('href', /cv-no\.pdf$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('contact form (A9)', () => {
|
||||||
|
test('client validation blocks an empty submit', async ({ page }) => {
|
||||||
|
await page.goto('/contact/');
|
||||||
|
await page.locator('#contact-form button[type="submit"]').click();
|
||||||
|
await expect(page.locator('[data-error-for="name"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-error-for="email"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('happy path shows the success panel', async ({ page }) => {
|
||||||
|
await page.route('**/api/contact', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' }),
|
||||||
|
);
|
||||||
|
await page.goto('/contact/');
|
||||||
|
await page.fill('#cf-name', 'Martin');
|
||||||
|
await page.fill('#cf-email', 'martin@example.com');
|
||||||
|
await page.fill('#cf-message', 'We would like to talk to you about a role.');
|
||||||
|
await page.waitForTimeout(2100); // pass the time-trap
|
||||||
|
await page.locator('#contact-form button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#form-success')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honeypot submission never calls the relay', async ({ page }) => {
|
||||||
|
let called = false;
|
||||||
|
await page.route('**/api/contact', (route) => {
|
||||||
|
called = true;
|
||||||
|
return route.fulfill({ status: 200, body: '{"ok":true}' });
|
||||||
|
});
|
||||||
|
await page.goto('/contact/');
|
||||||
|
await page.fill('#cf-name', 'Bot');
|
||||||
|
await page.fill('#cf-email', 'bot@spam.com');
|
||||||
|
await page.fill('#cf-message', 'spam');
|
||||||
|
await page.fill('#cf-company', 'ACME'); // honeypot
|
||||||
|
await page.locator('#contact-form button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#form-success')).toBeVisible();
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('language switch maps page↔page (J5, ROUTING_SPEC §3)', () => {
|
||||||
|
test('deep NO case-study URL → EN twin', async ({ page }) => {
|
||||||
|
await page.goto('/no/prosjekter/jobtrack/');
|
||||||
|
await page.locator('header [role="group"] a', { hasText: 'EN' }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/projects\/jobtrack\/$/);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('lang', 'en-GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EN about → NO twin with localised slug', async ({ page }) => {
|
||||||
|
await page.goto('/about/');
|
||||||
|
await page.locator('header [role="group"] a', { hasText: 'NO' }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/no\/om-meg\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switch works without JavaScript (links, not scripts)', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/no/erfaring/');
|
||||||
|
await page.locator('header [role="group"] a', { hasText: 'EN' }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/experience\/$/);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('smoke — both locales render (TECH_SPEC §9.3)', () => {
|
||||||
|
test('English home', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Systems Developer/);
|
||||||
|
await expect(page.locator('h1')).toHaveText('Connor Babbington');
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('lang', 'en-GB');
|
||||||
|
// decision facts present in the first view (chip)
|
||||||
|
await expect(page.getByText('Tønsberg, Norway', { exact: true }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Norwegian home', async ({ page }) => {
|
||||||
|
await page.goto('/no/');
|
||||||
|
await expect(page).toHaveTitle(/Systemutvikler/);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||||
|
await expect(page.getByText('Gyldig oppholdstillatelse')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hreflang alternates emitted', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('link[hreflang="en"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('link[hreflang="nb"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('link[hreflang="x-default"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case study shows architecture and decisions', async ({ page }) => {
|
||||||
|
await page.goto('/projects/jobtrack/');
|
||||||
|
await expect(page.locator('h1')).toHaveText('JobTrack');
|
||||||
|
await expect(page.locator('#architecture svg[role="img"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#decisions')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip link is the first focusable element', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const focused = page.locator(':focus');
|
||||||
|
await expect(focused).toHaveText(/Skip to content/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "resumesite-e2e",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"install-browsers": "playwright install --with-deps chromium"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.1",
|
||||||
|
"@playwright/test": "^1.50.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/*
|
||||||
|
E2E runs against the built static output served by `astro preview` (TECH_SPEC §9.3).
|
||||||
|
The relay is not required — the form spec mocks /api/contact.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4321',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm -C ../site build && pnpm -C ../site preview --port 4321 --host',
|
||||||
|
url: 'http://localhost:4321/',
|
||||||
|
timeout: 180_000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+71
@@ -0,0 +1,71 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
'@axe-core/playwright':
|
||||||
|
specifier: ^4.10.1
|
||||||
|
version: 4.12.1(playwright-core@1.61.1)
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.50.1
|
||||||
|
version: 1.61.1
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@axe-core/playwright@4.12.1':
|
||||||
|
resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==}
|
||||||
|
peerDependencies:
|
||||||
|
playwright-core: '>= 1.0.0'
|
||||||
|
|
||||||
|
'@playwright/test@1.61.1':
|
||||||
|
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
axe-core@4.12.1:
|
||||||
|
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
playwright-core@1.61.1:
|
||||||
|
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.61.1:
|
||||||
|
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@axe-core/playwright@4.12.1(playwright-core@1.61.1)':
|
||||||
|
dependencies:
|
||||||
|
axe-core: 4.12.1
|
||||||
|
playwright-core: 1.61.1
|
||||||
|
|
||||||
|
'@playwright/test@1.61.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.61.1
|
||||||
|
|
||||||
|
axe-core@4.12.1: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
playwright-core@1.61.1: {}
|
||||||
|
|
||||||
|
playwright@1.61.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.61.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
Reference in New Issue
Block a user