diff --git a/src/consts.ts b/src/consts.ts
index 0049e2f..c746a7c 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -29,5 +29,5 @@ export const SOCIALS = [
// hide: repo-navne der skjules helt. pinned: vises først (i den rækkefølge).
export const PROJECTS = {
hide: [] as string[],
- pinned: [] as string[],
+ pinned: ['ubl-tools', 'gtk-ahfail', 'pi3-smart-workspace'] as string[],
};
diff --git a/src/content.config.ts b/src/content.config.ts
index 9e0b254..87ae8d9 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -14,4 +14,24 @@ const blog = defineCollection({
}),
});
-export const collections = { blog };
+// Projektsider (/projekter/): én markdown-fil pr. værktøj jeg udgiver.
+// Frontmatter bærer de faste links, så siden og søgeindekset kan vise dem ens.
+const projekter = defineCollection({
+ loader: glob({ pattern: '**/*.md', base: './src/content/projekter' }),
+ schema: () =>
+ z.object({
+ title: z.string(),
+ description: z.string(),
+ repo: z.string(), // repo-navn på Gitea under SITE.giteaUser
+ sprog: z.string(), // fx Rust, Python
+ licens: z.string(),
+ platforme: z.array(z.string()).default([]),
+ aur: z.array(z.string()).default([]), // AUR-pakkenavne
+ tags: z.array(z.string()).default([]),
+ date: z.coerce.date(), // første udgivelse
+ updated: z.coerce.date().optional(),
+ draft: z.boolean().default(false),
+ }),
+});
+
+export const collections = { blog, projekter };
diff --git a/src/content/projekter/ahfail.md b/src/content/projekter/ahfail.md
new file mode 100644
index 0000000..fb6a28c
--- /dev/null
+++ b/src/content/projekter/ahfail.md
@@ -0,0 +1,55 @@
+---
+title: "ahfail"
+description: "En skærmlås til Linux og macOS. Skriver du forkert kodeord, dukker Dennis Nedry op og siger \"ah ah ah, you didn't say the magic word\"."
+repo: gtk-ahfail
+sprog: Rust
+licens: AGPL-3.0-only
+platforme: [Linux (Wayland), Linux (X11), macOS]
+aur: [ahfail, ahfail-bin]
+tags: [linux, wayland, x11, macos, pam, rust]
+date: 2026-09-30
+updated: 2026-09-30
+---
+
+Skriver du forkert kodeord, kører en animation i loop af mit eget ansigt photoshoppet ind på Dennis Nedry fra Jurassic Park, mens klippet med "ah ah ah, you didn't say the magic word" spiller. Lyden skrues helt op, så den der prøvede at snige sig ind på din maskine også får det hele med (`--no-max-volume` hvis du hellere vil være diskret). Under joken er det en rigtig skærmlås.
+
+```bash
+ahfail # lås skærmen
+ahfail test # se effekten uden at låse
+ahfail doctor # hvad mangler maskinen?
+```
+
+Den er ét program på Linux, og den finder selv ud af om du kører Wayland eller X11. På Wayland låser den gennem `ext-session-lock-v1`, så det virker i sway, Hyprland, niri, river, labwc og KDE Plasma 6. GNOME lader ikke andre programmer låse skærmen, så der er den ude. På X11 gør den som i3lock: fuldskærmsvinduer på alle skærme og et greb om tastatur og mus, og kan den ikke få grebet, nægter den at låse i stedet for at vise en lås dine tastetryk går lige igennem.
+
+Kodeordet tjekkes gennem dens egen PAM-tjeneste, `/etc/pam.d/ahfail`, som pakken installerer selv. Den rører ikke andre pakkers PAM-opsætning, på samme måde som swaylock og gtklock gør det. Den inkluderer `login`, så systemets `pam_faillock` gælder også her, og på Arch betyder det at tre forkerte kodeord på et kvarter låser kontoen i ti minutter. Det er værd at vide inden man viser den frem til fredagsbaren.
+
+En skærmlås sidder direkte i login-vejen, så en fejl i den kan i værste fald svække låsen. Der er ingen garantier, og du installerer den på eget ansvar. Det står også i pakken.
+
+## Installation
+
+På Arch Linux, som kildepakke eller færdigbygget:
+
+```bash
+yay -S ahfail
+yay -S ahfail-bin
+```
+
+Kør `ahfail doctor` bagefter. Den fortæller om sessionstypen er understøttet, om PAM-filen ligger rigtigt, om GStreamer kan afspille klippet og om lydstyrken kan styres, med den præcise rettelse hvis noget mangler.
+
+Vil du have den til at låse af sig selv, så peg din idle-dæmon på den:
+
+```bash
+swayidle -w timeout 300 'ahfail' before-sleep 'ahfail' # Wayland
+xss-lock -- ahfail # X11
+```
+
+## macOS
+
+På Mac er det systemets egen låseskærm der låser, og effekten kommer fra en PAM-hook der installeres via Homebrew:
+
+```bash
+brew tap asger-weirsoee/tap
+brew install ahfail
+```
+
+Homebrew skriver bagefter den ene linje ud som skal tilføjes i `/etc/pam.d/screensaverui`. Det er det eneste manuelle trin.
diff --git a/src/content/projekter/pi3-smart-workspace.md b/src/content/projekter/pi3-smart-workspace.md
new file mode 100644
index 0000000..1bc10dc
--- /dev/null
+++ b/src/content/projekter/pi3-smart-workspace.md
@@ -0,0 +1,47 @@
+---
+title: "pi3-smart-workspace"
+description: "Et lille værktøj til i3 der gør $mod+1 til den første workspace på den skærm musen står på, i stedet for workspace nummer 1 et helt andet sted."
+repo: pi3-smart-workspace
+sprog: Python
+licens: Apache-2.0
+platforme: [Linux (i3)]
+aur: [pi3-smart-workspace]
+tags: [i3, linux, python]
+date: 2020-09-04
+updated: 2026-09-30
+---
+
+Med flere skærme i i3 er workspaces nummereret globalt. Trykker du `$mod+1`, hopper du til workspace 1, også selvom den ligger på skærmen til venstre og du sidder og arbejder på den til højre. `pi3-smart-workspace` vender det om: tasten vælger den N'te workspace på den skærm musen står på. `$mod+1` er altid den første workspace der hvor musen er, og et setup med flere skærme føles meget mere sammenhængende.
+
+Den læser din kørende i3-konfiguration og bruger tre ting fra den: navne på skærmene (`set $
+
+ Vil du læse hele fakturaen eller have den vedhæftede PDF ud, så har jeg skrevet
+ ubl-tools til terminalen,
+ også på Windows og macOS.
+
Træk en .xml-fil hertil, eller
diff --git a/src/seo.ts b/src/seo.ts
index e4f76e0..20bb89c 100644
--- a/src/seo.ts
+++ b/src/seo.ts
@@ -159,3 +159,31 @@ export function collectionPage(opts: { url: string; name: string; description: s
about: { '@id': personId },
};
}
+
+// SoftwareSourceCode — a project page for a tool the Person wrote and maintains.
+// codeRepository points at Gitea; sameAs lists the package pages (AUR) for the same tool.
+export function softwareSourceCode(opts: {
+ url: string; name: string; description: string; codeRepository: string;
+ language: string; license: string; platforms: string[]; sameAs: string[];
+ dateCreated: string; dateModified: string; keywords: string[];
+}) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'SoftwareSourceCode',
+ '@id': `${opts.url}#software`,
+ url: opts.url,
+ name: opts.name,
+ description: opts.description,
+ codeRepository: opts.codeRepository,
+ programmingLanguage: opts.language,
+ license: opts.license,
+ runtimePlatform: opts.platforms,
+ sameAs: opts.sameAs,
+ dateCreated: opts.dateCreated,
+ dateModified: opts.dateModified,
+ keywords: opts.keywords,
+ inLanguage: 'da-DK',
+ author: { '@id': personId },
+ maintainer: { '@id': personId },
+ };
+}