Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions .github/workflows/shadow-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ name: Build shadow image
# Bootstrap: the very first build can run on `runs-on: ubuntu-latest` before
# the ARC enrollment for this repo (TIN-705/706 in jesssullivan-infra) lands.
# After enrollment, swap the runs-on label to `tinyland-dind`.
# Operator fallback: set BLOG_SHADOW_SOURCE_RUNNER=ubuntu-latest, or pass
# source_runner on manual dispatch, when the ARC lane is unavailable.

on:
push:
branches:
- "shadow-deploy/**"
- 'shadow-deploy/**'
workflow_dispatch:
inputs:
ref:
description: "Git ref to build (defaults to current ref)"
description: 'Git ref to build (defaults to current ref)'
required: false
source_runner:
description: 'Runner for the public source image build'
required: false
default: 'tinyland-dind'

concurrency:
group: shadow-image-${{ github.ref }}
Expand All @@ -38,13 +44,35 @@ permissions:
packages: write

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
IMAGE_NAME: ghcr.io/jesssullivan/jesssullivan-github-io-shadow-tailnet
SOURCE_RUNNER: ${{ inputs.source_runner || vars.BLOG_SHADOW_SOURCE_RUNNER || 'tinyland-dind' }}

jobs:
resolve:
name: Resolve shadow image runner
runs-on: ubuntu-latest
outputs:
source_runner: ${{ steps.runner.outputs.source_runner }}
steps:
- name: Resolve source runner
id: runner
run: |
set -euo pipefail
case "${SOURCE_RUNNER}" in
tinyland-dind|ubuntu-latest)
;;
*)
echo "::error::Unsupported source runner ${SOURCE_RUNNER}. Allowed: tinyland-dind, ubuntu-latest."
exit 1
;;
esac
echo "source_runner=${SOURCE_RUNNER}" >> "$GITHUB_OUTPUT"

build:
name: Build and push shadow image
runs-on: tinyland-dind
needs: resolve
runs-on: ${{ needs.resolve.outputs.source_runner }}
timeout-minutes: 30

steps:
Expand Down Expand Up @@ -96,7 +124,7 @@ jobs:
image-ref: ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
version: v0.70.0
format: table
exit-code: "0"
exit-code: '0'
severity: HIGH,CRITICAL
ignore-unfixed: true

Expand All @@ -108,6 +136,7 @@ jobs:
echo "- Image: \`${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}\`"
echo "- Branch: \`${{ github.ref_name }}\`"
echo "- Commit: \`${{ github.sha }}\`"
echo "- Source runner: \`${{ needs.resolve.outputs.source_runner }}\`"
echo ""
echo "### Private package mirror"
echo ""
Expand Down
5 changes: 4 additions & 1 deletion docs/blog-shadow-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ OpenTofu apply, or tailnet smoke; those remain private infra responsibilities.

`.github/workflows/shadow-image.yml` still supports the older
`shadow-deploy/**` branch flow for explicit operator builds. That workflow only
builds the source image; the private mirror and apply are handled by infra.
builds the source image; the private mirror and apply are handled by infra. It
uses the `tinyland-dind` ARC runner by default and accepts the same
`BLOG_SHADOW_SOURCE_RUNNER=ubuntu-latest` fallback, or manual dispatch
`source_runner=ubuntu-latest`, when the ARC source-image lane is unavailable.

## Cloudflare Pages Shadow

Expand Down
123 changes: 123 additions & 0 deletions e2e/dark-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ async function openThemeSettings(page: Page) {
await expect(lightOption).toBeVisible();
}

const NUDGE_STORAGE_KEY = 'theme-switcher-nudge-last-shown';

function localDateKeyScript() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

test.describe('Dark Mode', () => {
test('defaults to light mode', async ({ page }) => {
await gotoStableRoute(page);
Expand Down Expand Up @@ -106,4 +116,117 @@ test.describe('Dark Mode', () => {
await page.getByRole('button', { name: 'Set color theme to Rose' }).click();
expect(await page.locator('html').getAttribute('data-theme')).toBe('rose');
});

test('theme welcome nudge appears once per desktop day', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.addInitScript((key) => {
localStorage.removeItem(key);
}, NUDGE_STORAGE_KEY);

await page.goto('/');
await expect(page.getByTestId('theme-welcome-nudge')).toBeVisible({ timeout: 8000 });
await expect(page.getByTestId('theme-switcher-trigger')).toHaveAttribute('aria-expanded', 'true');

await page.getByLabel('Dismiss theme welcome nudge').click();
await expect(page.getByTestId('theme-welcome-nudge')).not.toBeVisible();

const stored = await page.evaluate((key) => localStorage.getItem(key), NUDGE_STORAGE_KEY);
const today = await page.evaluate(localDateKeyScript);
expect(stored).toBe(today);
});

test('theme welcome nudge uses Skeleton/Zag popover parts', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.addInitScript((key) => {
localStorage.removeItem(key);
}, NUDGE_STORAGE_KEY);

await page.goto('/');
await expect(page.getByTestId('theme-welcome-nudge')).toBeVisible({ timeout: 8000 });

const trigger = page.getByTestId('theme-switcher-trigger');
const content = page.getByTestId('theme-switcher-content');

await expect(trigger).toHaveAttribute('data-scope', 'popover');
await expect(trigger).toHaveAttribute('data-part', 'trigger');
await expect(trigger).toHaveAttribute('data-state', 'open');
await expect(content).toHaveAttribute('data-scope', 'popover');
await expect(content).toHaveAttribute('data-part', 'content');
await expect(content).toHaveAttribute('data-state', 'open');
await expect(page.locator('[data-scope="popover"][data-part="arrow"]')).toBeVisible();
});

test('theme welcome nudge inherits dark-mode style cascade', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.addInitScript((key) => {
localStorage.removeItem(key);
localStorage.setItem('color-mode', 'dark');
localStorage.setItem('skeleton-theme', 'rose');
}, NUDGE_STORAGE_KEY);

await page.goto('/');
await expect(page.getByTestId('theme-welcome-nudge')).toBeVisible({ timeout: 8000 });
await expect(page.locator('html')).toHaveAttribute('data-mode', 'dark');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'rose');

const styles = await page.getByTestId('theme-switcher-content').evaluate((element) => {
const content = window.getComputedStyle(element);
const nudge = window.getComputedStyle(element.querySelector('[data-testid="theme-welcome-nudge"]')!);
const link = window.getComputedStyle(element.querySelector('a')!);
return {
background: content.backgroundColor,
borderColor: content.borderTopColor,
textColor: nudge.color,
linkColor: link.color,
};
});

expect(styles.background).not.toBe('rgba(0, 0, 0, 0)');
expect(styles.borderColor).not.toBe('rgba(0, 0, 0, 0)');
expect(styles.textColor).not.toBe(styles.background);
expect(styles.linkColor).not.toBe(styles.textColor);
});

test('theme welcome nudge stays inside short desktop viewports', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 560 });
await page.addInitScript((key) => {
localStorage.removeItem(key);
}, NUDGE_STORAGE_KEY);

await page.goto('/');
await expect(page.getByTestId('theme-welcome-nudge')).toBeVisible({ timeout: 8000 });

const contentBox = await page.getByTestId('theme-switcher-content').boundingBox();
expect(contentBox).not.toBeNull();
expect(contentBox!.y + contentBox!.height).toBeLessThanOrEqual(560);
});

test('theme welcome nudge stays hidden when already shown today', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.addInitScript((key) => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
localStorage.setItem(key, `${year}-${month}-${day}`);
}, NUDGE_STORAGE_KEY);

await page.goto('/');
const startedAt = Date.now();
await page.waitForFunction((start) => Date.now() - start > 3500, startedAt);
await expect(page.getByTestId('theme-welcome-nudge')).not.toBeVisible();
await expect(page.getByTestId('theme-switcher-trigger')).toHaveAttribute('aria-expanded', 'false');
});

test('theme welcome nudge is desktop only', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await page.addInitScript((key) => {
localStorage.removeItem(key);
}, NUDGE_STORAGE_KEY);

await page.goto('/');
const startedAt = Date.now();
await page.waitForFunction((start) => Date.now() - start > 3500, startedAt);
await expect(page.getByTestId('theme-welcome-nudge')).not.toBeVisible();
});
});
62 changes: 62 additions & 0 deletions e2e/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,42 @@ async function expectMainSearchResults(page: Page, query: string) {
return { input, dropdown };
}

async function expectSearchInputReadable(input: Locator) {
const initialStyles = await input.evaluate((element) => {
const styles = window.getComputedStyle(element);
const placeholder = window.getComputedStyle(element, '::placeholder');
return {
background: styles.backgroundColor,
text: styles.color,
caret: styles.caretColor,
placeholder: placeholder.color,
};
});

expect(initialStyles.placeholder).not.toBe(initialStyles.background);

await input.fill('solar');
const filledStyles = await input.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
background: styles.backgroundColor,
text: styles.color,
caret: styles.caretColor,
};
});

expect(filledStyles.text).not.toBe(filledStyles.background);
expect(filledStyles.caret).not.toBe(filledStyles.background);
}

function todayKey() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

test.describe('Search — main blog page', () => {
test('search input is visible after FlexSearch loads', async ({ page }) => {
await page.goto('/blog');
Expand Down Expand Up @@ -89,6 +125,32 @@ test.describe('Search — main blog page', () => {
});
});

test.describe('Search — color modes', () => {
test.use({ viewport: { width: 1280, height: 800 } });

test('main and sidebar inputs keep readable text in light and dark modes', async ({ page }) => {
await page.goto('/blog');

for (const mode of ['light', 'dark'] as const) {
await page.evaluate(
({ mode, today }) => {
localStorage.setItem('color-mode', mode);
localStorage.setItem('skeleton-theme', 'pine');
localStorage.setItem('theme-switcher-nudge-last-shown', today);
},
{ mode, today: todayKey() },
);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('html')).toHaveAttribute('data-mode', mode);

const inputs = page.locator('input[type="search"]');
await expect(inputs.first()).toBeVisible({ timeout: SEARCH_TIMEOUT });
await expectSearchInputReadable(inputs.nth(0));
await expectSearchInputReadable(inputs.nth(1));
}
});
});

test.describe('Search — sidebar', () => {
test.use({ viewport: { width: 1280, height: 800 } });

Expand Down
45 changes: 26 additions & 19 deletions src/lib/components/BlogSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,20 @@
placeholder="Search posts..."
bind:value={searchQuery}
aria-label="Search recent blog posts"
class="w-full px-3 py-1.5 rounded-lg border border-surface-300-700 bg-surface-50-950 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
class="w-full px-3 py-1.5 rounded-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-surface-900 text-surface-950 dark:text-surface-50 caret-primary-600 dark:caret-primary-400 placeholder:text-surface-500 dark:placeholder:text-surface-400 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
{#if searchQuery.trim() && searchResults.length > 0}
<ul class="mt-3 space-y-2">
{#each searchResults as result (result.slug)}
<li>
<a href="/blog/{result.slug}" class="block hover:text-primary-500 transition-colors" aria-label={`Read search result: ${result.title}`}>
<span class="text-sm font-medium leading-tight line-clamp-2">{result.title}</span>
<span class="text-xs text-surface-500 mt-0.5 block line-clamp-2">{result.description}</span>
</a>
{#each searchResults as result (result.slug)}
<li>
<a
href="/blog/{result.slug}"
class="block hover:text-primary-500 transition-colors"
aria-label={`Read search result: ${result.title}`}
>
<span class="text-sm font-medium leading-tight line-clamp-2">{result.title}</span>
<span class="text-xs text-surface-500 mt-0.5 block line-clamp-2">{result.description}</span>
</a>
</li>
{/each}
</ul>
Expand All @@ -69,11 +73,15 @@
<div>
<h3 class="font-heading text-sm font-bold uppercase tracking-wider text-surface-500 mb-3">Recent Posts</h3>
<ul class="space-y-3">
{#each recentPosts as post (post.slug)}
<li>
<a href="/blog/{post.slug}" class="block hover:text-primary-500 transition-colors" aria-label={`Read recent post: ${post.title}`}>
<span class="text-sm font-medium leading-tight line-clamp-2">{post.title}</span>
<time class="text-xs text-surface-500 mt-0.5 block">
{#each recentPosts as post (post.slug)}
<li>
<a
href="/blog/{post.slug}"
class="block hover:text-primary-500 transition-colors"
aria-label={`Read recent post: ${post.title}`}
>
<span class="text-sm font-medium leading-tight line-clamp-2">{post.title}</span>
<time class="text-xs text-surface-500 mt-0.5 block">
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
Expand All @@ -92,13 +100,12 @@
<div>
<h3 class="font-heading text-sm font-bold uppercase tracking-wider text-surface-500 mb-3">Tags</h3>
<div class="flex flex-wrap gap-1.5">
{#each allTags as tag (tag)}
<a
href="/blog/tag/{encodeURIComponent(tag)}"
class="badge preset-outlined-surface-500 text-xs hover:preset-outlined-primary-500 transition-colors"
aria-label={`View posts tagged ${tag}`}
>{tag}</a
>
{#each allTags as tag (tag)}
<a
href="/blog/tag/{encodeURIComponent(tag)}"
class="badge preset-outlined-surface-500 text-xs hover:preset-outlined-primary-500 transition-colors"
aria-label={`View posts tagged ${tag}`}>{tag}</a
>
{/each}
</div>
</div>
Expand Down
Loading
Loading