From 1fce1b3cecceff301c44c1b8a2a123468797c1df Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 4 Jul 2026 01:20:46 +0200 Subject: [PATCH] feat: core layout, UI atoms, homepage + behaviour modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI atoms: Chip, Button, SplitCvButton, LangSwitch, ThemeToggle, Card, SectionLabel, TraceMotif, FramedImage - core: Base/Seo/Header/MobileNav/Footer/HintBar/SkipLink/ThemeScript - homepage sections: Hero, ProofStrip, SkillsGrid, ProjectCards, ExperienceTimeline, AboutTeaser, ContactBand + Homepage composition (EN/NO) - behaviour modules: theme, nav (overlay/hint/copy/header), observer (reveal/spy), lightbox, form — progressive enhancement, fail-silent - SEO head component: meta, hreflang from slug map, JSON-LD, OG references - move content data to src/data (avoid Astro reserved content-collection folder) Co-Authored-By: Claude Fable 5 --- site/src/components/core/Footer.astro | 83 ++++++++++++ site/src/components/core/Header.astro | 119 +++++++++++++++++ site/src/components/core/HintBar.astro | 39 ++++++ site/src/components/core/MobileNav.astro | 96 ++++++++++++++ site/src/components/core/Seo.astro | 57 ++++++++ site/src/components/core/SkipLink.astro | 12 ++ site/src/components/core/ThemeScript.astro | 21 +++ site/src/components/home/AboutTeaser.astro | 44 +++++++ site/src/components/home/ContactBand.astro | 37 ++++++ .../components/home/ExperienceTimeline.astro | 77 +++++++++++ site/src/components/home/Hero.astro | 72 ++++++++++ site/src/components/home/Homepage.astro | 39 ++++++ site/src/components/home/ProjectCards.astro | 87 +++++++++++++ site/src/components/home/ProofStrip.astro | 23 ++++ site/src/components/home/SkillsGrid.astro | 42 ++++++ site/src/components/ui/Button.astro | 55 ++++++++ site/src/components/ui/Card.astro | 30 +++++ site/src/components/ui/Chip.astro | 54 ++++++++ site/src/components/ui/FramedImage.astro | 84 ++++++++++++ site/src/components/ui/LangSwitch.astro | 51 ++++++++ site/src/components/ui/SectionLabel.astro | 16 +++ site/src/components/ui/SplitCvButton.astro | 63 +++++++++ site/src/components/ui/ThemeToggle.astro | 36 +++++ site/src/components/ui/TraceMotif.astro | 26 ++++ site/src/{content => data}/experience.ts | 0 site/src/{content => data}/meta.ts | 0 site/src/{content => data}/profile.ts | 0 .../src/{content => data}/projects/homelab.ts | 0 .../{content => data}/projects/inboxintel.ts | 0 .../{content => data}/projects/jobtrack.ts | 0 site/src/{content => data}/skills.ts | 0 site/src/i18n/dictionary.en.ts | 15 +++ site/src/i18n/dictionary.no.ts | 15 +++ site/src/i18n/dictionary.ts | 15 +++ site/src/layouts/Base.astro | 67 ++++++++++ site/src/lib/content.ts | 14 +- site/src/lib/seo.ts | 92 +++++++++++++ site/src/pages/index.astro | 22 +--- site/src/pages/no/index.astro | 5 + site/src/scripts/form.ts | 99 ++++++++++++++ site/src/scripts/lightbox.ts | 90 +++++++++++++ site/src/scripts/nav.ts | 123 ++++++++++++++++++ site/src/scripts/observer.ts | 48 +++++++ site/src/scripts/theme.ts | 28 ++++ site/src/styles/global.css | 22 ++++ 45 files changed, 1891 insertions(+), 27 deletions(-) create mode 100644 site/src/components/core/Footer.astro create mode 100644 site/src/components/core/Header.astro create mode 100644 site/src/components/core/HintBar.astro create mode 100644 site/src/components/core/MobileNav.astro create mode 100644 site/src/components/core/Seo.astro create mode 100644 site/src/components/core/SkipLink.astro create mode 100644 site/src/components/core/ThemeScript.astro create mode 100644 site/src/components/home/AboutTeaser.astro create mode 100644 site/src/components/home/ContactBand.astro create mode 100644 site/src/components/home/ExperienceTimeline.astro create mode 100644 site/src/components/home/Hero.astro create mode 100644 site/src/components/home/Homepage.astro create mode 100644 site/src/components/home/ProjectCards.astro create mode 100644 site/src/components/home/ProofStrip.astro create mode 100644 site/src/components/home/SkillsGrid.astro create mode 100644 site/src/components/ui/Button.astro create mode 100644 site/src/components/ui/Card.astro create mode 100644 site/src/components/ui/Chip.astro create mode 100644 site/src/components/ui/FramedImage.astro create mode 100644 site/src/components/ui/LangSwitch.astro create mode 100644 site/src/components/ui/SectionLabel.astro create mode 100644 site/src/components/ui/SplitCvButton.astro create mode 100644 site/src/components/ui/ThemeToggle.astro create mode 100644 site/src/components/ui/TraceMotif.astro rename site/src/{content => data}/experience.ts (100%) rename site/src/{content => data}/meta.ts (100%) rename site/src/{content => data}/profile.ts (100%) rename site/src/{content => data}/projects/homelab.ts (100%) rename site/src/{content => data}/projects/inboxintel.ts (100%) rename site/src/{content => data}/projects/jobtrack.ts (100%) rename site/src/{content => data}/skills.ts (100%) create mode 100644 site/src/layouts/Base.astro create mode 100644 site/src/lib/seo.ts create mode 100644 site/src/pages/no/index.astro create mode 100644 site/src/scripts/form.ts create mode 100644 site/src/scripts/lightbox.ts create mode 100644 site/src/scripts/nav.ts create mode 100644 site/src/scripts/observer.ts create mode 100644 site/src/scripts/theme.ts diff --git a/site/src/components/core/Footer.astro b/site/src/components/core/Footer.astro new file mode 100644 index 0000000..f8d9269 --- /dev/null +++ b/site/src/components/core/Footer.astro @@ -0,0 +1,83 @@ +--- +import { useDict, interpolate } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId } from '@i18n/slugMap'; +import { getProfile } from '@lib/content'; +import LangSwitch from '@components/ui/LangSwitch.astro'; + +interface Props { + locale: Locale; + pageId: PageId; +} +const { locale, pageId } = Astro.props; +const d = useDict(locale); +const p = getProfile(locale); +const year = new Date().getFullYear(); + +const nav: { id: PageId; label: string }[] = [ + { id: 'projects', label: d.nav.projects }, + { id: 'experience', label: d.nav.experience }, + { id: 'about', label: d.nav.about }, + { id: 'contact', label: d.nav.contact }, +]; +--- + + diff --git a/site/src/components/core/Header.astro b/site/src/components/core/Header.astro new file mode 100644 index 0000000..e224a8f --- /dev/null +++ b/site/src/components/core/Header.astro @@ -0,0 +1,119 @@ +--- +import { useDict } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId, PROJECT_PAGE_IDS } from '@i18n/slugMap'; +import LangSwitch from '@components/ui/LangSwitch.astro'; +import ThemeToggle from '@components/ui/ThemeToggle.astro'; +import SplitCvButton from '@components/ui/SplitCvButton.astro'; +import MobileNav from '@components/core/MobileNav.astro'; + +interface Props { + locale: Locale; + pageId: PageId; +} +const { locale, pageId } = Astro.props; +const d = useDict(locale); + +const navItems: { id: PageId; label: string }[] = [ + { id: 'projects', label: d.nav.projects }, + { id: 'experience', label: d.nav.experience }, + { id: 'about', label: d.nav.about }, + { id: 'contact', label: d.nav.contact }, +]; +const isCurrent = (id: PageId) => + id === pageId || + (id === 'projects' && (PROJECT_PAGE_IDS as readonly string[]).includes(pageId)); +--- + + + + + + diff --git a/site/src/components/core/HintBar.astro b/site/src/components/core/HintBar.astro new file mode 100644 index 0000000..faf023f --- /dev/null +++ b/site/src/components/core/HintBar.astro @@ -0,0 +1,39 @@ +--- +/* + First-visit language hint (ROUTING_SPEC §3, MOCKUPS M5e). Rendered only on EN pages, + hidden by default; nav.ts reveals it when the browser prefers Norwegian and it hasn't + been dismissed. Never auto-redirects. Text is Norwegian (invites a NO-preferring reader). +*/ +import { useDict } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId } from '@i18n/slugMap'; + +interface Props { + locale: Locale; + pageId: PageId; +} +const { locale, pageId } = Astro.props; +const d = useDict(locale); +--- + + diff --git a/site/src/components/core/MobileNav.astro b/site/src/components/core/MobileNav.astro new file mode 100644 index 0000000..07c0d46 --- /dev/null +++ b/site/src/components/core/MobileNav.astro @@ -0,0 +1,96 @@ +--- +/* + Full-screen mobile nav overlay (MOCKUPS M4). Hidden by default; nav.ts toggles + [data-open], traps focus, locks body scroll, closes on Esc. Large tap targets. + Language switch inside is plain links (works without JS). +*/ +import { useDict } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId } from '@i18n/slugMap'; +import { getProfile } from '@lib/content'; +import LangSwitch from '@components/ui/LangSwitch.astro'; +import ThemeToggle from '@components/ui/ThemeToggle.astro'; + +interface Props { + locale: Locale; + pageId: PageId; +} +const { locale, pageId } = Astro.props; +const d = useDict(locale); +const p = getProfile(locale); + +const navItems: { id: PageId; label: string }[] = [ + { id: 'home', label: d.nav.home }, + { id: 'projects', label: d.nav.projects }, + { id: 'experience', label: d.nav.experience }, + { id: 'about', label: d.nav.about }, + { id: 'contact', label: d.nav.contact }, +]; +--- + + diff --git a/site/src/components/core/Seo.astro b/site/src/components/core/Seo.astro new file mode 100644 index 0000000..59dba78 --- /dev/null +++ b/site/src/components/core/Seo.astro @@ -0,0 +1,57 @@ +--- +/* Head metadata: title, description, canonical, hreflang, OpenGraph, JSON-LD (SEO_SPEC). */ +import type { Locale } from '@i18n/locales'; +import { HTML_LANG, OG_LOCALE, otherLocale } from '@i18n/locales'; +import { pathFor, type PageId } from '@i18n/slugMap'; +import { absUrl, alternates, ogImagePath, buildJsonLd } from '@lib/seo'; + +interface Props { + locale: Locale; + pageId: PageId; + title: string; + description: string; + ogType?: 'website' | 'profile' | 'article'; + jsonLdOpts?: { + projectName?: string; + projectType?: 'SoftwareApplication' | 'SoftwareSourceCode'; + }; +} +const { locale, pageId, title, description, ogType = 'website', jsonLdOpts } = Astro.props; + +const origin = Astro.site?.href ?? 'https://cesnimda.co.uk'; +const canonical = absUrl(pathFor(pageId, locale), origin); +const alts = alternates(pageId, origin); +const ogImage = absUrl(ogImagePath(pageId, locale), origin); +const jsonLd = buildJsonLd(pageId, locale, origin, jsonLdOpts); +--- + +{title} + + + +{alts.map((a) => )} + + + + + + + + + + + + + + + + + + + + +{ + jsonLd.map((obj) => ( + diff --git a/site/src/components/home/AboutTeaser.astro b/site/src/components/home/AboutTeaser.astro new file mode 100644 index 0000000..20e42f8 --- /dev/null +++ b/site/src/components/home/AboutTeaser.astro @@ -0,0 +1,44 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { homeAnchor, pathFor } from '@i18n/slugMap'; +import type { ProfileVM } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; + +interface Props { + locale: Locale; + profile: ProfileVM; +} +const { locale, profile } = Astro.props; +const d = useDict(locale); +--- + +
+ +
+
+

{profile.aboutHeading}

+

{profile.aboutParagraphs[0]}

+ + {profile.aboutHeading} → + +
+
+

+ Languages +

+
    + { + profile.languages.map((lang) => ( +
  • + {lang.label} — {lang.level} +
  • + )) + } +
+
+
+
diff --git a/site/src/components/home/ContactBand.astro b/site/src/components/home/ContactBand.astro new file mode 100644 index 0000000..fadd771 --- /dev/null +++ b/site/src/components/home/ContactBand.astro @@ -0,0 +1,37 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { homeAnchor, pathFor } from '@i18n/slugMap'; +import type { ProfileVM } from '@lib/content'; + +interface Props { + locale: Locale; + profile: ProfileVM; +} +const { locale, profile } = Astro.props; +const d = useDict(locale); +--- + +
+
+

{profile.contactHeading}

+

{profile.contactBody}

+ +
+
diff --git a/site/src/components/home/ExperienceTimeline.astro b/site/src/components/home/ExperienceTimeline.astro new file mode 100644 index 0000000..8dfc244 --- /dev/null +++ b/site/src/components/home/ExperienceTimeline.astro @@ -0,0 +1,77 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { homeAnchor } from '@i18n/slugMap'; +import { getExperience } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; + +interface Props { + locale: Locale; + compact?: boolean; // when true (experience page), render heading differently +} +const { locale } = Astro.props; +const d = useDict(locale); +const items = getExperience(locale); +const firstAlongside = items.findIndex((i) => i.alongside); + +const period = (from: number, to: number | null) => `${from}–${to ?? d.home.present}`; +--- + +
+ +

{d.home.experienceHeading}

+ +
+ + + { + items.map((item, i) => ( + <> + {i === firstAlongside && ( +

+ {d.home.earlierRoles} +

+ )} + + {item.emphasis === 'featured' ? ( +
+
+ ) : ( +
+
+ )} + + )) + } +
+
diff --git a/site/src/components/home/Hero.astro b/site/src/components/home/Hero.astro new file mode 100644 index 0000000..7c5c660 --- /dev/null +++ b/site/src/components/home/Hero.astro @@ -0,0 +1,72 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { pathFor } from '@i18n/slugMap'; +import type { ProfileVM } from '@lib/content'; +import Chip from '@components/ui/Chip.astro'; +import Button from '@components/ui/Button.astro'; +import TraceMotif from '@components/ui/TraceMotif.astro'; + +interface Props { + locale: Locale; + profile: ProfileVM; +} +const { locale, profile } = Astro.props; +const d = useDict(locale); +const isPlaceholder = profile.photo.src.startsWith('placeholder:'); +--- + +
+
+
+ +
+
+

+ {profile.eyebrow} +

+

{profile.name}

+

{profile.heroTagline}

+

{profile.heroSummary}

+ + + +
    + {profile.chips.map((chip) =>
  • )} +
+ +
+ + +
+
+ +
+
+
+ { + isPlaceholder ? ( +
+ [ portrait ] +
+ ) : ( + {profile.photo.alt} + ) + } +
+

{profile.photo.caption}

+
+
+
+
diff --git a/site/src/components/home/Homepage.astro b/site/src/components/home/Homepage.astro new file mode 100644 index 0000000..34aa4a3 --- /dev/null +++ b/site/src/components/home/Homepage.astro @@ -0,0 +1,39 @@ +--- +/* Full homepage composition, locale-parameterised so both routes share one source. */ +import type { Locale } from '@i18n/locales'; +import Base from '@layouts/Base.astro'; +import { getProfile, getMeta } from '@lib/content'; +import Hero from '@components/home/Hero.astro'; +import ProofStrip from '@components/home/ProofStrip.astro'; +import SkillsGrid from '@components/home/SkillsGrid.astro'; +import ProjectCards from '@components/home/ProjectCards.astro'; +import ExperienceTimeline from '@components/home/ExperienceTimeline.astro'; +import AboutTeaser from '@components/home/AboutTeaser.astro'; +import ContactBand from '@components/home/ContactBand.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const profile = getProfile(locale); +const m = getMeta('home', locale); +--- + + + +
+ +
+ + + + + + diff --git a/site/src/components/home/ProjectCards.astro b/site/src/components/home/ProjectCards.astro new file mode 100644 index 0000000..6ea3901 --- /dev/null +++ b/site/src/components/home/ProjectCards.astro @@ -0,0 +1,87 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { homeAnchor } from '@i18n/slugMap'; +import { getProjectCards } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import Chip from '@components/ui/Chip.astro'; +import FramedImage from '@components/ui/FramedImage.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const projects = getProjectCards(locale); +const caseStudies = projects.filter((p) => p.template === 'case-study'); +const capabilities = projects.filter((p) => p.template === 'capability'); +--- + +
+ +

{d.home.projectsHeading}

+ + + + { + capabilities.map((p) => ( + + +
+

{p.name}

+

{p.cardTeaser}

+
+ + {d.project.explore} + + +
+ )) + } +
diff --git a/site/src/components/home/ProofStrip.astro b/site/src/components/home/ProofStrip.astro new file mode 100644 index 0000000..26d486e --- /dev/null +++ b/site/src/components/home/ProofStrip.astro @@ -0,0 +1,23 @@ +--- +import type { ProfileVM } from '@lib/content'; + +interface Props { + profile: ProfileVM; +} +const { profile } = Astro.props; +--- + +
+
    + { + profile.proof.map((tile) => ( +
  • +

    {tile.stat}

    +

    + {tile.label} +

    +
  • + )) + } +
+
diff --git a/site/src/components/home/SkillsGrid.astro b/site/src/components/home/SkillsGrid.astro new file mode 100644 index 0000000..05871bd --- /dev/null +++ b/site/src/components/home/SkillsGrid.astro @@ -0,0 +1,42 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { homeAnchor } from '@i18n/slugMap'; +import { getSkills } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import Chip from '@components/ui/Chip.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const groups = getSkills(locale); +const num = ['01', '02', '03']; +--- + +
+ +

{d.home.skillsHeading}

+

{d.home.skillsIntro}

+ +
+ { + groups.map((group, i) => ( +
+

+ {num[i]} — {group.title} +

+

{group.context}

+
    + {group.skills.map((s) => ( +
  • + +
  • + ))} +
+
+ )) + } +
+
diff --git a/site/src/components/ui/Button.astro b/site/src/components/ui/Button.astro new file mode 100644 index 0000000..625dd96 --- /dev/null +++ b/site/src/components/ui/Button.astro @@ -0,0 +1,55 @@ +--- +/* + Button / link (MICRO_INTERACTIONS). Renders when href is set, else + ) +} diff --git a/site/src/components/ui/Card.astro b/site/src/components/ui/Card.astro new file mode 100644 index 0000000..ab14ed2 --- /dev/null +++ b/site/src/components/ui/Card.astro @@ -0,0 +1,30 @@ +--- +/* + Surface card (DESIGN_SYSTEM §3): flat, hairline border, depth via border-strong on + hover — no drop shadow in dark. `interactive` makes the whole card a single link. +*/ +interface Props { + href?: string; + interactive?: boolean; + class?: string; + ariaLabel?: string; +} +const { href, interactive = false, class: cls, ariaLabel } = Astro.props; + +const base = 'rounded-md border border-line bg-surface-1 shadow-[var(--shadow-card)]'; +const hover = interactive + ? 'group block transition-colors duration-[--dur-standard] hover:border-line-strong focus-visible:border-line-strong' + : ''; +--- + +{ + href ? ( + + + + ) : ( +
+ +
+ ) +} diff --git a/site/src/components/ui/Chip.astro b/site/src/components/ui/Chip.astro new file mode 100644 index 0000000..ee23c5f --- /dev/null +++ b/site/src/components/ui/Chip.astro @@ -0,0 +1,54 @@ +--- +/* + Information atom (DESIGN_SYSTEM §2). kinds: fact (non-interactive), stack (with + optional tooltip), status (dot + label; accent=active, amber=in development). + Status meaning is never colour-only — dot + text always paired (COLOUR_SYSTEM §3.4). +*/ +type Status = 'active' | 'in-development' | 'archived'; + +interface Props { + kind?: 'fact' | 'stack' | 'status'; + status?: Status; + label: string; + tooltip?: string; + class?: string; +} +const { kind = 'fact', status, label, tooltip, class: cls } = Astro.props; + +const base = + 'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 font-mono text-mono-label whitespace-nowrap'; + +const statusRing: Record = { + active: 'border-accent/40 text-accent', + 'in-development': 'border-amber/40 text-amber', + archived: 'border-line text-ink-muted', +}; +const dotColor: Record = { + active: 'bg-accent', + 'in-development': 'bg-amber', + archived: 'bg-ink-faint', +}; +--- + +{ + kind === 'status' && status ? ( + + + ) : ( + + {label} + + ) +} diff --git a/site/src/components/ui/FramedImage.astro b/site/src/components/ui/FramedImage.astro new file mode 100644 index 0000000..544944c --- /dev/null +++ b/site/src/components/ui/FramedImage.astro @@ -0,0 +1,84 @@ +--- +/* + Screenshot in a browser-chrome frame (DESIGN_SYSTEM §5). Aspect-locked to prevent + layout shift (CLS 0). `placeholder:` sources render a styled skeleton until the real + capture lands (Phase 1/2 seeded-data capture prerequisite); real images are lazy and + become lightbox targets (bound by lightbox.ts via data-lightbox). +*/ +interface Media { + src: string; + width: number; + height: number; + alt: string; + caption: string; +} +interface Props { + media: Media; + priority?: boolean; + index?: number; + url?: string; + class?: string; + /** When true, never render the lightbox button (e.g. inside a card that is itself a link). */ + noZoom?: boolean; +} +const { media, priority = false, index, url, class: cls, noZoom = false } = Astro.props; +const isPlaceholder = media.src.startsWith('placeholder:'); +const zoomable = !isPlaceholder && !noZoom && index !== undefined; +const ratio = `${media.width} / ${media.height}`; +--- + +
+ {/* Browser chrome */} +
+ + + + { + url && ( + + {url} + + ) + } +
+ + { + isPlaceholder ? ( +
+ {media.caption} +
+ ) : !zoomable ? ( + {media.alt} + ) : ( + + ) + } +
{media.alt}
+
diff --git a/site/src/components/ui/LangSwitch.astro b/site/src/components/ui/LangSwitch.astro new file mode 100644 index 0000000..458ee0d --- /dev/null +++ b/site/src/components/ui/LangSwitch.astro @@ -0,0 +1,51 @@ +--- +/* + Language switch (MICRO_INTERACTIONS): a two-segment control that maps the current + page to its twin in the other locale via the slug map. Implemented as two links, + so it works with no JavaScript; the active segment is styled as the thumb. +*/ +import { useDict } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId } from '@i18n/slugMap'; +import { LOCALE_LABEL } from '@i18n/locales'; + +interface Props { + locale: Locale; + pageId: PageId; + class?: string; +} +const { locale, pageId, class: cls } = Astro.props; +const d = useDict(locale); + +const segments: { code: Locale; href: string; label: string }[] = [ + { code: 'en', href: pathFor(pageId, 'en'), label: LOCALE_LABEL.en }, + { code: 'no', href: pathFor(pageId, 'no'), label: LOCALE_LABEL.no }, +]; +--- + +
+ { + segments.map((s) => + s.code === locale ? ( + + {s.label} + + ) : ( + + {s.label} + + ), + ) + } +
diff --git a/site/src/components/ui/SectionLabel.astro b/site/src/components/ui/SectionLabel.astro new file mode 100644 index 0000000..20ae546 --- /dev/null +++ b/site/src/components/ui/SectionLabel.astro @@ -0,0 +1,16 @@ +--- +/* Numbered mono section eyebrow, e.g. "01 — PROJECTS" (DESIGN_SYSTEM §4). */ +interface Props { + number?: string; + text: string; + id?: string; +} +const { number, text, id } = Astro.props; +--- + +

+ {number ? `${number} — ${text}` : text} +

diff --git a/site/src/components/ui/SplitCvButton.astro b/site/src/components/ui/SplitCvButton.astro new file mode 100644 index 0000000..937fbb3 --- /dev/null +++ b/site/src/components/ui/SplitCvButton.astro @@ -0,0 +1,63 @@ +--- +/* + CV split button (MICRO_INTERACTIONS): primary zone downloads the current-locale CV; + the chevron is a native
disclosure listing both languages with file sizes + — works with no JavaScript. nav.ts enhances it with outside-click / Esc close. +*/ +import { useDict, interpolate } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; +import { getProfile } from '@lib/content'; + +interface Props { + locale: Locale; + compact?: boolean; +} +const { locale, compact = false } = Astro.props; +const d = useDict(locale); +const p = getProfile(locale); +const current = p.cv[locale]; +const other = locale === 'en' ? p.cv.no : p.cv.en; +const otherLabel = locale === 'en' ? d.cv.norsk : d.cv.english; +const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk; +--- + + diff --git a/site/src/components/ui/ThemeToggle.astro b/site/src/components/ui/ThemeToggle.astro new file mode 100644 index 0000000..d095a83 --- /dev/null +++ b/site/src/components/ui/ThemeToggle.astro @@ -0,0 +1,36 @@ +--- +import { useDict } from '@i18n/t'; +import type { Locale } from '@i18n/locales'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +--- + + diff --git a/site/src/components/ui/TraceMotif.astro b/site/src/components/ui/TraceMotif.astro new file mode 100644 index 0000000..121b21e --- /dev/null +++ b/site/src/components/ui/TraceMotif.astro @@ -0,0 +1,26 @@ +--- +/* + The signature "trace" (A1): a thin accent line that draws on load, steps down, + and terminates in a lit status dot. Static under reduced motion / repeat visits. +*/ +interface Props { + statusLine: string; + class?: string; +} +const { statusLine, class: cls } = Astro.props; +--- + +
+ + {statusLine} +
diff --git a/site/src/content/experience.ts b/site/src/data/experience.ts similarity index 100% rename from site/src/content/experience.ts rename to site/src/data/experience.ts diff --git a/site/src/content/meta.ts b/site/src/data/meta.ts similarity index 100% rename from site/src/content/meta.ts rename to site/src/data/meta.ts diff --git a/site/src/content/profile.ts b/site/src/data/profile.ts similarity index 100% rename from site/src/content/profile.ts rename to site/src/data/profile.ts diff --git a/site/src/content/projects/homelab.ts b/site/src/data/projects/homelab.ts similarity index 100% rename from site/src/content/projects/homelab.ts rename to site/src/data/projects/homelab.ts diff --git a/site/src/content/projects/inboxintel.ts b/site/src/data/projects/inboxintel.ts similarity index 100% rename from site/src/content/projects/inboxintel.ts rename to site/src/data/projects/inboxintel.ts diff --git a/site/src/content/projects/jobtrack.ts b/site/src/data/projects/jobtrack.ts similarity index 100% rename from site/src/content/projects/jobtrack.ts rename to site/src/data/projects/jobtrack.ts diff --git a/site/src/content/skills.ts b/site/src/data/skills.ts similarity index 100% rename from site/src/content/skills.ts rename to site/src/data/skills.ts diff --git a/site/src/i18n/dictionary.en.ts b/site/src/i18n/dictionary.en.ts index e8ccf19..0ba1158 100644 --- a/site/src/i18n/dictionary.en.ts +++ b/site/src/i18n/dictionary.en.ts @@ -1,6 +1,21 @@ import type { Dictionary } from './dictionary'; export const en: Dictionary = { + hero: { + viewProjects: 'View projects', + }, + home: { + skillsLabel: 'Skills', + skillsHeading: 'What I work with', + skillsIntro: 'A practical profile: business value, maintainability and dependable delivery.', + projectsLabel: 'Projects', + projectsHeading: 'Built, shipped and running', + experienceLabel: 'Experience', + experienceHeading: 'Where I’ve worked', + aboutLabel: 'About', + earlierRoles: 'Earlier & alongside', + present: 'Present', + }, nav: { home: 'Home', projects: 'Projects', diff --git a/site/src/i18n/dictionary.no.ts b/site/src/i18n/dictionary.no.ts index ff90313..05f15b3 100644 --- a/site/src/i18n/dictionary.no.ts +++ b/site/src/i18n/dictionary.no.ts @@ -12,6 +12,21 @@ import type { Dictionary } from './dictionary'; */ export const no: Dictionary = { + hero: { + viewProjects: 'Se prosjekter', + }, + home: { + skillsLabel: 'Kompetanse', + skillsHeading: 'Det jeg jobber med', + skillsIntro: 'En praktisk profil: forretningsverdi, vedlikeholdbarhet og pålitelig leveranse.', + projectsLabel: 'Prosjekter', + projectsHeading: 'Bygget, levert og i drift', + experienceLabel: 'Erfaring', + experienceHeading: 'Hvor jeg har jobbet', + aboutLabel: 'Om meg', + earlierRoles: 'Tidligere og ved siden av', + present: 'nå', + }, nav: { home: 'Hjem', projects: 'Prosjekter', diff --git a/site/src/i18n/dictionary.ts b/site/src/i18n/dictionary.ts index b4bb487..aac24d2 100644 --- a/site/src/i18n/dictionary.ts +++ b/site/src/i18n/dictionary.ts @@ -5,6 +5,21 @@ */ export interface Dictionary { + hero: { + viewProjects: string; + }; + home: { + skillsLabel: string; + skillsHeading: string; + skillsIntro: string; + projectsLabel: string; + projectsHeading: string; + experienceLabel: string; + experienceHeading: string; + aboutLabel: string; + earlierRoles: string; + present: string; + }; nav: { home: string; projects: string; diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro new file mode 100644 index 0000000..7eca575 --- /dev/null +++ b/site/src/layouts/Base.astro @@ -0,0 +1,67 @@ +--- +import '@fontsource-variable/space-grotesk'; +import '@fontsource-variable/inter'; +import '@fontsource-variable/jetbrains-mono'; +import '@styles/global.css'; + +import type { Locale } from '@i18n/locales'; +import { HTML_LANG } from '@i18n/locales'; +import type { PageId } from '@i18n/slugMap'; +import Seo from '@components/core/Seo.astro'; +import ThemeScript from '@components/core/ThemeScript.astro'; +import SkipLink from '@components/core/SkipLink.astro'; +import Header from '@components/core/Header.astro'; +import HintBar from '@components/core/HintBar.astro'; +import Footer from '@components/core/Footer.astro'; + +interface Props { + locale: Locale; + pageId: PageId; + title: string; + description: string; + ogType?: 'website' | 'profile' | 'article'; + jsonLdOpts?: { + projectName?: string; + projectType?: 'SoftwareApplication' | 'SoftwareSourceCode'; + }; + mainClass?: string; +} +const { locale, pageId, title, description, ogType, jsonLdOpts, mainClass } = Astro.props; +--- + + + + + + + + + + + + + + + +
+ {locale === 'en' && } +
+ +
+