diff --git a/.github/workflows/shadow-image.yml b/.github/workflows/shadow-image.yml index 315a400..c9eb86e 100644 --- a/.github/workflows/shadow-image.yml +++ b/.github/workflows/shadow-image.yml @@ -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 }} @@ -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: @@ -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 @@ -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 "" diff --git a/docs/blog-shadow-preview.md b/docs/blog-shadow-preview.md index 7286887..b292c60 100644 --- a/docs/blog-shadow-preview.md +++ b/docs/blog-shadow-preview.md @@ -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 diff --git a/e2e/dark-mode.spec.ts b/e2e/dark-mode.spec.ts index 4cacdde..712443a 100644 --- a/e2e/dark-mode.spec.ts +++ b/e2e/dark-mode.spec.ts @@ -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); @@ -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(); + }); }); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index 037d4a4..9fae41f 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -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'); @@ -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 } }); diff --git a/src/lib/components/BlogSidebar.svelte b/src/lib/components/BlogSidebar.svelte index 2441c78..b699fe6 100644 --- a/src/lib/components/BlogSidebar.svelte +++ b/src/lib/components/BlogSidebar.svelte @@ -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} @@ -69,11 +73,15 @@

Recent Posts