Compare commits

2 Commits

Author SHA1 Message Date
8f1c0746a5 home page updated
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 04:35:03 -05:00
c21614020a wcag and responsiveness
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
2026-02-10 03:22:22 -05:00
18 changed files with 942 additions and 111 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,88 @@
## Context
- The site is an Astro-based static site with shared global styling in `site/public/styles/global.css` and shared layout/navigation in `site/src/layouts/*`.
- Current UX gaps:
- Responsive behavior is inconsistent at smaller breakpoints (navigation does not collapse into a mobile-friendly menu).
- The background gradient shows abrupt cuts/banding on larger resolutions.
- Typography relies on system fonts; a smoother, display-friendly font is desired.
- Accessibility baseline is not formally enforced; target is WCAG 2.2 AA minimum standard (not necessarily 100% compliance).
## Goals / Non-Goals
**Goals:**
- Establish an explicit baseline of WCAG 2.2 AA-aligned behavior for the site shell and common interactive elements.
- Implement responsive layouts across common breakpoints; ensure primary navigation collapses into a hamburger menu with mild animation.
- Ensure the mobile menu is fully keyboard accessible and screen-reader friendly (correct semantics, labeling, focus management).
- Improve background rendering so gradients do not cut abruptly on large displays.
- Introduce a display-friendly font and apply it consistently across pages and components.
- Add lightweight verification (tests and/or build checks) that ensures the baseline remains intact.
**Non-Goals:**
- Full accessibility audit and remediation of all possible WCAG 2.2 AA items across all content (e.g., all third-party embeds, all user-provided HTML).
- Building a complete design system or replacing all visual styling.
- Implementing complex client-side routing or heavy JS frameworks.
## Decisions
1. Use a small client-side navigation controller for the hamburger menu
Why: Astro renders static HTML; a small, isolated script can provide toggling + focus management without adding framework complexity.
Alternatives considered:
- CSS-only checkbox hack: rejected (harder to manage focus/ARIA correctly, less robust).
- A full component framework (React/Vue): rejected (unnecessary weight).
2. Prefer semantic HTML + minimal ARIA
Why: Better interoperability across assistive technologies and less risk of incorrect ARIA.
Approach:
- Use a `<button>` to toggle the menu.
- Control a `<nav>` region (or a `<div>` wrapper) via `aria-controls` and `aria-expanded`.
- Ensure menu items remain standard links with predictable tab order.
3. Add explicit focus styles and reduced-motion support globally
Why: Focus visibility and motion preferences are core accessibility/usability requirements; implementing globally reduces drift.
Approach:
- Provide a consistent `:focus-visible` outline that meets contrast requirements.
- Wrap animations/transitions in `@media (prefers-reduced-motion: reduce)` fallbacks.
4. Fix gradient banding/cuts via a single, oversized background layer
Why: Multiple fixed-size radial gradients can show cutoffs on ultrawide/large viewports.
Approach:
- Render the background using a `body::before` fixed-position layer with large gradients and `inset: -40vmax` (or similar) to eliminate edges.
- Keep the existing aesthetic but adjust sizes/stops so it scales smoothly.
Alternatives considered:
- Using an image background: rejected (asset management, potential compression artifacts).
5. Use a webfont with good UI readability and a limited weight range
Why: Improve perceived polish while keeping performance predictable.
Approach:
- Choose a modern UI font family (e.g., `Inter`, `DM Sans`, `Manrope`, or `Space Grotesk`) with 2-3 weights.
- Prefer self-hosted font assets or a single external source with `font-display: swap`.
Decision will be finalized during implementation based on desired look and licensing.
## Risks / Trade-offs
- [Risk] Mobile menu introduces accessibility regressions (trap focus, broken escape handling)
-> Mitigation: implement standard patterns (toggle button, ESC closes, return focus, body scroll lock optional) and add tests for key attributes.
- [Risk] Global CSS changes affect existing layouts
-> Mitigation: keep changes scoped to site shell classes and add visual spot-checks for key pages (`/`, `/videos`, `/podcast`, `/blog`, `/about`).
- [Risk] Webfont increases page weight
-> Mitigation: limit to necessary weights, use `woff2`, preload critical fonts, and keep fallbacks.
## Migration Plan
1. Implement navigation collapse + hamburger toggle script and styles.
2. Add global focus-visible styling and reduced-motion fallbacks.
3. Fix background gradient rendering on large displays.
4. Add/replace typography stack and adjust headings/line-height as needed.
5. Add verification (tests / lint checks) and confirm responsive behavior on key pages.
Rollback:
- Revert navigation script + CSS changes to restore previous behavior.
## Open Questions
- Which specific webfont should be used (and will it be self-hosted or loaded via a provider)?
- Should the mobile menu lock body scroll while open (common pattern, but optional)?
- Should the menu close on route navigation (likely yes), and should it close on outside click (likely yes)?

View File

@@ -0,0 +1,29 @@
## Why
The site needs a more robust, state-of-the-art UX baseline: a minimum standard of WCAG 2.2 AA accessibility and a consistently responsive UI across devices and large displays.
## What Changes
- Establish a minimum accessibility baseline targeting WCAG 2.2 AA (without aiming for perfect/100% compliance).
- Make the UI fully responsive across common breakpoints (mobile/tablet/desktop/large desktop).
- Update the primary navigation to collapse into a hamburger menu on smaller viewports with mild animation.
- Fix the background gradient so it does not show abrupt cuts/banding at larger resolutions.
- Introduce a smoother, display-friendly font stack (and apply it consistently).
## Capabilities
### New Capabilities
- `wcag-responsive-ui`: Accessibility + responsive UI shell standards for layout, navigation, typography, and global styling (WCAG 2.2 AA baseline).
### Modified Capabilities
<!-- None expected; this change introduces a new UI-shell capability that affects multiple pages/components. -->
## Impact
- Frontend UI: `site/src/layouts/*`, header/navigation components, shared UI components, and global CSS (`site/public/styles/global.css`).
- Interaction patterns: keyboard navigation and focus styles, menu toggle behavior, and motion controls (respecting reduced-motion preferences).
- Visual design: typography and background rendering across large screens.
- Verification: add/update checks/tests for responsive nav behavior and basic accessibility expectations (e.g., menu toggle labeling, focus visibility).

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Responsive layout baseline
The site MUST be responsive across common breakpoints (mobile, tablet, desktop, and large desktop) and MUST not exhibit broken layouts (overlapping content, horizontal scrolling, clipped navigation).
#### Scenario: Mobile viewport does not horizontally scroll
- **WHEN** the site is viewed on a small mobile viewport
- **THEN** content reflows to a single-column layout and the page does not require horizontal scrolling to read primary content
#### Scenario: Large viewport uses available space without visual artifacts
- **WHEN** the site is viewed on a large desktop viewport (ultrawide / high resolution)
- **THEN** the background and layout scale without visible abrupt gradient cutoffs or banding artifacts
### Requirement: Collapsible primary navigation (hamburger menu)
The primary navigation MUST collapse into a hamburger menu on smaller viewports.
The menu toggle MUST be a `<button>` with:
- `aria-controls` referencing the menu container
- `aria-expanded` reflecting open/closed state
- an accessible label (e.g., `aria-label="Open menu"`/`"Close menu"` or equivalent)
When the menu is open, the menu items MUST be visible and keyboard navigable.
#### Scenario: Menu collapses on small viewport
- **WHEN** the viewport is below the mobile navigation breakpoint
- **THEN** the primary navigation renders in a collapsed state and can be opened via a hamburger toggle
#### Scenario: Menu toggle exposes accessible state
- **WHEN** the user toggles the menu open and closed
- **THEN** `aria-expanded` updates correctly and the toggle remains reachable via keyboard
### Requirement: Keyboard and focus behavior baseline (WCAG 2.2 AA aligned)
The site MUST support keyboard navigation for all primary interactive elements.
The site MUST provide visible focus indication for keyboard users using `:focus-visible` styles.
For the mobile menu:
- pressing `Escape` MUST close the menu (when open)
- closing the menu MUST return focus to the menu toggle button
#### Scenario: Focus is visible on links and buttons
- **WHEN** a keyboard user tabs through the page
- **THEN** the focused element shows a visible focus indicator
#### Scenario: Escape closes the menu
- **WHEN** the menu is open and the user presses `Escape`
- **THEN** the menu closes and focus returns to the menu toggle
### Requirement: Reduced motion support
The site MUST respect user motion preferences:
- if `prefers-reduced-motion: reduce` is set, animations/transitions for the menu and other UI elements MUST be reduced or disabled.
#### Scenario: Reduced motion disables menu animation
- **WHEN** the user's system preference is `prefers-reduced-motion: reduce`
- **THEN** opening/closing the menu does not use noticeable animation
### Requirement: Typography baseline (display-friendly font)
The site MUST use a display-friendly font stack consistently across pages, including headings and navigation.
The site MUST ensure text remains readable:
- reasonable line height
- sufficient contrast against the background for primary text and focus indicators
#### Scenario: Font is applied consistently
- **WHEN** a user navigates between pages
- **THEN** typography (font family and basic scale) remains consistent

View File

@@ -0,0 +1,27 @@
## 1. Navigation + Responsive Shell
- [x] 1.1 Identify the current header/nav implementation and decide the mobile breakpoint for collapsing navigation
- [x] 1.2 Implement hamburger toggle UI (button + icon) with correct ARIA (`aria-controls`, `aria-expanded`, accessible label)
- [x] 1.3 Implement the mobile menu panel styles + mild open/close animation (and close-on-route navigation)
- [x] 1.4 Add keyboard behavior: `Escape` closes menu and focus returns to toggle; ensure tab order remains sane
- [x] 1.5 Add reduced-motion fallback: disable/reduce menu animations when `prefers-reduced-motion: reduce`
- [x] 1.6 Ensure desktop navigation links remain clickable/accessible (no `inert`/`aria-hidden` desktop regression)
## 2. WCAG 2.2 AA Baseline
- [x] 2.1 Add/standardize global `:focus-visible` styles for links/buttons (high-contrast, consistent, not clipped)
- [x] 2.2 Ensure interactive elements meet minimum hit target expectations where feasible (spacing/padding for nav + key buttons)
- [x] 2.3 Add skip-to-content link and verify it is visible on focus and works across pages/layouts
- [x] 2.4 Audit and fix obvious contrast issues for primary text and focus outlines against the background
## 3. Background + Typography Polish
- [x] 3.1 Fix large-resolution background gradient cutoffs (move to a scaled, oversized background layer/pseudo-element)
- [x] 3.2 Introduce a display-friendly font (webfont) and apply consistently across the site; ensure sensible type scale/line-height
- [x] 3.3 Verify responsive behavior on key pages (`/`, `/videos`, `/podcast`, `/blog`, `/about`) at common breakpoints
## 4. Verification
- [x] 4.1 Add/update tests to ensure hamburger toggle ARIA attributes exist and update correctly
- [x] 4.2 Add/update tests or checks for focus-visible styling presence and reduced-motion rules
- [x] 4.3 Build the site and perform a keyboard-only smoke test (nav, cards, blog category nav, menu open/close)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,52 @@
## Context
This change introduces a Service Worker to improve perceived load time and reduce network usage on repeat visits by caching critical assets in the browser.
The site is a static Astro build. That means:
- The Service Worker should live at the site root (`/sw.js`) so it can control all routes.
- Navigations (HTML documents) should not be cached in a way that causes indefinite staleness after new deploys.
## Goals / Non-Goals
**Goals:**
- Improve repeat-visit performance by pre-caching the critical site shell assets.
- Add runtime caching for media assets (images) with bounded storage usage.
- Ensure safe update behavior: cache versioning and cleanup on activate.
- Keep local development predictable by not registering the Service Worker in dev by default.
**Non-Goals:**
- Full offline-first experience for all routes/content.
- Background sync, push notifications, or complex offline fallbacks.
- Server-side caching (handled by separate changes, if desired).
## Decisions
1. **Implement a lightweight, custom Service Worker (no Workbox)**
Rationale: The project already outputs static assets and the needed caching strategies are straightforward. A small custom `/sw.js` avoids adding a build-time dependency and keeps behavior explicit.
Alternatives considered:
- Workbox: powerful, but adds dependency surface area and build configuration overhead.
2. **Cache strategy by request type**
Rationale: Different resources have different freshness requirements.
- Navigations (HTML documents): **Network-first**, fallback to cache on failure. This minimizes stale HTML risks while still helping resiliency.
- Static shell assets (CSS/JS/fonts/icons): **Pre-cache** on install and serve from cache for speed.
- Images/media: **Cache-first** with a size bound and eviction to avoid unbounded storage.
3. **Versioned caches + activation cleanup**
Rationale: Static sites frequently redeploy; versioning ensures updates can be picked up and old assets are not served after deploy. On activate, the SW deletes prior version caches.
Implementation approach:
- Use cache names like `shell-v<version>` and `media-v<version>`.
- Update the version string on build (initially a constant; later can be automated).
4. **Disable SW registration in development by default**
Rationale: Service worker caching can confuse local iteration and cause stale assets during development.
Implementation approach:
- Register SW only when `import.meta.env.PROD` is true (Astro build-time flag) or an explicit runtime guard is met.
## Risks / Trade-offs
- **[Stale or broken assets after deploy]** → Use versioned caches and delete old caches during activation. Prefer network-first for navigations.
- **[Over-caching HTML causes outdated content]** → Do not use cache-first for navigation; do not pre-cache HTML pages.
- **[Storage growth due to images]** → Enforce a max-entry limit with eviction for media cache.
- **[Browser compatibility gaps]** → Service worker is progressive enhancement; site must still function without it.

View File

@@ -0,0 +1,27 @@
## Why
Improve page load performance (especially repeat visits) by caching key assets closer to the user and reducing unnecessary network requests.
## What Changes
- Add a Service Worker to the site so the browser can cache and serve core assets efficiently.
- Pre-cache the critical shell (CSS, JS, fonts, icons) and use a runtime caching strategy for images and other large assets.
- Ensure safe update behavior on deploy (new service worker activates and old caches are cleaned up).
- Keep development experience predictable (service worker disabled or bypassed in dev by default).
## Capabilities
### New Capabilities
- `service-worker-performance`: Provide a service worker-based caching strategy that improves perceived load time and reduces network usage on repeat visits, while ensuring safe updates on new deploys.
### Modified Capabilities
- (none)
## Impact
- Adds new client-side assets for the service worker (e.g., `sw.js`) and registration logic in the site layout.
- Changes browser caching behavior; must avoid serving stale HTML indefinitely and ensure caches are versioned/invalidated on deploy.
- Service workers require a secure context (HTTPS) in production; local dev behavior should be explicitly controlled to avoid confusing caching during iteration.

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Service Worker registration
The site SHALL register a Service Worker on supported browsers when running in production (HTTPS), scoped to the site root so it can control all site pages.
#### Scenario: Production registration
- **WHEN** a user loads any page in a production environment
- **THEN** the site registers a service worker at `/sw.js` with scope `/`
#### Scenario: Development does not register
- **WHEN** a user loads any page in a local development environment
- **THEN** the site does not register a service worker
### Requirement: Pre-cache critical site shell assets
The Service Worker SHALL pre-cache a set of critical static assets required to render the site shell quickly on repeat visits.
#### Scenario: Pre-cache on install
- **WHEN** the service worker is installed
- **THEN** it caches the configured site shell assets in a versioned cache
### Requirement: Runtime caching for media assets
The Service Worker SHALL use runtime caching for media assets (for example images) to reduce repeat network fetches, while ensuring content can refresh.
#### Scenario: Cache-first for images
- **WHEN** a user requests an image resource
- **THEN** the service worker serves the cached image when available, otherwise fetches from the network and stores the response in the media cache
#### Scenario: Enforce cache size bounds
- **WHEN** the number of cached media items exceeds the configured maximum
- **THEN** the service worker evicts older entries to stay within the bound
### Requirement: Navigation requests avoid indefinite staleness
The Service Worker MUST NOT serve stale HTML indefinitely for navigation requests.
#### Scenario: Network-first navigation
- **WHEN** a user navigates to a page route (a document navigation request)
- **THEN** the service worker attempts to fetch from the network first and falls back to a cached response if the network is unavailable
### Requirement: Safe updates and cache cleanup
The Service Worker SHALL use versioned caches and remove old caches during activation to ensure updated assets are used after a new deploy.
#### Scenario: Activate new version and clean old caches
- **WHEN** a new service worker version activates
- **THEN** it deletes caches from older versions and begins using the current versioned caches

View File

@@ -0,0 +1,22 @@
## 1. Setup
- [x] 1.1 Add `sw.js` to site root output (place in `site/public/sw.js`)
- [x] 1.2 Add service worker registration to the base layout (register only in production)
## 2. Pre-cache Site Shell
- [x] 2.1 Implement versioned cache names and an explicit cache version constant
- [x] 2.2 Implement `install` handler to pre-cache critical shell assets
- [x] 2.3 Implement `activate` handler to delete old version caches
## 3. Runtime Caching
- [x] 3.1 Implement network-first strategy for navigation/document requests with cache fallback
- [x] 3.2 Implement cache-first strategy for images/media with network fallback
- [x] 3.3 Add a bounded eviction policy for media cache size
## 4. Verification
- [ ] 4.1 Verify service worker registers in production build and does not register in dev
- [ ] 4.2 Verify repeat navigation and asset loads hit cache (Chrome DevTools Application tab)
- [ ] 4.3 Verify a new deploy triggers cache version update and old caches are removed

View File

@@ -0,0 +1,71 @@
## Purpose
Define a minimum UX baseline for accessibility (WCAG 2.2 AA aligned) and responsive behavior for the site shell (navigation, focus, motion, typography, and background behavior).
## Requirements
### Requirement: Responsive layout baseline
The site MUST be responsive across common breakpoints (mobile, tablet, desktop, and large desktop) and MUST not exhibit broken layouts (overlapping content, horizontal scrolling, clipped navigation).
#### Scenario: Mobile viewport does not horizontally scroll
- **WHEN** the site is viewed on a small mobile viewport
- **THEN** content reflows to a single-column layout and the page does not require horizontal scrolling to read primary content
#### Scenario: Large viewport uses available space without visual artifacts
- **WHEN** the site is viewed on a large desktop viewport (ultrawide / high resolution)
- **THEN** the background and layout scale without visible abrupt gradient cutoffs or banding artifacts
### Requirement: Collapsible primary navigation (hamburger menu)
The primary navigation MUST collapse into a hamburger menu on smaller viewports.
The menu toggle MUST be a `<button>` with:
- `aria-controls` referencing the menu container
- `aria-expanded` reflecting open/closed state
- an accessible label (e.g., `aria-label="Open menu"`/`"Close menu"` or equivalent)
When the menu is open, the menu items MUST be visible and keyboard navigable.
#### Scenario: Menu collapses on small viewport
- **WHEN** the viewport is below the mobile navigation breakpoint
- **THEN** the primary navigation renders in a collapsed state and can be opened via a hamburger toggle
#### Scenario: Menu toggle exposes accessible state
- **WHEN** the user toggles the menu open and closed
- **THEN** `aria-expanded` updates correctly and the toggle remains reachable via keyboard
### Requirement: Keyboard and focus behavior baseline (WCAG 2.2 AA aligned)
The site MUST support keyboard navigation for all primary interactive elements.
The site MUST provide visible focus indication for keyboard users using `:focus-visible` styles.
For the mobile menu:
- pressing `Escape` MUST close the menu (when open)
- closing the menu MUST return focus to the menu toggle button
#### Scenario: Focus is visible on links and buttons
- **WHEN** a keyboard user tabs through the page
- **THEN** the focused element shows a visible focus indicator
#### Scenario: Escape closes the menu
- **WHEN** the menu is open and the user presses `Escape`
- **THEN** the menu closes and focus returns to the menu toggle
### Requirement: Reduced motion support
The site MUST respect user motion preferences:
- if `prefers-reduced-motion: reduce` is set, animations/transitions for the menu and other UI elements MUST be reduced or disabled.
#### Scenario: Reduced motion disables menu animation
- **WHEN** the user's system preference is `prefers-reduced-motion: reduce`
- **THEN** opening/closing the menu does not use noticeable animation
### Requirement: Typography baseline (display-friendly font)
The site MUST use a display-friendly font stack consistently across pages, including headings and navigation.
The site MUST ensure text remains readable:
- reasonable line height
- sufficient contrast against the background for primary text and focus indicators
#### Scenario: Font is applied consistently
- **WHEN** a user navigates between pages
- **THEN** typography (font family and basic scale) remains consistent

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@
--stroke: rgba(255, 255, 255, 0.16);
--accent: #ffcd4a;
--accent2: #5ee4ff;
--focus: rgba(94, 228, 255, 0.95);
}
* {
@@ -22,12 +23,10 @@ body {
body {
margin: 0;
color: var(--fg);
background:
radial-gradient(1000px 600px at 10% 10%, rgba(94, 228, 255, 0.22), transparent 55%),
radial-gradient(900px 600px at 90% 20%, rgba(255, 205, 74, 0.18), transparent 50%),
radial-gradient(900px 700px at 30% 90%, rgba(140, 88, 255, 0.14), transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
background: linear-gradient(180deg, var(--bg0), var(--bg1));
/* Prefer a display-friendly font if available; fall back to system fonts. */
font-family:
"Manrope",
ui-sans-serif,
system-ui,
-apple-system,
@@ -41,11 +40,53 @@ body {
"Segoe UI Emoji";
}
/* Oversized fixed background layer to avoid gradient cutoffs on large screens. */
body::before {
content: "";
position: fixed;
inset: -40vmax;
z-index: -1;
pointer-events: none;
background:
radial-gradient(1200px 800px at 10% 10%, rgba(94, 228, 255, 0.22), transparent 60%),
radial-gradient(1100px 800px at 90% 20%, rgba(255, 205, 74, 0.18), transparent 58%),
radial-gradient(1200px 900px at 30% 90%, rgba(140, 88, 255, 0.14), transparent 62%);
}
a {
color: inherit;
text-decoration: none;
}
/* WCAG-ish baseline: make keyboard focus obvious. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
.skip-link {
position: absolute;
left: 14px;
top: 12px;
z-index: 999;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(10, 14, 28, 0.92);
color: var(--fg);
font-weight: 800;
transform: translateY(-220%);
transition: transform 140ms ease;
}
.skip-link:focus {
transform: translateY(0);
}
.container {
width: min(1100px, calc(100% - 48px));
margin: 0 auto;
@@ -78,10 +119,122 @@ a {
color: var(--muted);
}
.nav a {
padding: 10px 12px;
border-radius: 999px;
}
.nav a:hover {
color: var(--fg);
}
.nav-toggle {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
}
.nav-toggle-icon {
width: 18px;
height: 12px;
position: relative;
display: block;
}
.nav-toggle-icon::before,
.nav-toggle-icon::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 999px;
background: rgba(242, 244, 255, 0.92);
}
.nav-toggle-icon::before {
top: 0;
box-shadow: 0 5px 0 rgba(242, 244, 255, 0.92);
}
.nav-toggle-icon::after {
bottom: 0;
}
@media (max-width: 760px) {
.site-header {
position: sticky;
}
.nav-toggle {
display: inline-flex;
}
.nav {
position: absolute;
top: calc(100% + 10px);
right: 14px;
width: min(86vw, 320px);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(10, 14, 28, 0.92);
box-shadow:
0 18px 60px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
transform-origin: top right;
transition:
opacity 160ms ease,
transform 160ms ease,
visibility 0s linear 160ms;
}
.nav[data-open="false"] {
opacity: 0;
transform: translateY(-6px) scale(0.98);
pointer-events: none;
visibility: hidden;
}
.nav[data-open="true"] {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
visibility: visible;
transition:
opacity 160ms ease,
transform 160ms ease,
visibility 0s;
}
.nav a {
padding: 12px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
.subnav {
display: flex;
gap: 10px;

148
site/public/sw.js Normal file
View File

@@ -0,0 +1,148 @@
/* Service Worker: lightweight caching for a static Astro site.
- Navigations: network-first (avoid stale HTML indefinitely)
- Site shell: pre-cache on install
- Images: cache-first with bounded eviction
*/
// Bump this value on deploy to invalidate caches.
const CACHE_VERSION = "v1";
const CACHE_SHELL = `shell-${CACHE_VERSION}`;
const CACHE_PAGES = `pages-${CACHE_VERSION}`;
const CACHE_MEDIA = `media-${CACHE_VERSION}`;
const SHELL_ASSETS = ["/", "/styles/global.css", "/favicon.svg", "/favicon.ico", "/robots.txt"];
// Keep media cache bounded so we don't grow indefinitely.
const MAX_MEDIA_ENTRIES = 80;
const isGet = (request) => request && request.method === "GET";
const isNavigationRequest = (request) =>
request.mode === "navigate" || request.destination === "document";
const isImageRequest = (request, url) => {
if (request.destination === "image") return true;
const p = url.pathname.toLowerCase();
return (
p.endsWith(".png") ||
p.endsWith(".jpg") ||
p.endsWith(".jpeg") ||
p.endsWith(".webp") ||
p.endsWith(".gif") ||
p.endsWith(".avif") ||
p.endsWith(".svg")
);
};
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const extra = keys.length - maxEntries;
if (extra <= 0) return;
// Cache keys are returned in insertion order in practice; delete the oldest.
for (let i = 0; i < extra; i += 1) {
await cache.delete(keys[i]);
}
}
async function cachePutSafe(cacheName, request, response) {
// Only cache successful or opaque responses. Avoid caching 404/500 HTML.
if (!response) return;
if (response.type !== "opaque" && !response.ok) return;
const cache = await caches.open(cacheName);
await cache.put(request, response);
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_SHELL);
await cache.addAll(SHELL_ASSETS);
// Activate new worker ASAP to pick up new caching rules.
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keep = new Set([CACHE_SHELL, CACHE_PAGES, CACHE_MEDIA]);
const keys = await caches.keys();
await Promise.all(keys.map((k) => (keep.has(k) ? Promise.resolve() : caches.delete(k))));
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (!isGet(request)) return;
const url = new URL(request.url);
// Only handle http(s).
if (url.protocol !== "http:" && url.protocol !== "https:") return;
// Network-first for navigations (HTML documents). Cache as fallback only.
if (isNavigationRequest(request)) {
event.respondWith(
(async () => {
try {
const fresh = await fetch(request);
// Cache a clone so we can serve it when offline.
await cachePutSafe(CACHE_PAGES, request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
// Fallback: try cached homepage shell.
const home = await caches.match("/");
if (home) return home;
throw new Error("No cached navigation fallback.");
}
})(),
);
return;
}
// Cache-first for images/media with bounded cache size.
if (isImageRequest(request, url)) {
event.respondWith(
(async () => {
const cached = await caches.match(request);
if (cached) return cached;
const res = await fetch(request);
await cachePutSafe(CACHE_MEDIA, request, res.clone());
await trimCache(CACHE_MEDIA, MAX_MEDIA_ENTRIES);
return res;
})(),
);
return;
}
// Stale-while-revalidate for styles/scripts/fonts from same-origin.
if (
url.origin === self.location.origin &&
(request.destination === "style" ||
request.destination === "script" ||
request.destination === "font")
) {
event.respondWith(
(async () => {
const cached = await caches.match(request);
const networkPromise = fetch(request)
.then(async (res) => {
await cachePutSafe(CACHE_SHELL, request, res.clone());
return res;
})
.catch(() => null);
return cached || (await networkPromise) || fetch(request);
})(),
);
}
});

View File

@@ -40,6 +40,14 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<!-- Display-friendly font (swap to avoid blocking render). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles/global.css" />
{
@@ -47,8 +55,27 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
<script async defer data-website-id={cfg.umami.websiteId} src={cfg.umami.scriptUrl} />
) : null
}
{
// Register SW only in production builds (Astro sets import.meta.env.PROD at build time).
import.meta.env.PROD ? (
<script is:inline>
{`
if ("serviceWorker" in navigator) {
// SW requires HTTPS (or localhost). In prod we expect HTTPS.
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {
// noop: SW is progressive enhancement
});
});
}
`}
</script>
) : null
}
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header">
<a
class="brand"
@@ -60,7 +87,18 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
>
SanthoshJ
</a>
<nav class="nav">
<button
class="nav-toggle"
type="button"
aria-controls="primary-nav"
aria-expanded="false"
aria-label="Open menu"
data-nav-toggle
>
<span class="nav-toggle-icon" aria-hidden="true"></span>
</button>
<nav class="nav" id="primary-nav" data-open="false">
<a
href="/videos"
data-umami-event="click"
@@ -88,24 +126,83 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
>
Blog
</a>
<a
href="/about"
data-umami-event="click"
data-umami-event-target_id="nav.about"
data-umami-event-placement="nav"
data-umami-event-target_url="/about"
>
About
</a>
</nav>
</header>
<main class="container">
<main class="container" id="main-content">
<slot />
</main>
<footer class="site-footer">
<p class="muted">© {new Date().getFullYear()} SanthoshJ</p>
<p class="muted">&copy; {new Date().getFullYear()} SanthoshJ</p>
</footer>
<script is:inline>
(() => {
const toggle = document.querySelector("[data-nav-toggle]");
if (!toggle) return;
const controlsId = toggle.getAttribute("aria-controls");
if (!controlsId) return;
const panel = document.getElementById(controlsId);
if (!panel) return;
const mql = window.matchMedia("(max-width: 760px)");
const setOpen = (open) => {
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
panel.dataset.open = open ? "true" : "false";
// Only hide/disable the nav panel in mobile mode. On desktop, nav is always visible/clickable.
if (mql.matches) {
panel.setAttribute("aria-hidden", open ? "false" : "true");
if (open) panel.removeAttribute("inert");
else panel.setAttribute("inert", "");
} else {
panel.removeAttribute("aria-hidden");
panel.removeAttribute("inert");
}
if (!open) toggle.focus({ preventScroll: true });
};
// Default state: closed on mobile, open on desktop.
setOpen(!mql.matches);
toggle.addEventListener("click", () => {
const isOpen = toggle.getAttribute("aria-expanded") === "true";
setOpen(!isOpen);
if (!isOpen) {
const firstLink = panel.querySelector("a");
if (firstLink) firstLink.focus({ preventScroll: true });
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (toggle.getAttribute("aria-expanded") !== "true") return;
setOpen(false);
});
panel.addEventListener("click", (e) => {
const t = e.target;
if (t && t.closest && t.closest("a")) setOpen(false);
});
document.addEventListener("click", (e) => {
if (toggle.getAttribute("aria-expanded") !== "true") return;
const t = e.target;
if (!t || !t.closest) return;
if (t.closest("[data-nav-toggle]")) return;
if (t.closest("#" + CSS.escape(controlsId))) return;
setOpen(false);
});
// If viewport changes, keep desktop usable and default mobile to closed.
mql.addEventListener("change", () => setOpen(!mql.matches));
})();
</script>
</body>
</html>

View File

@@ -1,34 +0,0 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import CtaLink from "../components/CtaLink.astro";
import { LINKS } from "../lib/links";
---
<BaseLayout
title="About | SanthoshJ"
description="About SanthoshJ and where to follow."
canonicalPath="/about"
>
<section class="section">
<div class="section-header">
<h2>About</h2>
<span class="muted">Tech, streaming, movies, travel</span>
</div>
<div class="empty">
<p style="margin-top: 0;">
This is a lightweight site that aggregates my content so it can be discovered via search and
shared cleanly.
</p>
<div class="cta-row">
<CtaLink platform="youtube" placement="about" url={LINKS.youtubeChannel} label="YouTube" />
<CtaLink
platform="instagram"
placement="about"
url={LINKS.instagramProfile}
label="Instagram"
/>
<CtaLink platform="podcast" placement="about" url={LINKS.podcast} label="Podcast" />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -26,22 +26,21 @@ const pods = podcastEpisodes(cache)
<BaseLayout
title="SanthoshJ | Tech, streaming, movies, travel"
description="A fast, SEO-first home for videos, movie posts, and the Irregular Mind podcast."
description="SanthoshJ shares tech, gaming streams, movie recommendations, and travel stories—plus the Irregular Mind podcast. Explore the newest videos and episodes."
canonicalPath="/"
>
<section class="hero">
<div>
<h1>Fast content. Clear next actions.</h1>
<h1>Tech, gaming, movies & travel — videos + podcast by SanthoshJ</h1>
<p>
I post about technology, game streaming, movies, and travel. This site collects the best of
it and points you to the platform you prefer.
Quick takes and long-form stories: tech explainers, game streaming highlights, movie recommendations, and travel notes—curated in one place.
</p>
<div class="cta-row">
<CtaLink
platform="youtube"
placement="hero"
url={LINKS.youtubeChannel}
label="Subscribe on YouTube"
label="Watch on YouTube"
class="primary"
/>
<CtaLink
@@ -54,7 +53,7 @@ const pods = podcastEpisodes(cache)
platform="podcast"
placement="hero"
url={LINKS.podcast}
label="Listen to the podcast"
label="Listen to the Irregular Mind on Spotify"
/>
</div>
<p class="muted" style="margin-top: 14px;">
@@ -62,10 +61,9 @@ const pods = podcastEpisodes(cache)
</p>
</div>
<div class="empty">
<strong>Goal:</strong> 10% month-over-month growth in followers and engagement.
<br />
<span class="muted"
>This site is the SEO landing surface that turns search traffic into followers.</span
>New videos and episodes every week. Start with the latest drops below.</span
>
</div>
</section>

View File

@@ -0,0 +1,37 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
async function read(rel: string) {
return await readFile(path.join(process.cwd(), rel), "utf8");
}
describe("wcag + responsive shell", () => {
it("adds skip link and main content anchor", async () => {
const src = await read("src/layouts/BaseLayout.astro");
expect(src).toContain('class="skip-link"');
expect(src).toContain('href="#main-content"');
expect(src).toContain('id="main-content"');
});
it("adds a hamburger toggle with ARIA and a script that updates aria-expanded", async () => {
const src = await read("src/layouts/BaseLayout.astro");
expect(src).toContain('class="nav-toggle"');
expect(src).toContain('aria-controls="primary-nav"');
expect(src).toContain('aria-expanded="false"');
expect(src).toContain('id="primary-nav"');
expect(src).toContain("toggle.setAttribute(\"aria-expanded\"");
expect(src).toContain("e.key !== \"Escape\"");
});
it("defines baseline focus-visible, reduced-motion, and oversized background layer", async () => {
const css = await read("public/styles/global.css");
expect(css).toContain("a:focus-visible");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain("body::before");
expect(css).toContain("@media (max-width: 760px)");
expect(css).toContain(".nav[data-open=\"true\"]");
});
});