From a7833a17ab2c9eb9aa5a8d2ba7c2e3d4d95ed961 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Sun, 10 May 2026 23:31:38 -0400 Subject: [PATCH 1/5] Add daily theme welcome nudge --- e2e/dark-mode.spec.ts | 109 +++++++++++++++++++ src/lib/components/ThemeSwitcher.svelte | 137 +++++++++++++++++++++++- 2 files changed, 242 insertions(+), 4 deletions(-) diff --git a/e2e/dark-mode.spec.ts b/e2e/dark-mode.spec.ts index 4cacdde..9778316 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,103 @@ 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: 5000 }); + 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: 5000 }); + + 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: 5000 }); + 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 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/src/lib/components/ThemeSwitcher.svelte b/src/lib/components/ThemeSwitcher.svelte index 01099b8..2e9d3cf 100644 --- a/src/lib/components/ThemeSwitcher.svelte +++ b/src/lib/components/ThemeSwitcher.svelte @@ -1,12 +1,74 @@ - + + {#if showNudgeContent} + + + + {/if} + {#if showNudgeContent} +
+
+
+ + Hey there! + + + Welcome to Jess’s static blog. Set your preferred theme and mode here ^w^, or explore this + static site’s + codebase + and + build pipeline. This site is dedicated to the + public domain. -Jess + +
+ + + +
+
+ {/if}

Mode

{#each ['light', 'dark', 'system'] as const as mode (mode)} {/if} From d65fcc3b4090e0b2ed9fc9af280ecddfc148b65d Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Mon, 11 May 2026 00:44:00 -0400 Subject: [PATCH 4/5] Fix idle callback lint types --- src/lib/components/ThemeSwitcher.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/ThemeSwitcher.svelte b/src/lib/components/ThemeSwitcher.svelte index 6e617c9..5ce2e6a 100644 --- a/src/lib/components/ThemeSwitcher.svelte +++ b/src/lib/components/ThemeSwitcher.svelte @@ -10,8 +10,10 @@ const NUDGE_IDLE_TIMEOUT_MS = 1000; const DESKTOP_QUERY = '(min-width: 768px)'; const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + type IdleDeadlineLike = { didTimeout: boolean; timeRemaining: () => number }; + type IdleCallback = (deadline: IdleDeadlineLike) => void; type IdleCapableWindow = { - requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; + requestIdleCallback?: (callback: IdleCallback, options?: { timeout?: number }) => number; cancelIdleCallback?: (handle: number) => void; setTimeout: typeof window.setTimeout; clearTimeout: typeof window.clearTimeout; From f7d6cb1104f99f3abe2266cd4d9c91b89c2dec6a Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Mon, 11 May 2026 01:03:14 -0400 Subject: [PATCH 5/5] Allow shadow image runner fallback --- .github/workflows/shadow-image.yml | 39 ++++++++++++++++++++++++++---- docs/blog-shadow-preview.md | 5 +++- 2 files changed, 38 insertions(+), 6 deletions(-) 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