Skip to content

Commit 1ae28de

Browse files
fix(theme): make cards and sort controls theme-aware with idempotent toggle
Centralize theme init in theme-init.ts so Head and ThemeToggle apply the same palette on toggle and refresh. Card chrome and content use light-first dark: utilities; blog sort buttons use aria-pressed CSS instead of conflicting JS class swaps. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a0c8515 commit 1ae28de

15 files changed

Lines changed: 407 additions & 179 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pendragon-coding': patch
3+
---
4+
5+
Fix CardList and content cards for light/dark theme parity, and unify theme init/toggle through shared applyTheme so refresh matches toggle.

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,15 @@ Run `ubs --diff` before every commit. Convert critical/high findings to P0/P1 be
117117
## Learned User Preferences
118118

119119
- Prefer Bun/TypeScript scripts over PowerShell for repo maintenance that calls GitHub APIs or must run on Windows without a full git checkout
120+
- Theme-aware UI should use light-first base styles with `dark:` overrides on shared surfaces (cards, controls), not dark-only palettes that ignore `html.dark`
121+
- Cursor `stop` hook runs `bun vet` as a session quality gate; treat vet failures as blocking before ending a session
120122

121123
## Learned Workspace Facts
122124

123125
- Do not use `:` in tracked file paths (including agent-generated distillation filenames); Git for Windows rejects them and blocks `git pull` / checkout on this repo's primary dev machine
124126
- Record domain vocabulary in `UBIQUITOUS_LANGUAGE.md`; this repo does not use `CONTEXT.md`
125127
- Colocate unit tests beside implementation as `src/**/*.test.ts` (there is no top-level `tests/` tree for unit tests)
126128
- Production canonical site URL is `https://pendragon-coding.dev` (`Astro.site` in `astro.config.mjs`)
129+
- Class-based theme toggles `dark` on `<html>`; `src/scripts/theme-init.ts` is the single source for Head inline FOUC script and ThemeToggle (keep toggle/refresh idempotent)
130+
- Blog listing uses `CardList` with `layoutMode="single-column"` (one post per row); myWork/bookshelf/shoutouts use `CardList` grid via SectionList `layoutMode="grid"`
131+
- `.gitattributes` enforces `* text=auto eol=lf`; phantom dirty files on Windows with empty diffs usually mean `core.autocrlf` / index stat drift, not real content changes

src/components/BlogCard.astro

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ const { entry } = Astro.props;
1313
const slug = toSlug(entry.data.title);
1414
const excerpt = getExcerpt(entry.data.description, entry.body);
1515
const tags: string[] = entry.data.tags ?? [];
16-
// Serialise tags for the <li data-tags> attribute used by the JS filter
1716
const dataTags = tags.join(',');
1817
const mins = readingTime(entry.body ?? '');
19-
// ISO 8601 date string for lexical sort (YYYY-MM-DD sorts chronologically)
2018
const isoDate = entry.data.date.toISOString().slice(0, 10);
2119
---
2220
<Card
@@ -26,28 +24,28 @@ const isoDate = entry.data.date.toISOString().slice(0, 10);
2624
data-sort-title={entry.data.title}
2725
>
2826
<article id={toSlug(entry.data.title)}>
29-
<h3 class="text-2xl font-medium text-green-100 mt-4">
30-
<a href={`/blog/${slug}`} class="hover:underline decoration-green-400 underline-offset-4">{entry.data.title}</a>
27+
<h3 class="card-title text-2xl font-medium mt-4">
28+
<a href={`/blog/${slug}`} class="hover:underline decoration-green-600 dark:decoration-green-400 underline-offset-4">{entry.data.title}</a>
3129
</h3>
3230
<div class="flex items-center gap-3 my-2">
33-
<p class="text-sm text-green-300">{entry.data.date.toLocaleDateString('en-US', {
31+
<p class="card-meta text-sm">{entry.data.date.toLocaleDateString('en-US', {
3432
year: 'numeric',
3533
month: 'short',
3634
day: 'numeric'
3735
})}</p>
38-
<span class="text-xs bg-green-800 text-green-300 rounded-full px-2 py-0.5 border border-green-600" aria-label={`${mins} minute read`}>
36+
<span class="card-pill text-xs rounded-full px-2 py-0.5 border" aria-label={`${mins} minute read`}>
3937
{mins} min read
4038
</span>
4139
</div>
42-
<p class="text-green-200 text-sm mt-2">{excerpt}</p>
40+
<p class="card-body text-sm mt-2">{excerpt}</p>
4341
{tags.length > 0 && (
4442
<div class="flex flex-wrap gap-1 mt-3">
4543
{tags.map((tag) => (
4644
<button
4745
type="button"
4846
data-tag={tag}
4947
aria-pressed="false"
50-
class="text-xs bg-green-800 text-green-200 rounded-full px-2 py-0.5 cursor-pointer border border-green-600 hover:bg-green-700"
48+
class="card-pill text-xs rounded-full px-2 py-0.5 cursor-pointer border"
5149
>
5250
{tag}
5351
</button>

src/components/Head.astro

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
---
22
import { SITE_CONFIG, type PageMetaProps } from '../config/site';
3+
import { buildHeadInlineScript } from '../scripts/theme-init';
34
45
type Props = PageMetaProps;
56
7+
const themeInitScript = buildHeadInlineScript();
8+
69
const {
710
pageTitle,
811
description = SITE_CONFIG.description,
@@ -32,35 +35,5 @@ const {
3235
<link rel="manifest" href="/favicon_io/site.webmanifest">
3336

3437
<!-- Theme initialization script - runs before page render to prevent flash -->
35-
<script is:inline>
36-
(function() {
37-
var explicit = localStorage.getItem('theme-explicit');
38-
var mq = window.matchMedia('(prefers-color-scheme: dark)');
39-
var theme;
40-
41-
if (explicit) {
42-
theme = localStorage.getItem('theme') || 'light';
43-
} else {
44-
theme = mq.matches ? 'dark' : 'light';
45-
}
46-
47-
if (theme === 'dark') {
48-
document.documentElement.classList.add('dark');
49-
} else {
50-
document.documentElement.classList.remove('dark');
51-
}
52-
53-
// Listen for system preference changes when no explicit choice
54-
if (!explicit) {
55-
mq.addEventListener('change', function(e) {
56-
if (localStorage.getItem('theme-explicit')) return;
57-
if (e.matches) {
58-
document.documentElement.classList.add('dark');
59-
} else {
60-
document.documentElement.classList.remove('dark');
61-
}
62-
});
63-
}
64-
})();
65-
</script>
38+
<script is:inline set:html={themeInitScript}></script>
6639
</head>

src/components/ThemeToggle.astro

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<button
66
id="theme-toggle"
77
type="button"
8-
class="relative inline-flex h-8 w-16 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2 focus:ring-offset-green-950 dark:focus:ring-offset-gray-900"
8+
class="relative inline-flex h-8 w-16 items-center rounded-full bg-gray-300 dark:bg-gray-600 transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2 focus:ring-offset-green-200 dark:focus:ring-offset-gray-900"
99
role="switch"
1010
aria-checked="false"
1111
aria-label="Toggle dark mode"
@@ -35,41 +35,13 @@
3535
</span>
3636
</button>
3737

38-
<script is:inline>
39-
// Set up theme toggle functionality
40-
function setupThemeToggle() {
41-
const button = document.getElementById('theme-toggle');
42-
if (!button) return;
38+
<script>
39+
import { setupThemeToggle } from '../scripts/theme-init';
4340

44-
// Update button state based on current theme
45-
const isDark = document.documentElement.classList.contains('dark');
46-
button.setAttribute('aria-checked', isDark ? 'true' : 'false');
47-
48-
// Remove any existing listeners to avoid duplicates
49-
const newButton = button.cloneNode(true);
50-
button.parentNode?.replaceChild(newButton, button);
51-
52-
// Add click handler
53-
newButton.addEventListener('click', function() {
54-
const element = document.documentElement;
55-
element.classList.toggle('dark');
56-
57-
const nowDark = element.classList.contains('dark');
58-
localStorage.setItem('theme', nowDark ? 'dark' : 'light');
59-
localStorage.setItem('theme-explicit', 'true');
60-
61-
// Update aria-checked attribute
62-
this.setAttribute('aria-checked', nowDark ? 'true' : 'false');
63-
});
64-
}
65-
66-
// Set up toggle on initial load
6741
if (document.readyState === 'loading') {
6842
document.addEventListener('DOMContentLoaded', setupThemeToggle);
6943
} else {
7044
setupThemeToggle();
7145
}
72-
73-
// Re-initialize after HTMX swaps (in case header is replaced)
7446
document.body.addEventListener('htmx:afterSwap', setupThemeToggle);
7547
</script>

src/components/blog/BlogListingContent.astro

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,10 @@ const { pageTitle = 'Blog', description, items } = Astro.props;
1717
<p>{description}</p>
1818
</div>
1919
<div id="blog-sort-controls" class="flex gap-2 mt-4 mb-2" aria-label="Sort posts by">
20-
<button
21-
type="button"
22-
data-sort="date"
23-
aria-pressed="true"
24-
class="text-sm rounded px-3 py-1 border border-green-800 bg-green-800 text-green-50 transition-colors duration-150 hover:bg-green-700"
25-
>
20+
<button type="button" data-sort="date" aria-pressed="true" class="blog-sort-btn">
2621
Newest first
2722
</button>
28-
<button
29-
type="button"
30-
data-sort="title"
31-
aria-pressed="false"
32-
class="text-sm rounded px-3 py-1 border border-green-600 bg-green-900 text-green-300 transition-colors duration-150 hover:bg-green-700"
33-
>
23+
<button type="button" data-sort="title" aria-pressed="false" class="blog-sort-btn">
3424
Title A–Z
3525
</button>
3626
</div>
@@ -39,7 +29,7 @@ const { pageTitle = 'Blog', description, items } = Astro.props;
3929
<BlogCard entry={item} />
4030
))}
4131
</CardList>
42-
<p id="blog-empty-state" class="text-green-300 text-center mt-8" hidden>
32+
<p id="blog-empty-state" class="card-meta text-center mt-8" hidden>
4333
No posts match the selected tag(s).
44-
<a href="/blog/" class="underline text-green-400 ml-1">Clear filter</a>
34+
<a href="/blog/" class="underline text-green-700 dark:text-green-400 ml-1">Clear filter</a>
4535
</p>

src/components/content/ContentCard.astro

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const { title, links, description, layoutMode, contentKind, contentId } = Astro.
1616
const cardVariant = layoutMode === 'single-column' ? 'long-form' : 'short-form';
1717
const isTestimonial = contentKind === 'testimonial';
1818
19-
// For testimonials, parse role from description (text before <br><br>)
2019
let role = '';
2120
let quoteText = description;
2221
if (isTestimonial && description.includes('<br><br>')) {
@@ -25,32 +24,29 @@ if (isTestimonial && description.includes('<br><br>')) {
2524
quoteText = description.substring(splitIndex + '<br><br>'.length);
2625
}
2726
28-
// Detect LinkedIn links for icon treatment
2927
const isLinkedIn = (href: string) => href.includes('linkedin.com');
3028
---
3129

3230
<Card variant={cardVariant} id={contentId} gridCell={layoutMode === 'grid'}>
3331
{isTestimonial ? (
3432
<div class="testimonial-card">
35-
{/* Quote icon */}
3633
<svg class="testimonial-quote-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
3734
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179zm10 0C13.553 16.227 13 15 13 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179z"/>
3835
</svg>
3936

40-
{/* Header: name + role + LinkedIn */}
4137
<div class="flex items-start justify-between gap-4 mb-3">
4238
<div>
43-
<h3 class="text-2xl font-bold text-green-100 m-0">{title}</h3>
39+
<h3 class="card-title text-2xl font-bold m-0">{title}</h3>
4440
{role && (
45-
<p class="text-sm text-green-200 mt-1 mb-0 font-medium">{role}</p>
41+
<p class="card-meta text-sm mt-1 mb-0 font-medium">{role}</p>
4642
)}
4743
</div>
4844
{links.map((link) => (
4945
<a
5046
href={link.href}
5147
target="_blank"
5248
rel="noopener noreferrer"
53-
class="testimonial-linkedin-link"
49+
class="card-pill card-link testimonial-linkedin-link"
5450
title={isLinkedIn(link.href) ? `${title} on LinkedIn` : link.title ?? 'Link'}
5551
>
5652
{isLinkedIn(link.href) ? (
@@ -66,27 +62,21 @@ const isLinkedIn = (href: string) => href.includes('linkedin.com');
6662
))}
6763
</div>
6864

69-
{/* Quote body */}
7065
<blockquote class="testimonial-quote-text">
71-
<p class="m-0" set:html={quoteText}></p>
66+
<p class="card-body m-0 text-base leading-relaxed italic" set:html={quoteText}></p>
7267
</blockquote>
7368
</div>
7469
) : (
7570
<div class="bookshelf-card">
76-
{/* Title */}
77-
<h3 class="text-xl font-bold text-green-100 m-0 mb-2">{title}</h3>
78-
79-
{/* Description */}
80-
<p class="text-sm text-green-200 m-0 mb-3 leading-relaxed" set:html={description}></p>
81-
82-
{/* Links as pills */}
71+
<h3 class="card-title text-xl font-bold m-0 mb-2">{title}</h3>
72+
<p class="card-body text-sm m-0 mb-3 leading-relaxed" set:html={description}></p>
8373
<div class="flex flex-wrap gap-2 mt-auto">
8474
{links.map((link) => (
8575
<a
8676
href={link.href}
8777
target="_blank"
8878
rel="noopener noreferrer"
89-
class="bookshelf-link-pill"
79+
class="card-pill card-link bookshelf-link-pill"
9080
>
9181
<span>{link.title ?? 'Read'}</span>
9282
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5" aria-hidden="true">

src/e2e/blog-integration.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test.describe('blog post card structure', () => {
5151
expect(href).toMatch(/^\/blog\/.+/);
5252

5353
// Date element: <p> inside the flex row following the h3
54-
const dateEl = card.locator('article p.text-sm.text-green-300');
54+
const dateEl = card.locator('article .card-meta.text-sm');
5555
await expect(dateEl).toBeVisible();
5656
const dateText = await dateEl.textContent();
5757
expect(dateText?.trim().length).toBeGreaterThan(0);
@@ -61,8 +61,8 @@ test.describe('blog post card structure', () => {
6161
await expect(readingTimeBadge).toBeVisible();
6262
await expect(readingTimeBadge).toContainText('min read');
6363

64-
// Excerpt: <p class="text-green-200 ..."> with non-empty text
65-
const excerpt = card.locator('article p.text-green-200');
64+
// Excerpt with non-empty text
65+
const excerpt = card.locator('article p.card-body');
6666
await expect(excerpt).toBeVisible();
6767
const excerptText = await excerpt.textContent();
6868
expect(excerptText?.trim().length).toBeGreaterThan(0);

src/e2e/sidebar.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,17 @@ describe('theme toggle e2e', () => {
167167
expect(homeHtml).toContain('Toggle dark mode');
168168
});
169169

170-
it('FOUC prevention script references localStorage in head', () => {
171-
expect(homeHtml).toContain("localStorage.getItem('theme')");
172-
expect(homeHtml).toContain("classList.add('dark')");
170+
it('FOUC prevention script references theme storage in head', () => {
171+
expect(homeHtml).toContain("'theme'");
172+
expect(homeHtml).toContain("'theme-explicit'");
173+
expect(homeHtml).toContain('classList.add');
174+
});
175+
176+
it('blog cards emit semantic theme-aware title classes', async () => {
177+
const blogHtml = await fetchPage('/blog/');
178+
expect(blogHtml).toContain('card-title');
179+
expect(blogHtml).toContain('card-meta');
180+
expect(blogHtml).toContain('card-body');
173181
});
174182

175183
it('theme toggle handles htmx:afterSwap for reinitialization', () => {

0 commit comments

Comments
 (0)