merge: upstream theme updates (54 commits)

Key changes merged from svemagie/blog-eleventy-indiekit:

- feat: /updated.xml feed for recently edited posts
- feat: sitemap.xml generation in eleventy.after hook
- feat: excludePostTypes filter for homepage section config
- feat: view mode toggle (repo/type) for changelog page
- feat: replyTargets config for platform-to-syndicator mapping
- feat: syndication badge + linked timestamp on owner replies
- perf: memoize aiPosts/aiStats/hash filters; batch unfurl pre-fetch
- perf: clear eleventy-img in-memory cache between builds (OOM fix)
- perf: memory profiler (logMemory) at build phases
- perf: OG batch tracking (totalGenerated/batch counters)
- fix: h-entry u-url absolute for IndieNews compatibility
- fix: webmention platform detection in build-time templates
- fix: deduplicate interactions via interactionKey
- fix: reply form syndication via replyTargets (not hardcoded platforms)
- fix: remove skeleton loader CSS (CLS fix)
- fix: avatar dimensions 96→128 to match CSS classes
- css: remove unused skeleton loader rules

Local customisations preserved:
- Gitea-based data files (githubActivity, githubRepos, githubStarred)
- Funkwhale cover image cache copy in eleventy.after
- URL fallback arrays in funkwhale/lastfm data fetchers
- CONFIGURABLE cache durations (FUNKWHALE_FETCH_CACHE_DURATION etc.)
- OG_CACHE_DIR naming (not cacheDir)
- Our ogSlug format (plain slug, not date-prefixed)
- Gruvbox design tokens (link colours, selection colours)
- Unfurl manifest optimisation (skip re-fetching known URLs)
- CLAUDE.md, README.md, .github/workflows (ours)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-04-09 14:41:09 +02:00
21 changed files with 531 additions and 1271 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Fetches recent posts from Bluesky using the AT Protocol API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
export default async function () {
const rawHandle = (process.env.BLUESKY_HANDLE || "")
@@ -22,7 +22,7 @@ export default async function () {
// Get the author's feed using public API (no auth needed for public posts)
const feedUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=10`;
const response = await EleventyFetch(feedUrl, {
const response = await cachedFetch(feedUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
+2 -2
View File
@@ -3,7 +3,7 @@
* Fetches from Indiekit's endpoint-funkwhale public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
import { cacheCoverUrls, cacheFunkwhaleImage, gcFunkwhaleImages } from "../lib/cache-funkwhale-image.js";
const INDIEKIT_URL =
@@ -27,7 +27,7 @@ async function fetchFromIndiekit(endpoint) {
for (const url of urls) {
try {
console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: FUNKWHALE_FETCH_CACHE_DURATION,
type: "json",
});
+1 -1
View File
@@ -3,7 +3,7 @@
* Fetches commits and repos from self-hosted Gitea instance
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const GITEA_URL = process.env.GITEA_INTERNAL_URL || process.env.GITEA_URL || "https://gitea.giersig.eu";
const GITEA_ORG = process.env.GITEA_ORG || "giersig.eu";
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function () {
const url = `${GITEA_URL}/api/v1/orgs/${GITEA_ORG}/repos?limit=10&sort=newest`;
const repos = await cachedFetch(url, {
duration: "1h",
duration: "1h", // Cache for 1 hour
type: "json",
});
+2 -2
View File
@@ -6,7 +6,7 @@
* The starred page fetches all data client-side via Alpine.js.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -19,7 +19,7 @@ export default async function () {
for (const url of urls) {
try {
const response = await EleventyFetch(url, {
const response = await cachedFetch(url, {
duration: "15m",
type: "json",
});
+2 -2
View File
@@ -3,7 +3,7 @@
* Fetches from Indiekit's endpoint-lastfm public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL =
process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com";
@@ -26,7 +26,7 @@ async function fetchFromIndiekit(path) {
for (const url of urls) {
try {
console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: LASTFM_FETCH_CACHE_DURATION,
type: "json",
});
+3 -3
View File
@@ -3,7 +3,7 @@
* Fetches recent posts from Mastodon using the public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
export default async function () {
const instance = (
@@ -28,7 +28,7 @@ export default async function () {
// First, look up the account ID
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
const account = await EleventyFetch(lookupUrl, {
const account = await cachedFetch(lookupUrl, {
duration: "1h", // Cache account lookup for 1 hour
type: "json",
fetchOptions: {
@@ -46,7 +46,7 @@ export default async function () {
// Fetch recent statuses (excluding replies; boosts included since that's primary activity)
const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true`;
const statuses = await EleventyFetch(statusesUrl, {
const statuses = await cachedFetch(statusesUrl, {
duration: "15m", // Cache for 15 minutes
type: "json",
fetchOptions: {
+2 -2
View File
@@ -25,8 +25,8 @@
<img
src="{{ authorAvatar }}"
alt="{{ authorName }}"
width="96"
height="96"
width="128"
height="128"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-lg"
loading="eager"
>
@@ -7,11 +7,12 @@
{% set sectionConfig = section.config or {} %}
{% set maxItems = sectionConfig.maxItems or 5 %}
{% set showSummary = sectionConfig.showSummary if sectionConfig.showSummary is defined else true %}
{% set excludeTypes = sectionConfig.excludeTypes or [] %}
{% set primaryPosts = collections.posts if (collections and collections.posts) else [] %}
{% set fallbackRecentPosts = collections.recentPosts if (collections and collections.recentPosts) else [] %}
{% set listedPosts = primaryPosts | excludeUnlistedPosts %}
{% set listedPosts = primaryPosts | excludeUnlistedPosts | excludePostTypes(excludeTypes) %}
{% if not (listedPosts and listedPosts.length) %}
{% set listedPosts = fallbackRecentPosts | excludeUnlistedPosts %}
{% set listedPosts = fallbackRecentPosts | excludeUnlistedPosts | excludePostTypes(excludeTypes) %}
{% endif %}
{% if listedPosts and listedPosts.length %}
+11 -1
View File
@@ -153,9 +153,19 @@
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none">
{{ reply.content.html | safe if reply.content.html else reply.content.text }}
</div>
{% set replySource = reply['wm-source'] | default('', true) %}
{% set replyAuthorUrl = reply.author.url | default('', true) %}
{% set buildPlatform = 'webmention' %}
{% if 'bsky.app' in replyAuthorUrl or ('brid.gy/' in replySource and '/bluesky/' in replySource) %}
{% set buildPlatform = 'bluesky' %}
{% elif 'brid.gy/' in replySource and '/mastodon/' in replySource %}
{% set buildPlatform = 'mastodon' %}
{% elif 'fed.brid.gy' in replySource %}
{% set buildPlatform = 'activitypub' %}
{% endif %}
<button class="wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2"
data-reply-url="{{ reply.url }}"
data-platform="">
data-platform="{{ buildPlatform }}">
Reply
</button>
</div>
+6 -23
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ site.locale | default('en') }}" class="loading">
<html lang="{{ site.locale | default('en') }}">
<head>
<script>const q = new URLSearchParams(window.location.search), r = document.referrer, n = i => i ? 'http://' + i.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0].replace(/[^a-zA-Z0-9.-]/g, '') : ''; Object.defineProperty(document, "referrer", { get: () => n(q.get('ref') || q.get('referer') || q.get('referrer') || q.get('utm_source')) || r });</script>
@@ -147,7 +147,7 @@
{# Critical CSS — inlined for fast first paint #}
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
{# Defer full stylesheet — loads after first paint #}
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}" media="print" onload="this.media='all';document.documentElement.classList.remove('loading')">
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}"></noscript>
<link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}"></noscript>
@@ -165,6 +165,7 @@
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
<script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script>
<script defer src="/js/vendor/alpine-collapse.min.js?v={{ '/js/vendor/alpine-collapse.min.js' | hash }}"></script>
<script defer src="/js/vendor/alpine.min.js?v={{ '/js/vendor/alpine.min.js' | hash }}"></script>
<style>[x-cloak] { display: none !important; }</style>
@@ -181,14 +182,13 @@
[x-data] > .flex.border-b { display: none !important; }
/* Hide loading spinners and JS-only buttons */
[x-show*="loading"], button[\\@click*="fetch"], button[\\@click*="loadMore"] { display: none !important; }
/* Show content and hide skeleton for no-JS (stylesheet loads synchronously via noscript link) */
.page-skeleton { display: none !important; }
html.loading main.container > .page-content { display: block !important; }
/* Content is always visible — no skeleton loader */
</style>
</noscript>
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
<link rel="alternate" type="application/rss+xml" href="/feed.xml" title="RSS Feed">
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed">
<link rel="alternate" type="application/rss+xml" href="/updated.xml" title="Recently Updated — RSS Feed">
<link rel="alternate" type="application/rss+xml" href="/digest/feed.xml" title="Weekly Digest — RSS Feed">
{% if site.markdownAgents.enabled and page.url and page.url.startsWith('/articles/') and page.url != '/articles/' %}
<link rel="alternate" type="text/markdown" href="{{ page.url | stripTrailingSlash }}.md" title="Markdown version">
@@ -385,23 +385,6 @@
</header>
<main class="container py-8" id="main-content" data-pagefind-body>
{# Skeleton loader — shown until Tailwind stylesheet loads #}
<div class="page-skeleton" aria-hidden="true">
<div style="display:flex;gap:1.5rem;align-items:flex-start;margin-bottom:2rem">
<div class="skel-bone skel-circle" style="width:96px;height:96px;flex-shrink:0"></div>
<div style="flex:1">
<div class="skel-bone" style="height:1.75rem;width:50%;margin-bottom:.75rem"></div>
<div class="skel-bone" style="height:1rem;width:35%;margin-bottom:.75rem"></div>
<div class="skel-bone" style="height:3rem;width:90%"></div>
</div>
</div>
<div class="skel-bone" style="height:5rem;margin-bottom:.75rem"></div>
<div class="skel-bone" style="height:5rem;margin-bottom:.75rem"></div>
<div class="skel-bone" style="height:5rem;margin-bottom:.75rem"></div>
<div class="skel-bone" style="height:5rem"></div>
</div>
<div class="page-content">
{% if withSidebar and page.url == "/" and homepageConfig and homepageConfig.sections %}
{# Homepage: builder controls its own layout and sidebar #}
{{ content | safe }}
@@ -426,7 +409,6 @@
{% else %}
{{ content | safe }}
{% endif %}
</div>
</main>
<footer class="border-t border-surface-200 dark:border-surface-700 mt-12 pt-8 pb-6">
@@ -469,6 +451,7 @@
<ul class="space-y-2">
<li><a href="/feed.xml" class="text-sm text-surface-700 dark:text-surface-300 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">RSS Feed</a></li>
<li><a href="/feed.json" class="text-sm text-surface-700 dark:text-surface-300 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">JSON Feed</a></li>
<li><a href="/updated.xml" class="text-sm text-surface-700 dark:text-surface-300 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Updated Posts Feed</a></li>
<li><a href="/changelog/" class="text-sm text-surface-700 dark:text-surface-300 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Changelog</a></li>
</ul>
</div>
+11 -1
View File
@@ -187,6 +187,16 @@ withBlogSidebar: true
{% endif %}
{% endfor %}
{% endif %}
{# Fallback: if no selfHostedApUrl from syndication yet, check mpSyndicateTo.
This ensures the Fediverse button appears on the first build, before the
syndication endpoint has run and added the URL to the syndication array. #}
{% if not selfHostedApUrl and mpSyndicateTo %}
{% for url in mpSyndicateTo %}
{% if url.indexOf(site.url) == 0 %}
{% set selfHostedApUrl = page.url | url %}
{% endif %}
{% endfor %}
{% endif %}
{% if externalSyndication.length or selfHostedApUrl %}
<footer class="post-footer mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap items-center gap-4">
@@ -265,7 +275,7 @@ withBlogSidebar: true
</footer>
{% endif %}
<a class="u-url" href="{{ page.url }}" hidden>Permalink</a>
<a class="u-url u-uid" href="{{ site.url }}{{ page.url }}" hidden>Permalink</a>
{# Author h-card for IndieWeb authorship #}
<span class="p-author h-card hidden">
+94 -6
View File
@@ -13,9 +13,26 @@ withSidebar: false
<div x-data="changelogApp()" x-init="init()">
{# View mode toggle #}
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-surface-600 dark:text-surface-400">Group by:</span>
<div class="inline-flex rounded-lg border border-surface-200 dark:border-surface-700 overflow-hidden">
<button
@click="setViewMode('repo')"
:class="viewMode === 'repo' ? 'bg-accent-100 dark:bg-accent-900 text-accent-700 dark:text-accent-300' : 'text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800'"
class="px-3 py-1.5 text-xs font-medium transition-colors"
>Repository</button>
<button
@click="setViewMode('type')"
:class="viewMode === 'type' ? 'bg-accent-100 dark:bg-accent-900 text-accent-700 dark:text-accent-300' : 'text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800'"
class="px-3 py-1.5 text-xs font-medium transition-colors border-l border-surface-200 dark:border-surface-700"
>Change Type</button>
</div>
</div>
{# Tab navigation #}
<div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto" role="tablist" aria-label="Changelog categories">
<template x-for="tab in tabs" :key="tab.key">
<template x-for="tab in activeTabs" :key="tab.key">
<button
@click="activeTab = tab.key"
:class="activeTab === tab.key ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
@@ -42,6 +59,14 @@ withSidebar: false
<span class="ml-3 text-surface-600 dark:text-surface-400">Loading changelog...</span>
</div>
{# Error/stale data banner #}
<div x-show="apiError && !loading" x-cloak class="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-amber-800 dark:text-amber-200 text-sm">
<span class="font-medium">Note:</span>
<span x-text="commits.length > 0 ? 'Showing cached data — GitHub API is temporarily unavailable.' : apiError"></span>
</p>
</div>
{# Commit list #}
<div x-show="!loading" x-cloak>
<template x-if="filteredCommits().length === 0">
@@ -60,8 +85,8 @@ withSidebar: false
<div class="flex flex-wrap items-center gap-2 mt-2">
<span
class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="categoryColors[commit.commitCategory]"
x-text="categoryLabels[commit.commitCategory] || commit.commitCategory"
:class="activeCategoryColors[activeCommitCategory(commit)]"
x-text="activeCategoryLabels[activeCommitCategory(commit)] || activeCommitCategory(commit)"
></span>
<a :href="commit.repoUrl" target="_blank" rel="noopener"
class="text-xs px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400"
@@ -127,14 +152,16 @@ function categorizeCommit(message) {
function changelogApp() {
return {
activeTab: 'all',
viewMode: 'repo',
loading: true,
loadingMore: false,
apiError: null,
commits: [],
categories: {},
currentPage: 1,
hasMore: false,
tabs: [
repoTabs: [
{ key: 'all', label: 'All' },
{ key: 'features', label: 'Features' },
{ key: 'fixes', label: 'Fixes' },
@@ -143,7 +170,19 @@ function changelogApp() {
{ key: 'refactor', label: 'Refactor' },
],
categoryLabels: {
typeTabs: [
{ key: 'all', label: 'All' },
{ key: 'features', label: 'Features' },
{ key: 'fixes', label: 'Fixes' },
{ key: 'refactor', label: 'Refactor' },
{ key: 'performance', label: 'Performance' },
{ key: 'accessibility', label: 'Accessibility' },
{ key: 'documentation', label: 'Docs' },
{ key: 'chores', label: 'Chores' },
{ key: 'tests', label: 'Tests' },
],
repoCategoryLabels: {
features: 'Features',
fixes: 'Fixes',
documentation: 'Docs',
@@ -151,18 +190,66 @@ function changelogApp() {
refactor: 'Refactor',
},
categoryColors: {
typeCategoryLabels: {
features: 'Feature',
fixes: 'Fix',
performance: 'Perf',
accessibility: 'A11y',
documentation: 'Docs',
refactor: 'Refactor',
chores: 'Chore',
style: 'Style',
tests: 'Test',
other: 'Other',
},
repoCategoryColors: {
features: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
fixes: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
documentation: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
chores: 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300',
refactor: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
other: 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300',
},
typeCategoryColors: {
features: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
fixes: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
performance: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
accessibility: 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300',
documentation: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
refactor: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
chores: 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300',
style: 'bg-pink-100 dark:bg-pink-900 text-pink-700 dark:text-pink-300',
tests: 'bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300',
other: 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300',
},
get activeTabs() {
return this.viewMode === 'repo' ? this.repoTabs : this.typeTabs;
},
get activeCategoryLabels() {
return this.viewMode === 'repo' ? this.repoCategoryLabels : this.typeCategoryLabels;
},
get activeCategoryColors() {
return this.viewMode === 'repo' ? this.repoCategoryColors : this.typeCategoryColors;
},
get canLoadMore() {
return this.hasMore;
},
setViewMode(mode) {
this.viewMode = mode;
this.activeTab = 'all';
},
activeCommitCategory(commit) {
return this.viewMode === 'repo' ? commit.category : commit.commitCategory;
},
async init() {
await this.fetchChangelog(1);
},
@@ -216,6 +303,7 @@ function changelogApp() {
this.hasMore = anyHasMore;
} catch (err) {
console.error('Changelog error:', err);
this.apiError = err.message || 'Failed to load changelog';
} finally {
this.loading = false;
this.loadingMore = false;
+8 -13
View File
@@ -41,20 +41,22 @@ body{background-color:#fffef5;color:#282828}
main.container{padding-top:1.5rem;padding-bottom:1.5rem}
@media(min-width:768px){main.container{padding-top:2rem;padding-bottom:2rem}}
/* Layout with sidebar */
.layout-with-sidebar{display:grid;grid-template-columns:1fr;gap:1.5rem}
@media(min-width:1024px){.layout-with-sidebar{grid-template-columns:2fr 1fr;gap:2rem}}
/* Layout with sidebar — must match Tailwind's compiled output exactly to prevent CLS */
.layout-with-sidebar{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));gap:1.5rem}
@media(min-width:768px){.layout-with-sidebar{gap:2rem}}
@media(min-width:1024px){.layout-with-sidebar{grid-template-columns:repeat(3,minmax(0,1fr))}}
.main-content{min-width:0;overflow-x:hidden}
@media(min-width:1024px){.main-content{grid-column:span 2/span 2}}
/* Reserve sidebar space on desktop to prevent CLS when Alpine.js hydrates collapsible widgets */
@media(min-width:1024px){.sidebar{min-height:600px}}
/* Font faces — keep Inter available for UI elements that use font-sans.
/* Font faces — in critical CSS so fonts begin downloading immediately.
font-display:optional prevents FOUT/CLS: font either loads in time or fallback is kept. */
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:400;src:url(/fonts/inter-latin-400-normal.woff2) format('woff2')}
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:600;src:url(/fonts/inter-latin-600-normal.woff2) format('woff2')}
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:700;src:url(/fonts/inter-latin-700-normal.woff2) format('woff2')}
/* Basic typography */
/* Basic typography — prevent FOUT */
h1,h2,h3,h4{margin:0;line-height:1.25;font-weight:900}
a{color:#076678}
.dark a{color:#83a598}
@@ -82,13 +84,6 @@ button:focus-visible,[type="button"]:focus-visible{outline:2px solid #076678;out
::selection{background:#d65d0e;color:#fbf1c7}
.dark ::selection{background:#fe8019;color:#1d2021}
/* Skeleton loader — visible until Tailwind stylesheet loads */
html.loading main.container>.page-content{display:none}
html:not(.loading) .page-skeleton{display:none}
@keyframes skel-pulse{0%,100%{opacity:1}50%{opacity:.4}}
.skel-bone{background:#d5c4a1;border-radius:.5rem;animation:skel-pulse 1.5s ease-in-out infinite}
.dark .skel-bone{background:#504945}
.skel-circle{border-radius:50%}
/* Reduced motion — disable animations for users who prefer it */
@media(prefers-reduced-motion:reduce){.skel-bone{animation:none}*{transition-duration:0.01ms!important;animation-duration:0.01ms!important}}
@media(prefers-reduced-motion:reduce){*{transition-duration:0.01ms!important;animation-duration:0.01ms!important}}
@@ -1,439 +0,0 @@
# Navigation Redesign Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the unusable 22-item "/" dropdown with a curated header nav (Home, About, Now, Blog dropdown, Pages dropdown, Interactions, Dashboard, Search, Theme), update the footer to match the approved design, and refactor /slashes/ into a comprehensive site map covering all three page sources.
**Architecture:** Three files change — `base.njk` (header desktop nav, mobile nav, footer), `slashes.njk` (add Site Pages section), and `tailwind.css` (no structural CSS changes needed, existing nav component styles are reused). The "/" dropdown becomes a "Pages" dropdown with 4 curated items. CV and Digest move to footer only. /slashes/ gains a hardcoded "Site Pages" section for theme .njk pages.
**Tech Stack:** Nunjucks templates, Tailwind CSS utility classes, Alpine.js (dropdowns, auth-gated Dashboard link)
---
### Task 1: Replace desktop header nav in base.njk
**Files:**
- Modify: `_includes/layouts/base.njk:154-221` (desktop nav inside `.site-nav` and search/dashboard area)
**Step 1: Replace the desktop nav links and dropdowns**
Replace lines 154-221 of `base.njk` (from `<nav class="site-nav"` through the closing `</div>` of `.header-actions` before the mobile nav) with:
```nunjucks
<nav class="site-nav" id="site-nav">
<a href="/">Home</a>
<a href="/about/">About</a>
<a href="/now/">Now</a>
{# Blog dropdown #}
<div class="nav-dropdown" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="/blog/" class="nav-dropdown-trigger">
Blog
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
<a href="/blog/">All Posts</a>
{% for pt in enabledPostTypes %}
<a href="{{ pt.path }}">{{ pt.label }}</a>
{% endfor %}
</div>
</div>
{# Pages dropdown #}
<div class="nav-dropdown" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="/slashes/" class="nav-dropdown-trigger">
Pages
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">Blogroll</a>{% endif %}
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">Podroll</a>{% endif %}
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">News</a>{% endif %}
<a href="/slashes/">All Pages</a>
</div>
</div>
<a href="/interactions/">Interactions</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
x-transition
@indiekit:auth.window="show = $event.detail.loggedIn"
class="admin-nav-link">
<svg class="w-4 h-4 inline -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
Dashboard
</a>
</nav>
<a href="/search/" aria-label="Search" title="Search" class="p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
</a>
<button id="theme-toggle" type="button" class="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</div>
```
**Key changes from current:**
- Removed: CV direct link, Digest direct link, the "/" dropdown with 22+ items
- Added: Now direct link, "Pages" dropdown (Blogroll, Podroll, News, All Pages)
- Kept: Blog dropdown (unchanged), Interactions, Dashboard (auth-only), Search icon, Theme toggle
- The `hasPluginPages` variable is no longer needed in the header — plugin checks are inline in the Pages dropdown
**Step 2: Verify the edit didn't break the surrounding HTML structure**
Check that `<div class="header-actions">` still wraps the nav, search icon, and theme toggle. Check the closing `</div>` for `.header-container` is still in place at around line 237.
---
### Task 2: Replace mobile nav in base.njk
**Files:**
- Modify: `_includes/layouts/base.njk:240-309` (mobile nav `<nav class="mobile-nav">`)
**Step 1: Replace the mobile nav**
Replace the entire `<nav class="mobile-nav" ...>` block (lines 240-309) with:
```nunjucks
<nav class="mobile-nav hidden" id="mobile-nav" x-data="{ blogOpen: false, pagesOpen: false }">
<a href="/">Home</a>
<a href="/about/">About</a>
<a href="/now/">Now</a>
{# Blog section #}
<div class="mobile-nav-section">
<button type="button" class="mobile-nav-toggle" @click="blogOpen = !blogOpen">
Blog
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': blogOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="mobile-nav-submenu" x-show="blogOpen" x-collapse>
<a href="/blog/">All Posts</a>
{% for pt in enabledPostTypes %}
<a href="{{ pt.path }}">{{ pt.label }}</a>
{% endfor %}
</div>
</div>
{# Pages section #}
<div class="mobile-nav-section">
<button type="button" class="mobile-nav-toggle" @click="pagesOpen = !pagesOpen">
Pages
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': pagesOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="mobile-nav-submenu" x-show="pagesOpen" x-collapse>
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">Blogroll</a>{% endif %}
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">Podroll</a>{% endif %}
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">News</a>{% endif %}
<a href="/slashes/">All Pages</a>
</div>
</div>
<a href="/interactions/">Interactions</a>
<a href="/search/">Search</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
@indiekit:auth.window="show = $event.detail.loggedIn">
Dashboard
</a>
{# Mobile theme toggle #}
<button type="button" class="mobile-theme-toggle" aria-label="Toggle dark mode">
<span class="theme-label">Theme</span>
<span class="theme-icons">
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</span>
</button>
</nav>
```
**Key changes from current:**
- Removed: CV link, Digest link, the "/" accordion with 22+ items
- Added: Now direct link, "Pages" accordion (Blogroll, Podroll, News, All Pages)
- Renamed Alpine variable: `slashOpen``pagesOpen`
- Kept: Blog accordion (unchanged), Interactions, Search, Dashboard (auth-only), theme toggle
---
### Task 3: Update footer in base.njk
**Files:**
- Modify: `_includes/layouts/base.njk:339-386` (footer `<footer>` block)
**Step 1: Replace the footer grid**
Replace lines 339-386 (the entire `<footer>` element) with:
```nunjucks
<footer class="border-t border-surface-200 dark:border-surface-700 mt-12 pt-8 pb-6">
<div class="container">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
{# Navigate #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Navigate</h4>
<ul class="space-y-2">
<li><a href="/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Home</a></li>
<li><a href="/about/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">About</a></li>
<li><a href="/cv/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">CV</a></li>
<li x-data="{ show: false }" x-show="show" x-cloak @indiekit:auth.window="show = $event.detail.loggedIn">
<a href="/dashboard" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Dashboard</a>
</li>
</ul>
</div>
{# Content #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Content</h4>
<ul class="space-y-2">
<li><a href="/blog/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Blog</a></li>
{% for pt in enabledPostTypes %}
<li><a href="{{ pt.path }}" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">{{ pt.label }}</a></li>
{% endfor %}
<li><a href="/digest/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Digest</a></li>
</ul>
</div>
{# Connect #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Connect</h4>
<ul class="space-y-2">
{% for social in site.social %}
<li><a href="{{ social.url }}" rel="{{ social.rel }}" target="_blank" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">{{ social.name }}</a></li>
{% endfor %}
</ul>
</div>
{# Meta #}
<div>
<h4 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400 mb-3">Meta</h4>
<ul class="space-y-2">
<li><a href="/feed.xml" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">RSS Feed</a></li>
<li><a href="/feed.json" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">JSON Feed</a></li>
<li><a href="/changelog/" class="text-sm text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Changelog</a></li>
</ul>
</div>
</div>
<p class="text-center text-sm text-surface-500 dark:text-surface-400">Powered by <a href="https://getindiekit.com" class="hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Indiekit</a> + <a href="https://11ty.dev" class="hover:text-surface-900 dark:hover:text-surface-100 hover:underline">Eleventy</a></p>
</div>
</footer>
```
**Key changes from current:**
- Navigate: Removed Changelog (was duplicated in Meta), removed Search (header icon suffices), added Dashboard (auth-only via Alpine.js)
- Content: Removed Interactions, added Digest
- Connect: Unchanged (dynamic social links)
- Meta: Unchanged (RSS, JSON Feed, Changelog)
---
### Task 4: Add "Site Pages" section to slashes.njk
**Files:**
- Modify: `slashes.njk:143-153` (after Activity Feeds section, before inspiration box)
**Step 1: Add the Site Pages section**
Replace lines 143-153 (from `{% endif %}` closing Activity Feeds through the inspiration `<div>`) with:
```nunjucks
{% endif %}
{# Site pages — theme-provided .njk pages not in collections.pages or activity feeds #}
<div class="mb-8">
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-4">Site Pages</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
Theme-provided pages for content aggregation, search, and site info.
</p>
<ul class="post-list">
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/blog/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/blog</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">All posts chronologically</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/cv/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/cv</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Curriculum vitae</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/changelog/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/changelog</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Site changes and updates</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/digest/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/digest</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Content digest</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/featured/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/featured</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Featured posts</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/graph/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/graph</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Content graph visualization</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/interactions/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/interactions</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Social interactions (likes, reposts, replies)</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/readlater/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/readlater</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Read later queue</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/search/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/search</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Full-text search</p>
</li>
<li class="post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/starred/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/starred</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Starred GitHub repositories</p>
</li>
</ul>
</div>
{# Inspiration section #}
<div class="mt-8 p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-2">Want more slash pages?</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm">
Check out <a href="https://slashpages.net" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">slashpages.net</a>
for inspiration on pages like <code>/now</code>, <code>/uses</code>, <code>/colophon</code>, <code>/blogroll</code>, and more.
</p>
</div>
```
**Key additions:** /blog, /cv, /changelog, /digest, /featured, /graph, /interactions, /readlater, /search, /starred — all theme .njk pages that were previously invisible on /slashes/.
---
### Task 5: Verify the build locally
**Step 1: Build Eleventy and check for errors**
Run from the theme directory:
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
npm run build 2>&1 | tail -20
```
Expected: Build completes with zero errors. Template syntax errors would fail the build.
**Step 2: Spot-check the output HTML**
```bash
# Check header nav has "Now" and "Pages" but not "CV" or "Digest"
grep -c 'href="/now/"' _site/index.html
# Expected: at least 1
grep 'nav-dropdown-trigger' _site/index.html | head -4
# Expected: "Blog" and "Pages" triggers, no "/" trigger
# Check footer has Dashboard with Alpine.js auth
grep 'Dashboard' _site/index.html | grep 'indiekit:auth'
# Expected: 1 match in footer
# Check /slashes/ has "Site Pages" section
grep 'Site Pages' _site/slashes/index.html
# Expected: 1 match
```
---
### Task 6: Commit, push, and update submodule
**Step 1: Commit the theme changes**
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git add _includes/layouts/base.njk slashes.njk
git commit -m "feat: redesign navigation - curated header, updated footer, comprehensive /slashes/"
git push origin main
```
**Step 2: Update the submodule in indiekit-cloudron**
```bash
cd /home/rick/code/indiekit-dev/indiekit-cloudron
git submodule update --remote eleventy-site
git add eleventy-site
git commit -m "chore: update eleventy-site submodule (navigation redesign)"
git push origin main
```
**Step 3: Deploy**
```bash
cd /home/rick/code/indiekit-dev/indiekit-cloudron
make prepare
cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
```
---
## Summary of Changes
| Before | After |
|--------|-------|
| "/" dropdown with 22+ items | "Pages" dropdown with 4 curated items |
| CV in header | CV in footer only |
| Digest in header | Digest in footer Content column |
| No "Now" in header | Now as direct link |
| Footer: Changelog in Navigate | Changelog in Meta only (no duplicate) |
| Footer: Search in Navigate | Search removed (header icon suffices) |
| Footer: Interactions in Content | Interactions removed from footer |
| Footer: No Dashboard | Dashboard in Navigate (auth-only) |
| Footer: No Digest | Digest in Content column |
| /slashes/: 2 sections (Pages + Activity) | 3 sections (Pages + Activity + Site Pages) |
| /slashes/: Missing 10+ theme pages | All theme .njk pages listed |
@@ -1,730 +0,0 @@
# Design System Compliance Fix — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix all 368 design system violations found by the comprehensive audit across ~80 Nunjucks template files and CSS.
**Architecture:** Fix CSS-level component classes first (highest leverage — one fix eliminates violations across many templates), then fix templates by violation category, grouped by file similarity. Each task handles one violation category across a batch of related files.
**Tech Stack:** Nunjucks templates, Tailwind CSS, Alpine.js
**Important context:**
- Source of truth: `/home/rick/code/indiekit-dev/indiekit-eleventy-theme/` (this repo is a Git submodule)
- Design system: `.interface-design/system.md`
- No test suite — verification is visual via `cloudron restart` after deployment
- CSS focus-visible base layer already exists in `css/tailwind.css:117-142,498-506` — most "missing focus:ring-2" violations are false positives since the CSS handles focus-visible globally on `button`, `a`, `input`, `textarea`, `select`. Only elements using `focus:outline-none` (which suppresses the global style) actually need inline `focus:ring-2`.
- CSS `time` base rule already sets monospace font-family on all `<time>` elements (`css/tailwind.css:112-115,571-576`). Only `<span>` elements rendering dates via Alpine.js `x-text="formatDate()"` need explicit `font-mono` class.
---
## Task Overview
| Task | Category | Files | Violations Fixed |
|------|----------|-------|-----------------|
| 1 | CSS component class fixes | `css/tailwind.css` | ~30 (cascading to many templates) |
| 2 | Cards missing `shadow-sm` | 25 files | ~69 |
| 3 | Wrong domain colors | 8 files | ~33 |
| 4 | Date `font-mono` on `<span>` elements | 10 files | ~20 |
| 5 | Date `font-mono` on `<time>` elements (class consistency) | 20 files | ~55 |
| 6 | Hover violations | 8 files | ~14 |
| 7 | Border radius violations | 10 files | ~12 |
| 8 | Dark mode violations | 8 files | ~10 |
| 9 | Depth violations (shadow levels) | 6 files | ~14 |
| 10 | Transition violations | 6 files | ~11 |
| 11 | Inline `focus:ring-2` cleanup | 7 files | ~20 |
| 12 | Remaining button/focus violations | 15 files | ~80 |
---
### Task 1: CSS Component Class Fixes (tailwind.css)
**Files:**
- Modify: `css/tailwind.css`
These CSS-level fixes cascade to many templates automatically.
**Step 1: Fix `.widget-title` weight**
Line 398: Change `font-bold` to `font-semibold` per system.md.
```css
/* Before */
.widget-title {
@apply font-bold text-lg mb-4;
}
/* After */
.widget-title {
@apply font-semibold text-lg mb-4;
}
```
**Step 2: Fix `.repo-card` missing `shadow-sm`**
Line 361: Add `shadow-sm`.
```css
/* Before */
.repo-card {
@apply p-4 border border-surface-200 dark:border-surface-700 rounded-lg;
}
/* After */
.repo-card {
@apply p-4 border border-surface-200 dark:border-surface-700 rounded-lg shadow-sm;
}
```
**Step 3: Fix `.fab-menu-item` border radius**
Line 551: Change `rounded-xl` to `rounded-lg`.
```css
/* Before */
.fab-menu-item {
@apply ... rounded-xl bg-surface-50 ... shadow-md ...;
}
/* After */
.fab-menu-item {
@apply ... rounded-lg bg-surface-50 ... shadow-sm ...;
}
```
Also change `shadow-md hover:shadow-lg` to `shadow-sm` (system says cards/menu items = `shadow-sm`).
**Step 4: Fix `.p-category` hover border**
Line 335: Change `hover:border-surface-400 dark:hover:border-surface-500` to `hover:border-accent-400 dark:hover:border-accent-600`.
```css
/* Before */
.p-category {
@apply ... hover:border-surface-400 dark:hover:border-surface-500 transition-colors;
}
/* After */
.p-category {
@apply ... hover:border-accent-400 dark:hover:border-accent-600 transition-colors;
}
```
**Step 5: Fix `.pagination-link` — add `transition-colors` explicitly**
Line 490: The class already has `transition-colors`. Verify it also covers focus states adequately via the base CSS layer. No change needed if base layer covers it.
**Step 6: Remove `.widget` `mb-4`**
Line 394: Remove `mb-4` since widgets are inside `space-y-*` containers which handle spacing. The `mb-4` conflicts with container spacing.
```css
/* Before */
.widget {
@apply p-4 mb-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
}
/* After */
.widget {
@apply p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden;
}
```
**Step 7: Consolidate duplicate focus-visible systems**
Lines 117-142 and 498-506 define TWO competing focus-visible systems. Remove the duplicate at 498-506 (the outline-based one) and keep the ring-based one at 117-142 which is more specific and matches the system.md pattern.
```css
/* DELETE lines 498-506 */
/* Focus states */
@layer base {
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
@apply outline-2 outline-offset-2 outline-accent-500;
}
}
```
**Step 8: Fix `.post-type-dropdown` dark mode**
Lines 974-986: Change `@media (prefers-color-scheme: dark)` to `.dark` class selector (the site uses class-based dark mode).
```css
/* Before */
@media (prefers-color-scheme: dark) {
.post-type-dropdown { ... }
.post-type-dropdown-item { ... }
.post-type-dropdown-item:hover { ... }
}
/* After */
.dark .post-type-dropdown {
background: #1f2937;
border-color: #374151;
}
.dark .post-type-dropdown-item {
color: #d1d5db;
}
.dark .post-type-dropdown-item:hover {
background: #374151;
color: #34d399;
}
```
**Step 9: Fix `.save-later-btn` and `.share-post-btn` dark mode**
Lines 881-932: Add `.dark` variants for these buttons (currently no dark mode support).
```css
/* Add after line 907 */
.dark body[data-indiekit-auth="true"] .save-later-btn {
color: #9ca3af;
}
.dark body[data-indiekit-auth="true"] .save-later-btn:hover {
border-color: #4b5563;
color: #60a5fa;
}
/* Add after line 932 */
.dark body[data-indiekit-auth="true"] .share-post-btn {
color: #9ca3af;
}
.dark body[data-indiekit-auth="true"] .share-post-btn:hover {
border-color: #4b5563;
color: #34d399;
}
```
**Step 10: Commit**
```bash
git add css/tailwind.css
git commit -m "fix(css): fix 10 design system violations in component classes
- .widget-title: font-bold -> font-semibold
- .repo-card: add shadow-sm
- .fab-menu-item: rounded-xl -> rounded-lg, shadow-md -> shadow-sm
- .p-category: hover border surface -> accent
- .widget: remove mb-4 (conflicts with space-y containers)
- Remove duplicate focus-visible system (outline vs ring)
- .post-type-dropdown: prefers-color-scheme -> .dark class
- .save-later-btn/.share-post-btn: add dark mode variants
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 2: Cards Missing `shadow-sm` (25 files)
**Files to modify:**
Group A — Page templates:
- `github.njk` — lines 30, 104, 137, 171, 226 (5 cards)
- `funkwhale.njk` — lines 188, 238 (2 cards)
- `youtube.njk` — lines 37, 176 (2 cards)
- `listening.njk` — lines 273, 327, 399, 460 (4 cards)
- `blogroll.njk` — lines 68, 112 (2 cards)
- `readlater.njk` — lines 83 (1 card)
- `starred.njk` — line 187 (1 card)
- `interactions.njk` — lines 44, 60, 76, 92, 108, 217 (6 cards)
- `changelog.njk` — line 51 (1 card)
- `digest-index.njk` — line 25 (1 card)
Group B — Components/sections:
- `_includes/components/webmentions.njk` — lines 116, 186 (2 cards)
- `_includes/components/comments.njk` — line 78 (1 card)
- `_includes/components/funkwhale-stats-content.njk` — lines 4, 29 (2 cards)
- `_includes/components/fediverse-modal.njk` — line 32 (1 card)
- `_includes/components/post-navigation.njk` — lines 34, 80 (2 cards)
- `_includes/components/sections/cv-education.njk` — line 19 (1 card)
- `_includes/components/sections/cv-skills.njk` — line 18 (1 card)
- `_includes/components/sections/cv-interests.njk` — line 18 (1 card)
- `_includes/components/sections/cv-projects.njk` — line 19 (1 card)
- `_includes/components/sections/cv-projects-work.njk` — line 29 (1 card)
- `_includes/components/sections/cv-projects-personal.njk` — line 29 (1 card)
- `_includes/components/sections/ai-usage.njk` — lines 13, 16 (2 cards)
Group C — Layout templates:
- `_includes/layouts/page.njk` — lines 36, 40, 44, 48, 83 (5 cards)
- `_includes/layouts/home.njk` — lines 82, 113, 122, 131 (4 cards)
**Pattern:** For each card element, find the `border border-surface-200 dark:border-surface-700` class string and add `shadow-sm` after it. If the element has `border` but no surface tokens, add the full pattern: `border border-surface-200 dark:border-surface-700 shadow-sm`.
**Special cases:**
- `interactions.njk` cards (lines 44-108): These cards have NO border at all — add `border border-surface-200 dark:border-surface-700 shadow-sm`
- `changelog.njk:51`: Also add `bg-surface-50 dark:bg-surface-800` (missing background)
- `page.njk:36,40,44,48`: Change `bg-white` to `bg-surface-50` (token compliance)
- `webmentions.njk:116,186` and `comments.njk:78`: Change `bg-surface-100` to `bg-surface-50` (system says card bg = surface-50)
- `post-navigation.njk:34,80`: Change `bg-surface-100` to `bg-surface-50`
**Step 1: Fix Group A files** (page templates — add `shadow-sm` to each card)
**Step 2: Fix Group B files** (components/sections)
**Step 3: Fix Group C files** (layouts)
**Step 4: Commit**
```bash
git add *.njk _includes/
git commit -m "fix(cards): add shadow-sm to all card elements across 25 files
Design system requires shadow-sm + border on all cards.
Also fixes bg-white -> bg-surface-50 and bg-surface-100 -> bg-surface-50
where card backgrounds used wrong tokens.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 3: Wrong Domain Colors (8 files)
**Domain color reference:**
- Writing (amber): articles, notes, bookmarks, photos, blog, digest, categories, news
- Social (rose): likes, replies, reposts, interactions
- Code (emerald): github, starred
- Music (purple): funkwhale, listening
- Video (red): youtube
- Reading (orange): blogroll, podroll, readlater
**Files to modify:**
**3a. `starred.njk` — 12 violations (accent -> emerald)**
Every `accent-*` reference on this page should be `emerald-*`:
- Line 11: `text-accent-600 dark:text-accent-400` -> `text-emerald-600 dark:text-emerald-400`
- Lines 61-93: Tab button active states: `border-accent-600 text-accent-700 dark:text-accent-400` -> `border-emerald-600 text-emerald-700 dark:text-emerald-400`
- Line 111: `focus:ring-accent-500` -> `focus:ring-emerald-500`
- Line 126: Same
- Line 141: Same
- Lines 155: `bg-accent-600` -> `bg-emerald-600`
- Line 165: `text-accent-600 focus:ring-accent-500` -> `text-emerald-600 focus:ring-emerald-500`
- Lines 172-174: `text-accent-600 dark:text-accent-400` -> `text-emerald-600 dark:text-emerald-400`
- Line 193: `hover:text-surface-600` -> `hover:text-emerald-600 dark:hover:text-emerald-400`
- Lines 255-259: `bg-accent-600 hover:bg-accent-700` -> `bg-emerald-600 hover:bg-emerald-700`
**3b. `photos.njk` — 4 violations (purple -> amber)**
- Line 17: `text-purple-600 dark:text-purple-400` -> `text-amber-600 dark:text-amber-400` (sparkline)
- Line 28: `border-l-purple-400 dark:border-l-purple-500` -> `border-l-amber-400 dark:border-l-amber-500`
- Line 62: `text-purple-600 dark:text-purple-400` -> `text-amber-600 dark:text-amber-400`
**3c. `reposts.njk` — 1 violation (emerald sparkline -> rose)**
- Line 17: `text-emerald-600 dark:text-emerald-400` -> `text-rose-600 dark:text-rose-400`
**3d. `blog.njk` — 6 violations (red/green/sky -> rose for social types)**
- Line 42: `border-l-red-400 dark:border-l-red-500` -> `border-l-rose-400 dark:border-l-rose-500` (like card)
- Line 46: `border-l-green-400 dark:border-l-green-500` -> `border-l-rose-400 dark:border-l-rose-500` (repost card)
- Line 48: `border-l-sky-400 dark:border-l-sky-500` -> `border-l-rose-400 dark:border-l-rose-500` (reply card)
- Lines 60-61: `text-red-500` / `text-red-600 dark:text-red-400` -> `text-rose-500` / `text-rose-600 dark:text-rose-400`
- Lines 143-144: `text-green-500` / `text-green-600 dark:text-green-400` -> `text-rose-500` / `text-rose-600 dark:text-rose-400`
- Lines 182-184: `text-sky-500` / `text-sky-600 dark:text-sky-400` -> `text-rose-500` / `text-rose-600 dark:text-rose-400`
**3e. `featured.njk` — 3 violations (red/green/sky -> rose)**
- Line 56: `text-red-600 dark:text-red-400` / `text-red-500` -> `text-rose-600 dark:text-rose-400` / `text-rose-500`
- Line 85: `text-green-600 dark:text-green-400` -> `text-rose-600 dark:text-rose-400`
- Line 98: `text-sky-600 dark:text-sky-400` -> `text-rose-600 dark:text-rose-400`
**3f. `digest.njk` — 2 violations (accent -> amber on nav links)**
- Lines 154, 162: `text-accent-600 dark:text-accent-400` -> `text-amber-600 dark:text-amber-400`
**3g. `digest-index.njk` — 1 violation (orange -> amber)**
- Line 19: `text-orange-600 dark:text-orange-400` -> `text-amber-600 dark:text-amber-400`
**3h. `_includes/components/widgets/search.njk` — 2 violations (primary -> accent)**
- Line 4: `focus:ring-primary-500` -> `focus:ring-accent-500`
- Line 5: `bg-primary-600 hover:bg-primary-700` -> `bg-accent-600 hover:bg-accent-700`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add starred.njk photos.njk reposts.njk blog.njk featured.njk digest.njk digest-index.njk _includes/components/widgets/search.njk
git commit -m "fix(domain-colors): correct domain color assignments across 8 files
- starred.njk: accent -> emerald (Code domain)
- photos.njk: purple -> amber (Writing domain)
- reposts.njk: emerald -> rose (Social domain)
- blog.njk: red/green/sky -> rose (Social domain unified)
- featured.njk: red/green/sky -> rose (Social domain unified)
- digest.njk: accent -> amber (Writing domain)
- digest-index.njk: orange -> amber (Writing domain)
- search widget: primary -> accent (stale token)
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 4: Date `font-mono` on `<span>` Elements (Alpine.js renders)
These are dates rendered by Alpine.js `x-text="formatDate()"` into `<span>` elements (not `<time>`). The CSS base layer only covers `<time>` elements, so these `<span>` elements need explicit `font-mono`.
**Files to modify:**
- `starred.njk:27``<span x-text="formatDate(lastSync)">` — add `font-mono`
- `starred.njk:236``<span x-text="'Starred ' + formatDate(...)">` — add `font-mono`
- `changelog.njk:67``<span class="text-xs text-surface-500" x-text="formatDate(commit.date)">` — add `font-mono`
- `blogroll.njk:18``<span x-text="formatDate(status?.lastSync, 'full')">` — add `font-mono`
- `_includes/components/widgets/github-repos.njk:61``<span x-text="formatDate(commit.date)">` — add `font-mono`
- `_includes/components/widgets/github-repos.njk:86``<span x-text="formatDate(repo.updated_at)">` — add `font-mono`
- `_includes/components/widgets/github-repos.njk:142``<span x-text="formatDate(item.date)">` — add `font-mono`
- `interactions.njk:264``<time ... x-text="formatDate(...)">` — add `font-mono text-sm`
- `readlater.njk:99``<time ... x-text="formatDate(item.savedAt)">` — add `font-mono text-sm`
- `blogroll.njk:142``<time ... x-text="formatDate(item.published)">` — add `font-mono text-sm`
- `_includes/components/comments.njk:94``<time ... x-text="new Date(...)">` — add `font-mono text-sm`
**Step 1: Add `font-mono` class to each `<span>` and `font-mono text-sm` to each `<time>` listed above**
**Step 2: Commit**
```bash
git add starred.njk changelog.njk blogroll.njk interactions.njk readlater.njk _includes/components/widgets/github-repos.njk _includes/components/comments.njk
git commit -m "fix(dates): add font-mono to Alpine.js-rendered date spans
CSS base layer covers <time> elements automatically, but dates
rendered via x-text into <span> elements need explicit font-mono.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 5: Date `font-mono` on `<time>` Elements (class consistency)
The CSS base layer sets `font-family: monospace` on all `<time>` elements globally, so these render correctly already. However, the design system convention is to also add `font-mono text-sm` as Tailwind classes for consistency and to ensure `text-sm` sizing. This task adds the classes to all `<time>` elements that are missing them.
**Files to modify:**
Group A — Post collection pages:
- `articles.njk:37-39``<time class="dt-published">` add `font-mono text-sm`
- `notes.njk:31-33``<time class="dt-published text-sm ... font-medium">` add `font-mono`
- `bookmarks.njk:39-41` — same pattern
- `photos.njk:30-32` — same
- `likes.njk:37-39` — same
- `replies.njk:42-44` — same
- `reposts.njk:42-44` — same
- `blog.njk:67,106,150,189,227,271,298` — 7 `<time>` elements, add `font-mono`
- `digest.njk:22,54,71,84,107,121,130` — 7 `<time>` elements
- `digest-index.njk:31` — 2 `<time>` elements
- `featured.njk:58,70,87,100,116,130` — 6 `<time>` elements
- `categories.njk:63-65` — 1 `<time>` element
Group B — Layouts:
- `_includes/layouts/page.njk:20``<time class="dt-updated">` add `font-mono text-sm`
- `_includes/layouts/post.njk:23-25``<time class="dt-published">` add `font-mono text-sm`
- `_includes/layouts/home.njk:92-94``<time>` add `font-mono`
Group C — Sections/components:
- `_includes/components/sections/recent-posts.njk:55,83,116,144,173,210,224` — 7 `<time>` elements
- `_includes/components/sections/featured-posts.njk:58,86,119,148,176,213,227` — 7 `<time>` elements
- `_includes/components/webmentions.njk:138,168` — 2 `<time>` elements
- `_includes/components/post-navigation.njk:46,92` — 2 `<time>` elements (also change `text-xs` to `text-sm`)
- `_includes/components/sections/cv-experience.njk:27` — date text, add `font-mono`
- `_includes/components/sections/cv-education.njk:43,47,72,76` — date text, add `font-mono`
- `_includes/components/sections/cv-projects.njk:55,82` — date text, add `font-mono`
- `_includes/components/sections/cv-projects-work.njk:65,92` — date text
- `_includes/components/sections/cv-projects-personal.njk:65,92` — date text
- `_includes/components/cv-builder.njk:163``<time>` add `font-mono text-sm`
- `cv.njk:130``<time>` add `font-mono text-sm`
Group D — Widgets:
- `_includes/components/widgets/social-activity.njk:46,76` — 2 `<time>` elements
- `_includes/components/widgets/recent-posts.njk:25,40,55,70,81` — 5 `<time>` elements
- `_includes/components/widgets/recent-posts-blog.njk:23,36,49,62,72` — 5 `<time>` elements
- `github.njk:114,150` — 2 `<time>` elements
**Pattern for each fix:**
For `<time class="dt-published">`:
```html
<!-- Before -->
<time class="dt-published">
<!-- After -->
<time class="dt-published font-mono text-sm">
```
For `<time class="dt-published text-sm ... font-medium">` (already has text-sm):
```html
<!-- Before -->
<time class="dt-published text-sm text-surface-500 dark:text-surface-400 font-medium">
<!-- After -->
<time class="dt-published font-mono text-sm text-surface-500 dark:text-surface-400 font-medium">
```
For CV date text in `<span>` or `<p>`:
```html
<!-- Before -->
<span class="text-xs text-surface-500">Jan 2020 - Present</span>
<!-- After -->
<span class="text-xs text-surface-500 font-mono">Jan 2020 - Present</span>
```
**Step 1: Fix Group A** (collection pages)
**Step 2: Fix Group B** (layouts)
**Step 3: Fix Group C** (sections/components)
**Step 4: Fix Group D** (widgets)
**Step 5: Commit**
```bash
git add *.njk _includes/
git commit -m "fix(dates): add font-mono text-sm to all <time> elements
System convention: every rendered date gets font-mono class.
CSS base layer handles font-family, but classes ensure consistency
and proper text-sm sizing across all templates.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 6: Hover Violations (8 files)
**Files to modify:**
- `github.njk:226``hover:border-surface-400 dark:hover:border-surface-600` -> `hover:border-emerald-400 dark:hover:border-emerald-600`
- `starred.njk:187``hover:border-surface-400 dark:hover:border-surface-500` -> `hover:border-emerald-400 dark:hover:border-emerald-600`
- `_includes/components/sections/featured-posts.njk:45``hover:border-surface-400 dark:hover:border-surface-500` -> `hover:border-accent-400 dark:hover:border-accent-600`
- `_includes/components/widgets/toc.njk:10` — add `hover:underline` to ToC links
- `_includes/components/widgets/subscribe.njk:6,12` — add `hover:underline` to RSS/JSON feed links
- `_includes/components/widgets/categories.njk:8` — add `hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors` to category links
- `_includes/components/widgets/blogroll.njk:30` — add `hover:underline` to blog list links
- `news.njk:128,193``hover:border-accent-400` -> `hover:border-amber-400` (Writing domain)
- `digest-index.njk:25``hover:border-accent-300` -> `hover:border-amber-400 dark:hover:border-amber-600`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add github.njk starred.njk news.njk digest-index.njk _includes/
git commit -m "fix(hover): correct card hover borders to domain colors
Replace hover:border-surface-400 with domain-colored borders.
Add hover:underline to text links missing it.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 7: Border Radius Violations (10 files)
**Files to modify:**
- `_includes/layouts/page.njk:92,97,102` — AI level badges: `rounded` -> `rounded-full`
- `_includes/layouts/page.njk:33` — AI stats container: `rounded-xl` -> `rounded-lg`
- `_includes/layouts/home.njk:96` — post type badge: `rounded` -> `rounded-full`
- `blogroll.njk:68,112` — standard cards: `rounded-xl` -> `rounded-lg`
- `readlater.njk:83` — standard cards: `rounded-xl` -> `rounded-lg`
- `news.njk:193,246` — standard cards: `rounded-xl` -> `rounded-lg`
- `podroll.njk:66` — standard cards: `rounded-xl` -> `rounded-lg`
- `_includes/components/sections/cv-projects.njk:94` — tech badges: `rounded` -> `rounded-full`
- `_includes/components/sections/cv-projects-work.njk:104` — same
- `_includes/components/sections/cv-projects-personal.njk:104` — same
- `_includes/components/sections/ai-usage.njk:13` — stats panel: `rounded-xl` -> `rounded-lg`
- `_includes/components/sections/funkwhale-stats-content.njk:4` — stat cards: `rounded-xl` -> `rounded-lg`
- `_includes/components/h-card.njk:90` — category span: `rounded` -> `rounded-full`
- `_includes/components/sections/recent-posts.njk:214,229` — post type badges: `rounded` -> `rounded-full`
- `_includes/components/sections/featured-posts.njk:217,232` — same
- `funkwhale.njk:147` — trends chart container: `rounded-xl` -> `rounded-lg`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add *.njk _includes/
git commit -m "fix(radius): correct border-radius to match system
- rounded -> rounded-full for badges/pills
- rounded-xl -> rounded-lg for standard cards (xl reserved for hero/featured)
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 8: Dark Mode Violations (8 files)
**Files to modify:**
- `_includes/layouts/page.njk:36,40,44,48``bg-white` -> `bg-surface-50` (with existing `dark:bg-surface-800`)
- `_includes/components/sections/ai-usage.njk:16,20,24,28``bg-white` -> `bg-surface-50`
- `_includes/components/sections/ai-usage.njk:13``dark:bg-surface-800/50` -> `dark:bg-surface-800` (remove opacity)
- `_includes/components/comments.njk:41` — add `dark:text-surface-100`, fix `dark:border-surface-600` -> `dark:border-surface-700`
- `_includes/components/comments.njk:59` — same fix for textarea
- `_includes/components/fediverse-modal.njk:32``dark:bg-surface-700` -> `dark:bg-surface-800`
- `_includes/components/widgets/post-categories.njk:8,13``bg-accent-900` -> `bg-accent-900/30` (badge pattern)
- `changelog.njk:51` — add `bg-surface-50 dark:bg-surface-800`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add *.njk _includes/
git commit -m "fix(dark-mode): correct dark mode token pairs
- bg-white -> bg-surface-50 (token compliance)
- Add missing dark:text-surface-100 on inputs
- Fix dark:border-surface-600 -> dark:border-surface-700
- Fix badge bg opacity (dark:bg-accent-900/30)
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 9: Depth Violations (6 files)
**Files to modify:**
Avatar/album art images need `shadow-lg`:
- `_includes/components/h-card.njk:32` — avatar img: add `shadow-lg`
- `_includes/components/widgets/author-card-compact.njk:12` — avatar: add `shadow-lg`
- `_includes/components/widgets/funkwhale.njk:33` — now-playing cover: add `shadow-lg`
- `_includes/components/widgets/funkwhale.njk:55` — track thumbnail: add `shadow-lg`
- `_includes/components/widgets/funkwhale.njk:83` — scrobble thumbnail: add `shadow-lg`
- `_includes/components/widgets/recent-comments.njk:11` — commenter avatar: add `shadow-lg`
- `_includes/components/sections/funkwhale-stats-content.njk:47` — album cover: add `shadow-lg`
Stat number `font-mono`:
- `_includes/components/sections/funkwhale-stats-content.njk:5` — stat numbers: add `font-mono`
- `_includes/components/sections/ai-usage.njk:17` — stat numbers: add `font-mono`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add _includes/
git commit -m "fix(depth): add shadow-lg to avatars/album art, font-mono to stat numbers
System: avatars/album art get shadow-lg for presence.
Stat numbers get font-mono like dates/timestamps.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 10: Transition Violations (6 files)
**Files to modify:**
- `_includes/components/widgets/author-card-compact.njk:16` — author name link: add `transition-colors`
- `_includes/components/widgets/categories.njk:8` — category links: add `transition-colors`
- `_includes/components/widgets/post-navigation.njk:18,44` — nav links: add `transition-colors`
- `_includes/components/widgets/webmentions.njk:55,69,83,94,105,114` — links: add `transition-colors`
- `podroll.njk:119-128` — "View Episode" link: add `transition-colors`
- `podroll.njk:131-137` — "Subscribe to feed" link: add `transition-colors`
**Step 1: Fix all files above**
**Step 2: Commit**
```bash
git add _includes/ podroll.njk
git commit -m "fix(transitions): add transition-colors to interactive elements
System requires transition-colors on all elements with hover states.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 11: Inline `focus:ring-2` Cleanup (7 files)
Some templates use `focus:outline-none focus:ring-2 focus:ring-accent-500` which **suppresses** the CSS base layer focus-visible styles and replaces them with a focus (not focus-visible) ring. This means mouse clicks also trigger the ring. The system uses `focus-visible` in CSS. These inline `focus:outline-none` should be removed so the base CSS handles focus consistently.
**Files to modify:**
- `news.njk:15,48,59,70,332,342` — remove `focus:outline-none focus:ring-2 focus:ring-accent-500` (CSS base layer handles it)
- `podroll.njk:19,50,175` — remove inline focus classes
- `starred.njk:111,126,141,165` — remove inline focus classes (but domain color is handled in Task 3)
- `readlater.njk:44,61` — remove inline focus classes
- `_includes/components/webmentions.njk` line with `focus:ring-2` — remove
- `_includes/components/fediverse-modal.njk:63` — remove `focus:ring-2 focus:ring-[#a730b8]`
- `_includes/components/widgets/search.njk:4` — remove inline focus (after Task 3 fixes primary -> accent)
**Step 1: In each file, remove `focus:outline-none focus:ring-2 focus:ring-*-500` class groups**
The CSS base layer at `tailwind.css:117-142` provides `ring-2 ring-amber-500/70` on `:focus-visible` for all buttons, links, and inputs. This is the correct, centralized approach.
**Step 2: Commit**
```bash
git add *.njk _includes/
git commit -m "fix(focus): remove inline focus:ring-2 classes, rely on CSS base layer
The CSS base layer provides focus-visible rings on all interactive
elements. Inline focus:outline-none suppresses this and replaces
it with focus (not focus-visible) behavior. Removing these lets
the centralized system handle focus states consistently.
Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596"
```
---
### Task 12: Remaining Button/Focus Violations
After Task 11 removes conflicting inline focus classes, the CSS base layer handles focus-visible for all `button`, `a`, `input`, `textarea`, `select` elements. The remaining "missing focus:ring-2" violations from the audit are now **resolved by the CSS base layer** — no further template changes needed.
**Verification step:** After all previous tasks are committed, do a grep to confirm no `focus:outline-none` remains that would suppress the base styles:
```bash
grep -rn "focus:outline-none" --include="*.njk" /home/rick/code/indiekit-dev/indiekit-eleventy-theme/ | grep -v node_modules
```
If any remain, remove them.
**Commit:** No commit needed if grep finds nothing.
---
## Deployment
After all tasks are complete:
```bash
cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
git push origin main
cd /home/rick/code/indiekit-dev/indiekit-cloudron
git submodule update --remote eleventy-site
git add eleventy-site
git commit -m "chore: update eleventy-site submodule (design system compliance)"
git push origin main
make prepare
cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
```
## Visual Verification
After deployment, verify key pages with `playwright-cli`:
- `/` (home) — cards have shadows, dates are mono
- `/blog/` — social types use rose domain color
- `/github/` — emerald domain color throughout
- `/github/starred/` — emerald, not accent
- `/listening/` — cards have shadows
- `/podroll/` — rounded-lg, not rounded-xl
- Dark mode toggle — check all pages render correctly
+235 -20
View File
@@ -3,7 +3,7 @@ import pluginRss from "@11ty/eleventy-plugin-rss";
import pluginMermaid from "@kevingimbel/eleventy-plugin-mermaid";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import sitemap from "@quasibit/eleventy-plugin-sitemap";
import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import markdownItFootnote from "markdown-it-footnote";
@@ -16,7 +16,7 @@ import matter from "gray-matter";
import { createHash, createHmac } from "crypto";
import { createRequire } from "module";
import { execFileSync } from "child_process";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync, appendFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
@@ -49,6 +49,17 @@ const nestedSlugify = (str) => {
.join("/");
};
// Memory profiler — logs RSS + V8 heap at key build phases
function logMemory(phase) {
const mem = process.memoryUsage();
const rss = (mem.rss / 1024 / 1024).toFixed(0);
const heapUsed = (mem.heapUsed / 1024 / 1024).toFixed(0);
const heapTotal = (mem.heapTotal / 1024 / 1024).toFixed(0);
const external = (mem.external / 1024 / 1024).toFixed(0);
const arrayBuffers = (mem.arrayBuffers / 1024 / 1024).toFixed(0);
console.log(`[mem] ${phase}: RSS=${rss}MB heap=${heapUsed}/${heapTotal}MB external=${external}MB buffers=${arrayBuffers}MB`);
}
export default function (eleventyConfig) {
// Don't use .gitignore for determining what to process
// (content/ is in .gitignore because it's a symlink, but we need to process it)
@@ -374,13 +385,23 @@ export default function (eleventyConfig) {
},
});
// Sitemap generation
eleventyConfig.addPlugin(sitemap, {
sitemap: {
hostname: siteUrl,
},
// Performance: skip PostHTML parsing for pages without <img> tags.
// Both registered PostHTML plugins (remote-image-marker, eleventy-img) only
// target <img> elements — no point parsing+serializing HTML without them.
// Overrides the default @11ty/eleventy/html-transformer transform (same name
// overwrites via addTransform) with a pre-check that avoids the full PostHTML
// parse/serialize cycle (~3ms/page) for image-free pages.
eleventyConfig.addTransform("@11ty/eleventy/html-transformer", async function(content) {
if (typeof this.outputPath === "string" && this.outputPath.endsWith(".html") && !content.includes("<img")) {
// Safety: if URL transform callbacks exist (they modify <a>, <link>, etc.)
// we must still run the full pipeline even without images.
const hasUrlCallbacks = eleventyConfig.htmlTransformer.getCallbacks("html", this).length > 0;
if (!hasUrlCallbacks) return content;
}
return eleventyConfig.htmlTransformer.transformContent(this.outputPath, content, this);
});
// Wrap <table> elements in <table-saw> for responsive tables
eleventyConfig.addTransform("table-saw-wrap", function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
@@ -407,6 +428,33 @@ export default function (eleventyConfig) {
// page.url is unreliable during parallel rendering, but outputPath IS correct
// since files are written to the correct location. Derives the OG slug from
// outputPath and replaces placeholders emitted by base.njk.
// Clear eleventy-img in-memory cache between builds to prevent native memory leak.
// The MemoryCache singleton holds Sharp ArrayBuffers (~200KB-1MB each) that never get
// freed in watch mode. After 20-30 incremental rebuilds, external memory grows from
// ~170MB to 500MB+, eventually causing OOM. Disk cache handles persistence.
const { memCache: imgMemCache } = esmRequire("@11ty/eleventy-img/src/caches.js");
eleventyConfig.on("eleventy.before", () => {
const size = imgMemCache.size();
if (size > 0) {
imgMemCache.cache = {};
console.log(`[eleventy-img] Cleared in-memory cache (${size} entries)`);
}
});
// Cache: directory listing built once per build instead of 3,426 existsSync calls
let _ogFileSet = null;
eleventyConfig.on("eleventy.before", () => { _ogFileSet = null; });
function hasOgImage(ogSlug) {
if (!_ogFileSet) {
const ogDir = resolve(__dirname, ".cache", "og");
try {
_ogFileSet = new Set(readdirSync(ogDir));
} catch {
_ogFileSet = new Set();
}
}
return _ogFileSet.has(`${ogSlug}.png`);
}
eleventyConfig.addTransform("og-fix", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) return content;
@@ -698,7 +746,9 @@ export default function (eleventyConfig) {
key: "children",
});
// Date formatting filter — memoized (same dates repeat across pages in sidebars/pagination)
// Date formatting filter
// Memoized: same dates repeat across pages (sidebars, pagination, feeds)
// 16,935 calls → unique dates are ~2,350 (one per post)
const _dateDisplayCache = new Map();
eleventyConfig.on("eleventy.before", () => { _dateDisplayCache.clear(); });
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
@@ -837,6 +887,25 @@ export default function (eleventyConfig) {
return [...fw, ...lfm].sort((a, b) => b._ts - a._ts);
});
// Exclude post types from a collection by detecting type from frontmatter properties
// Usage: collections.posts | excludePostTypes(["reply", "like"])
// Supported types: reply, like, bookmark, repost, photo, article, note
eleventyConfig.addFilter("excludePostTypes", (posts, excludeTypes) => {
if (!Array.isArray(posts) || !Array.isArray(excludeTypes) || !excludeTypes.length) return posts;
return posts.filter((post) => {
const d = post.data || {};
let type;
if (d.inReplyTo || d.in_reply_to) type = "reply";
else if (d.likeOf || d.like_of) type = "like";
else if (d.bookmarkOf || d.bookmark_of) type = "bookmark";
else if (d.repostOf || d.repost_of) type = "repost";
else if (d.photo && d.photo.length) type = "photo";
else if (d.title) type = "article";
else type = "note";
return !excludeTypes.includes(type);
});
});
// Slugify filter
eleventyConfig.addFilter("slugify", (str) => {
if (!str) return "";
@@ -907,20 +976,25 @@ export default function (eleventyConfig) {
return url.endsWith("/") ? url.slice(0, -1) : url;
});
// Hash filter for cache busting — memoized (same paths repeat across every page render)
// Hash filter for cache busting - generates MD5 hash of file content
// Cache: same 16 static files are hashed once per build instead of once per page
// (16 files × 3,426 pages = 55,332 readFileSync calls without cache)
const _hashCache = new Map();
eleventyConfig.on("eleventy.before", () => { _hashCache.clear(); });
eleventyConfig.addFilter("hash", (filePath) => {
if (_hashCache.has(filePath)) return _hashCache.get(filePath);
const cached = _hashCache.get(filePath);
if (cached) return cached;
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
const result = createHash("md5").update(readFileSync(fullPath)).digest("hex").slice(0, 8);
_hashCache.set(filePath, result);
return result;
const content = readFileSync(fullPath);
const hash = createHash("md5").update(content).digest("hex").slice(0, 8);
_hashCache.set(filePath, hash);
return hash;
} catch {
return Date.now().toString(36);
}
});
// Clear hash cache on rebuild so changed files get new hashes
eleventyConfig.on("eleventy.before", () => { _hashCache.clear(); });
// Derive OG slug from page.url (reliable) instead of page.fileSlug
// (which suffers from Nunjucks race conditions in Eleventy 3.x parallel rendering).
@@ -962,8 +1036,16 @@ export default function (eleventyConfig) {
eleventyConfig.addFilter("timestamp", () => Date.now());
// Date filter (for sidebar dates)
// Memoized: same date+format combos repeat across pages
// 33,025 calls on initial build → unique combos ~2,350
const _dateFilterCache = new Map();
eleventyConfig.on("eleventy.before", () => { _dateFilterCache.clear(); });
eleventyConfig.addFilter("date", (dateObj, format) => {
if (!dateObj) return "";
const dateKey = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const key = `${dateKey}|${format}`;
const cached = _dateFilterCache.get(key);
if (cached !== undefined) return cached;
const date = new Date(dateObj);
const options = {};
@@ -971,7 +1053,9 @@ export default function (eleventyConfig) {
if (format.includes("d")) options.day = "numeric";
if (format.includes("yyyy")) options.year = "numeric";
return date.toLocaleDateString("en-US", options);
const result = date.toLocaleDateString("en-US", options);
_dateFilterCache.set(key, result);
return result;
});
// Webmention filters - with legacy URL support
@@ -1140,6 +1224,8 @@ export default function (eleventyConfig) {
});
// Filter AI-involved posts (aiTextLevel > "0")
// Memoized: same collections.posts input produces same output — compute once per build
// (694 calls × 2,350 posts = 1.6M iterations without cache)
const getAiMetadata = (data = {}) => {
const aiMeta = (data && typeof data.ai === "object" && !Array.isArray(data.ai))
? data.ai
@@ -1171,17 +1257,24 @@ export default function (eleventyConfig) {
return { textLevel, codeLevel, tools, description };
};
let _aiPostsCache = null;
let _aiStatsCache = null;
eleventyConfig.on("eleventy.before", () => { _aiPostsCache = null; _aiStatsCache = null; });
eleventyConfig.addFilter("aiPosts", (posts) => {
if (!Array.isArray(posts)) return [];
return posts.filter((post) => {
if (_aiPostsCache) return _aiPostsCache;
_aiPostsCache = posts.filter((post) => {
const { textLevel: level } = getAiMetadata(post.data || {});
return level !== "0" && level !== 0;
});
return _aiPostsCache;
});
// AI stats — returns { total, aiCount, percentage, byLevel }
eleventyConfig.addFilter("aiStats", (posts) => {
if (!Array.isArray(posts)) return { total: 0, aiCount: 0, percentage: 0, byLevel: {} };
if (_aiStatsCache) return _aiStatsCache;
const total = posts.length;
const byLevel = { 0: 0, 1: 0, 2: 0, 3: 0 };
for (const post of posts) {
@@ -1190,12 +1283,13 @@ export default function (eleventyConfig) {
byLevel[level] = (byLevel[level] || 0) + 1;
}
const aiCount = total - byLevel[0];
return {
_aiStatsCache = {
total,
aiCount,
percentage: total > 0 ? ((aiCount / total) * 100).toFixed(1) : "0",
byLevel,
};
return _aiStatsCache;
});
// Helper: exclude drafts from collections
@@ -1393,6 +1487,22 @@ export default function (eleventyConfig) {
.slice(0, 20);
});
// Recently edited posts (updated !== published) — for /updated.xml
// Note: getFilteredByGlob reuses Eleventy's cached template parse, no extra I/O
eleventyConfig.addCollection("recentlyUpdated", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
if (!item.data.updated) return false;
const published = new Date(item.date).getTime();
const updated = new Date(item.data.updated).getTime();
return updated > published;
})
.sort((a, b) => new Date(b.data.updated) - new Date(a.data.updated))
.slice(0, 20);
});
// Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) {
const categoryMap = new Map(); // nestedSlug -> display name (first seen)
@@ -1591,13 +1701,17 @@ export default function (eleventyConfig) {
// Exit code 2 = batch complete, more work remains → re-spawn.
// Manifest caching makes incremental builds fast (only new posts get generated).
eleventyConfig.on("eleventy.before", () => {
logMemory("before-build (OG start)");
console.time("[og] image generation");
const contentDir = resolve(__dirname, "content");
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
const BATCH_SIZE = 100;
let totalGenerated = 0;
let batch = 0;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
batch++;
try {
execFileSync(process.execPath, [
"--max-old-space-size=512",
@@ -1616,6 +1730,7 @@ export default function (eleventyConfig) {
} catch (err) {
if (err.status === 2) {
// Exit code 2 = batch complete, more images remain
totalGenerated += BATCH_SIZE;
continue;
}
throw err;
@@ -1676,6 +1791,8 @@ export default function (eleventyConfig) {
}
};
walk(contentDir);
// Free parsed markdown content before starting network-heavy prefetch
if (typeof global.gc === "function") global.gc();
if (urls.size === 0) {
console.timeEnd("[unfurl] prefetch");
@@ -1700,9 +1817,6 @@ export default function (eleventyConfig) {
return;
}
// Free parsed markdown content before starting network-heavy prefetch
if (typeof global.gc === "function") global.gc();
const urlArray = newUrls;
const UNFURL_BATCH = 50;
const totalBatches = Math.ceil(urlArray.length / UNFURL_BATCH);
@@ -1735,6 +1849,7 @@ export default function (eleventyConfig) {
// so we cannot use the incremental flag to guard pagefind. Use a one-shot flag instead.
let pagefindDone = false;
eleventyConfig.on("eleventy.after", async ({ dir, directories, runMode, incremental }) => {
logMemory("after-build (pre-hooks)");
// Markdown for Agents — generate index.md alongside index.html for articles
const mdEnabled = (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true";
if (mdEnabled && !incremental) {
@@ -1804,6 +1919,56 @@ export default function (eleventyConfig) {
}
}
// Sitemap generation — scan output HTML files, exclude URL patterns
// Runs on every build (including incremental) so new posts appear immediately
{
const sitemapOutputDir = directories?.output || dir.output;
const excludePatterns = [
/^\/replies\//,
/\/feed\.(xml|json)$/,
/^\/categories\//,
/^\/digest/,
/^\/webmention-debug\//,
/^\/404\.html$/,
/^\/dashboard/,
/^\/homepage/,
/^\/search\//,
/^\/graph\//,
/^\/sitemap\.xml$/,
/^\/\.interface-design\//,
];
try {
const walkHtml = (base, prefix = "") => {
const entries = [];
for (const entry of readdirSync(resolve(base, prefix), { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
entries.push(...walkHtml(base, rel));
} else if (entry.name === "index.html") {
const urlPath = prefix ? `/${prefix}/` : "/";
entries.push(urlPath);
}
}
return entries;
};
const allUrls = walkHtml(sitemapOutputDir)
.filter((url) => !excludePatterns.some((p) => p.test(url)))
.sort();
const xmlLines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
];
for (const url of allUrls) {
xmlLines.push(` <url><loc>${siteUrl}${url}</loc></url>`);
}
xmlLines.push("</urlset>");
writeFileSync(resolve(sitemapOutputDir, "sitemap.xml"), xmlLines.join("\n"));
console.log(`[sitemap] Generated sitemap.xml with ${allUrls.length} URLs`);
} catch (err) {
console.error("[sitemap] Generation failed:", err.message);
}
}
// Pagefind indexing — run exactly once per process lifetime
if (!pagefindDone) {
pagefindDone = true;
@@ -1886,6 +2051,56 @@ export default function (eleventyConfig) {
}
}
// Signal readiness to Indiekit plugins — build is complete, system is stable.
// Plugins poll for this file via @rmdes/indiekit-startup-gate before starting
// heavy background tasks (feed polling, AP federation, sync jobs).
// Only fires once — subsequent incremental builds skip this.
const readyPath = "/app/data/.indiekit-ready";
if (!existsSync(readyPath)) {
try {
writeFileSync(readyPath, "");
console.log("[startup-gate] Readiness signal created — plugins will start deferred tasks");
} catch { /* not running in Cloudron container — skip */ }
}
// Force garbage collection after post-build work completes.
// V8 doesn't return freed heap pages to the OS without GC pressure.
// In watch mode the watcher sits idle after its initial full build,
// so without this, ~2 GB of build-time allocations stay resident.
// Requires --expose-gc in NODE_OPTIONS (set in start.sh for the watcher).
if (typeof global.gc === "function") {
const before = process.memoryUsage();
global.gc();
const after = process.memoryUsage();
const freed = ((before.heapUsed - after.heapUsed) / 1024 / 1024).toFixed(0);
console.log(`[gc] Post-build GC freed ${freed} MB (heap: ${(after.heapUsed / 1024 / 1024).toFixed(0)} MB)`);
// Log V8 heap space breakdown for memory investigation
try {
const v8 = await import("node:v8");
const spaces = v8.getHeapSpaceStatistics();
console.log(`[gc] Heap spaces after GC:`);
for (const s of spaces) {
const usedMB = (s.space_used_size / 1024 / 1024).toFixed(1);
if (s.space_used_size > 1024 * 1024) {
console.log(`[gc] ${s.space_name}: ${usedMB} MB`);
}
}
// Write heap snapshot to /tmp if HEAP_SNAPSHOT=1 is set
if (process.env.HEAP_SNAPSHOT === "1") {
const filename = `/tmp/eleventy-heap-${Date.now()}.heapsnapshot`;
console.log(`[gc] Writing heap snapshot to ${filename}...`);
v8.writeHeapSnapshot(filename);
console.log(`[gc] Snapshot written`);
// Only take one snapshot, then unset
delete process.env.HEAP_SNAPSHOT;
}
} catch (e) {
console.log(`[gc] Heap stats unavailable: ${e.message}`);
}
}
// WebSub hub notification — skip on incremental rebuilds
if (incremental) return;
const hubUrl = "https://websubhub.com/hub";
+19 -11
View File
@@ -216,7 +216,7 @@ permalink: /interactions/
</div>
{# Webmentions list #}
<div x-show="!notConfigured && (!loading || webmentions.length)" class="space-y-4">
<div id="webmentions-list" x-show="!notConfigured && (!loading || webmentions.length)" class="space-y-4">
<template x-for="wm in paginatedWebmentions" :key="wm['wm-id']">
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<div class="flex gap-3">
@@ -297,7 +297,7 @@ permalink: /interactions/
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
<span class="text-surface-600 dark:text-surface-400 ml-1">(<span x-text="filteredWebmentions.length"></span> total)</span>
</div>
<div class="pagination-links">
<div class="pagination-links flex-wrap">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
@@ -307,7 +307,7 @@ permalink: /interactions/
Previous
</button>
<template x-for="p in pageNumbers" :key="p">
<template x-for="(p, idx) in pageNumbers" :key="'pg-' + idx">
<button
@click="typeof p === 'number' && goToPage(p)"
:disabled="p === '…'"
@@ -413,8 +413,7 @@ function interactionsApp() {
goToPage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
// Scroll to top of inbound tab
this.$el.closest('[x-show]')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
document.getElementById('webmentions-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
async init() {
@@ -515,12 +514,23 @@ function interactionsApp() {
mergeAndDeduplicate(wmItems, convItems) {
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
const seen = new Set();
const seenInteraction = new Set();
const result = [];
const normalizeUrl = (url) => (url || '').replace(/\/$/, '');
const interactionKey = (item) => {
const author = normalizeUrl(item.author?.url);
const type = item['wm-property'] || '';
const target = normalizeUrl(item['wm-target']);
return `${author}|${type}|${target}`;
};
for (const item of convItems) {
const key = item['wm-id'] || item.url;
if (key && !seen.has(key)) {
const iKey = interactionKey(item);
if (key && !seen.has(key) && !seenInteraction.has(iKey)) {
seen.add(key);
seenInteraction.add(iKey);
result.push(item);
}
}
@@ -530,11 +540,8 @@ function interactionsApp() {
if (seen.has(wmKey)) continue;
if (item.url && convUrls.has(item.url)) continue;
// Filter out self-webmentions from own syndicated Bluesky posts
if (item.url && (
item.url.includes('did:plc:g4utqyolpyb5zpwwodmm3hht') ||
item.url.includes('bsky.app/profile/svemagie.bsky.social')
)) continue;
const iKey = interactionKey(item);
if (seenInteraction.has(iKey)) continue;
if (!item.platform) {
const detected = this.detectPlatform(item);
@@ -542,6 +549,7 @@ function interactionsApp() {
}
seen.add(wmKey);
seenInteraction.add(iKey);
result.push(item);
}
+4 -4
View File
@@ -12,6 +12,7 @@ document.addEventListener("alpine:init", () => {
isOwner: false,
profile: null,
syndicationTargets: {},
replyTargets: {},
});
Alpine.data("commentsSection", (targetUrl) => ({
@@ -74,11 +75,13 @@ document.addEventListener("alpine:init", () => {
photo: data.photo,
};
this.syndicationTargets = data.syndicationTargets || {};
this.replyTargets = data.replyTargets || {};
// Also update global store for webmentions component
Alpine.store("owner").isOwner = true;
Alpine.store("owner").profile = this.ownerProfile;
Alpine.store("owner").syndicationTargets = this.syndicationTargets;
Alpine.store("owner").replyTargets = this.replyTargets;
// Note: owner:detected event is dispatched from init() after
// this completes, so the Alpine store is populated before the event fires
@@ -137,14 +140,11 @@ document.addEventListener("alpine:init", () => {
},
};
// Only add syndication target for the matching platform
// Only add syndication target if one exists for the platform
if (this.replyingTo.syndicateTo) {
micropubBody.properties["mp-syndicate-to"] = [
this.replyingTo.syndicateTo,
];
} else {
// IndieWeb webmention — no syndication, empty array
micropubBody.properties["mp-syndicate-to"] = [];
}
const res = await fetch("/micropub", {
+80 -6
View File
@@ -187,14 +187,41 @@
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
authorBadge.textContent = 'Author';
// Detect syndication platform and URL from the reply's syndication field
var synUrls = reply.syndication || [];
var synUrl = synUrls.length > 0 ? synUrls[0] : null;
var synPlatform = null;
if (synUrl) {
if (synUrl.includes('bsky.app')) synPlatform = 'bluesky';
else if (synUrl.match(/\/@[^/]+\/\d+/)) synPlatform = 'mastodon';
else if (synUrl.includes('activitypub')) synPlatform = 'activitypub';
}
headerRow.appendChild(nameSpan);
headerRow.appendChild(authorBadge);
// Add platform badge if syndicated
if (synPlatform) {
headerRow.appendChild(createProvenanceBadge(synPlatform));
}
// Timestamp — link to syndicated URL if available, plain text otherwise
var timeEl = document.createElement('time');
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
timeEl.dateTime = reply.published || '';
timeEl.textContent = formatDate(reply.published);
headerRow.appendChild(nameSpan);
headerRow.appendChild(authorBadge);
headerRow.appendChild(timeEl);
if (synUrl) {
var dateLink = document.createElement('a');
dateLink.href = synUrl;
dateLink.className = 'text-xs text-surface-600 dark:text-surface-400 hover:underline';
dateLink.target = '_blank';
dateLink.rel = 'noopener';
dateLink.appendChild(timeEl);
headerRow.appendChild(dateLink);
} else {
headerRow.appendChild(timeEl);
}
var textDiv = document.createElement('div');
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
@@ -234,6 +261,41 @@
const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])];
const convItems = [...(convData1.children || []), ...(convData2.children || [])];
// Collect post IDs from owner-enriched reply syndication URLs.
// When the owner replies from the frontend, Micropub creates the post
// and syndicates it (e.g., to Bluesky). The syndicated URL is stored
// in the post's syndication property. Bridgy/webmention.io picks up
// the same post and sends it back as a webmention — but may use a
// different URL format (e.g., DID-based vs handle-based on Bluesky).
// We extract platform-specific post IDs to match regardless of format:
// - Bluesky: rkey from /post/<rkey> (same post, DID or handle URL)
// - Mastodon: status ID from /<digits> suffix
function extractPostId(url) {
if (!url) return null;
// Bluesky: bsky.app/profile/.../post/<rkey>
var bskyMatch = url.match(/bsky\.app\/profile\/[^/]+\/post\/([a-z0-9]+)/i);
if (bskyMatch) return 'bsky:' + bskyMatch[1];
// Mastodon: instance/@user/<digits>
var mastoMatch = url.match(/\/@[^/]+\/(\d+)/);
if (mastoMatch) return 'masto:' + mastoMatch[1];
return null;
}
var ownerReplyPostIds = new Set();
var ownerReplySyndicationUrls = new Set();
for (var k = 0; k < convItems.length; k++) {
var c = convItems[k];
if (c.is_owner && c.syndication) {
var syns = Array.isArray(c.syndication) ? c.syndication : [c.syndication];
for (var s = 0; s < syns.length; s++) {
if (syns[s]) {
ownerReplySyndicationUrls.add(syns[s].replace(/\/$/, '').toLowerCase());
var pid = extractPostId(syns[s]);
if (pid) ownerReplyPostIds.add(pid);
}
}
}
}
// Build dedup sets from conversations items (richer metadata, take priority)
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
const seen = new Set();
@@ -266,6 +328,16 @@
const authorUrl = (wm.author && wm.author.url) || wm.url || '';
const action = wm['wm-property'] || 'mention';
if (authorUrl && authorActions.has(authorUrl + '::' + action)) continue;
// Skip webmention.io items that are syndicated echoes of owner replies.
// Match by platform post ID (not exact URL) because Bluesky URLs can
// use either DID or handle for the same post. Also fall back to exact
// URL match for non-Bluesky/Mastodon platforms.
if (wm.url) {
var wmLower = wm.url.replace(/\/$/, '').toLowerCase();
if (ownerReplySyndicationUrls.has(wmLower)) continue;
var wmPostId = extractPostId(wm.url);
if (wmPostId && ownerReplyPostIds.has(wmPostId)) continue;
}
seen.add(key);
allChildren.push(wm);
}
@@ -812,9 +884,11 @@
btn.addEventListener('click', function() {
var replyUrl = btn.dataset.replyUrl;
var platform = btn.dataset.platform || 'webmention';
var syndicateTo = null;
if (platform === 'bluesky') syndicateTo = ownerStore.syndicationTargets.bluesky || null;
if (platform === 'mastodon') syndicateTo = ownerStore.syndicationTargets.mastodon || null;
// Map platform to syndicator via replyTargets config
var targets = ownerStore.syndicationTargets || {};
var replyTargets = ownerStore.replyTargets || {};
var serviceName = replyTargets[platform] || null;
var syndicateTo = serviceName ? (targets[serviceName] || null) : null;
// Close any existing reply form
closeActiveReplyForm();
+45
View File
@@ -0,0 +1,45 @@
---
permalink: /updated.xml
eleventyExcludeFromCollections: true
eleventyImport:
collections:
- recentlyUpdated
---
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>{{ site.name }} — Recently Updated</title>
<link>{{ site.url }}/</link>
<description>Posts recently edited on {{ site.name }}</description>
<language>{{ site.locale | default('en') }}</language>
<atom:link href="{{ site.url }}/updated.xml" rel="self" type="application/rss+xml"/>
<atom:link href="https://websubhub.com/hub" rel="hub"/>
{%- if collections.recentlyUpdated.length %}
<lastBuildDate>{{ collections.recentlyUpdated[0].data.updated | dateToRfc822 }}</lastBuildDate>
{%- endif %}
{%- for post in collections.recentlyUpdated %}
{%- set absolutePostUrl = site.url + post.url %}
{%- set postImage = post.data.photo %}
{%- if postImage %}
{%- if postImage[0] and (postImage[0] | length) > 10 %}
{%- set postImage = postImage[0] %}
{%- endif %}
{%- endif %}
{%- if not postImage or postImage == "" %}
{%- set postImage = post.data.image or (post.content | extractFirstImage) %}
{%- endif %}
<item>
<title>{{ post.data.title | default(post.content | striptags | truncate(80)) | escape }} [updated]</title>
<link>{{ absolutePostUrl }}</link>
<guid isPermaLink="false">{{ absolutePostUrl }}#updated-{{ post.data.updated | dateToRfc822 }}</guid>
<pubDate>{{ post.data.updated | dateToRfc822 }}</pubDate>
<description>{{ post.content | htmlToAbsoluteUrls(absolutePostUrl) | escape }}</description>
{%- if postImage and postImage != "" and (postImage | length) > 10 %}
{%- set imageUrl = postImage | url | absoluteUrl(site.url) %}
<enclosure url="{{ imageUrl }}" type="image/jpeg" length="0"/>
<media:content url="{{ imageUrl }}" medium="image"/>
{%- endif %}
</item>
{%- endfor %}
</channel>
</rss>