Theming done
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-10
|
||||||
106
openspec/changes/archive/2026-02-11-dch-theming/design.md
Normal file
106
openspec/changes/archive/2026-02-11-dch-theming/design.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The site currently uses a single dark theme defined via CSS variables in `site/public/styles/global.css` (e.g., `--bg0`, `--bg1`, `--fg`, `--muted`, `--stroke`, `--accent`). There is no existing theme selection mechanism (no `data-theme` attribute, no persisted preference).
|
||||||
|
|
||||||
|
The site shell uses a sticky `.site-header` and a per-page `.subnav` row (when present). A theme switcher notch must be fixed-positioned such that it does not overlap the header and leaves enough space for the subnav region.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Provide three themes: `dark`, `light`, `high-contrast`.
|
||||||
|
- Allow switching themes via a floating notch on the right side of the screen positioned below the primary nav bar and not overlapping the subnav area.
|
||||||
|
- Make switching feel premium:
|
||||||
|
- hover animation on the notch
|
||||||
|
- smooth theme transitions (without an abrupt flash)
|
||||||
|
- Ensure accessibility:
|
||||||
|
- keyboard operable
|
||||||
|
- visible focus
|
||||||
|
- respects `prefers-reduced-motion`
|
||||||
|
- high contrast theme is meaningfully higher contrast, not just a color swap
|
||||||
|
- Persist the user's selection across page loads.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Rebuild the entire visual system or rewrite all CSS to a design-token framework.
|
||||||
|
- PWA theming (manifest/theme-color) beyond what is required to implement UI themes.
|
||||||
|
- Adding user accounts or server-side persistence of theme preference.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Theme selection mechanism: `data-theme` on `<html>`
|
||||||
|
Use `document.documentElement.dataset.theme = "dark" | "light" | "high-contrast"`.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Works cleanly with CSS variables.
|
||||||
|
- Scopes theme styles across the entire page without specificity fights.
|
||||||
|
|
||||||
|
Alternatives considered:
|
||||||
|
- Adding theme classes to `body` (works, but html-scoped variables are simpler for form control theming).
|
||||||
|
|
||||||
|
### 2. Token strategy: override existing CSS variables per theme
|
||||||
|
Keep the existing variable names and provide theme-specific overrides:
|
||||||
|
- `:root` remains the default (dark)
|
||||||
|
- `html[data-theme="light"] { ... }`
|
||||||
|
- `html[data-theme="high-contrast"] { ... }`
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Minimizes churn in existing CSS.
|
||||||
|
- Enables incremental migration of any remaining hard-coded colors to tokens.
|
||||||
|
|
||||||
|
### 3. Default theme resolution order
|
||||||
|
On first load, resolve the active theme in this order:
|
||||||
|
1) stored user preference (`localStorage.theme`)
|
||||||
|
2) forced colors / high-contrast OS mode (if detected) -> `high-contrast`
|
||||||
|
3) system color scheme -> `light` if `prefers-color-scheme: light`, else `dark`
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- User choice wins.
|
||||||
|
- If the user is in a forced/high-contrast environment, defaulting to high-contrast aligns with accessibility intent.
|
||||||
|
|
||||||
|
### 4. Prevent flash of wrong theme with a tiny head script
|
||||||
|
Insert a small inline script in the document `<head>` that sets `data-theme` before first paint.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Avoids "flash" where the page renders in dark before switching to light/high-contrast.
|
||||||
|
|
||||||
|
Trade-off:
|
||||||
|
- Inline scripts can constrain future CSP hardening; keep script small and self-contained.
|
||||||
|
|
||||||
|
### 5. Smooth transitions without animating on every page load
|
||||||
|
Use a transient attribute/class (e.g., `data-theme-transition="on"`) only during user-initiated theme changes.
|
||||||
|
|
||||||
|
Implementation shape:
|
||||||
|
- When switching: set `data-theme-transition="on"`, update `data-theme`, then remove after ~250ms.
|
||||||
|
- CSS applies transitions for color/background/border/shadow only when the attribute is present.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Avoids "everything animates" feeling during initial load.
|
||||||
|
- Avoids subtle jank on navigation.
|
||||||
|
|
||||||
|
### 6. Notch UI: fixed-position, expands on hover/focus
|
||||||
|
Implement the switcher as a fixed-position control at the right edge:
|
||||||
|
- Default collapsed: a small vertical tab.
|
||||||
|
- On `:hover` and `:focus-within`: expands into a small panel exposing the three theme options.
|
||||||
|
|
||||||
|
Accessibility decisions:
|
||||||
|
- Use a real `<button>` to open/close (for touch) OR a `<fieldset role="radiogroup">` with three radio-like buttons.
|
||||||
|
- Ensure it is reachable via keyboard and has clear `aria-label`s.
|
||||||
|
|
||||||
|
Placement decisions:
|
||||||
|
- Use a CSS variable `--theme-notch-top` to position it.
|
||||||
|
- A small inline script computes this based on `.site-header` height and, if a `.subnav` exists near the top, positions below it.
|
||||||
|
|
||||||
|
### 7. High Contrast theme semantics
|
||||||
|
The high-contrast theme will be a dedicated palette (not only increased brightness) with:
|
||||||
|
- strong background/foreground contrast
|
||||||
|
- high visibility focus ring
|
||||||
|
- more assertive stroke borders
|
||||||
|
|
||||||
|
Additionally, handle OS forced-colors mode:
|
||||||
|
- In `@media (forced-colors: active)`, prefer system colors and avoid gradients that reduce clarity.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Notch overlaps content]** -> compute top offset from header/subnav; provide safe-area padding; add responsive rules for small viewports.
|
||||||
|
- **[Theme transitions reduce readability]** -> scope transitions to a short window and limit properties; disable via `prefers-reduced-motion`.
|
||||||
|
- **[High contrast breaks brand feel]** -> keep layout/typography unchanged and only adjust palette and borders.
|
||||||
|
- **[CSP constraints]** -> keep head script minimal and consider moving to an external script if CSP hardening becomes a priority.
|
||||||
27
openspec/changes/archive/2026-02-11-dch-theming/proposal.md
Normal file
27
openspec/changes/archive/2026-02-11-dch-theming/proposal.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Add modern theming controls (dark/light/high-contrast) to improve accessibility and give the site a polished, customizable "WOW" experience.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add three user-selectable themes: **Dark**, **Light**, and **High Contrast**.
|
||||||
|
- Add a floating theme switcher "notch" on the right edge of the screen:
|
||||||
|
- positioned just below the primary nav bar
|
||||||
|
- leaves enough vertical space for secondary navigation
|
||||||
|
- hover state includes a tasteful animation
|
||||||
|
- Theme switching uses a smooth transition (not an abrupt flash).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `site-theming`: Theme tokens and a theme application mechanism that can switch between Dark/Light/High Contrast across the site.
|
||||||
|
- `theme-switcher-notch`: A floating, accessible UI control (right-side notch) that lets the user switch themes.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `wcag-responsive-ui`: Extend accessibility baseline to include theme switching requirements (keyboard, focus, reduced motion) and ensure High Contrast theme is supported.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected UI/CSS: global design tokens (CSS variables), background layers, card/CTA styling, focus styling.
|
||||||
|
- Affected layout: a new floating notch component that must not overlap navigation across breakpoints.
|
||||||
|
- Affected UX/accessibility: keyboard navigation and motion preferences during theme transitions.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Site themes
|
||||||
|
The site MUST support three themes:
|
||||||
|
- `dark`
|
||||||
|
- `light`
|
||||||
|
- `high-contrast`
|
||||||
|
|
||||||
|
Themes MUST be applied by setting a `data-theme` attribute on the root document element (`<html>`).
|
||||||
|
|
||||||
|
#### Scenario: Dark theme active
|
||||||
|
- **WHEN** `data-theme="dark"` is set on `<html>`
|
||||||
|
- **THEN** the site's background, text, and component styling reflect the dark palette
|
||||||
|
|
||||||
|
#### Scenario: Light theme active
|
||||||
|
- **WHEN** `data-theme="light"` is set on `<html>`
|
||||||
|
- **THEN** the site's background, text, and component styling reflect the light palette
|
||||||
|
|
||||||
|
#### Scenario: High contrast theme active
|
||||||
|
- **WHEN** `data-theme="high-contrast"` is set on `<html>`
|
||||||
|
- **THEN** the site uses a high-contrast palette with a clearly visible focus ring and high-contrast borders
|
||||||
|
|
||||||
|
### Requirement: Theme persistence
|
||||||
|
The site MUST persist the user's theme selection so it is retained across page loads and navigations.
|
||||||
|
|
||||||
|
Persistence MUST be stored locally in the browser (e.g., localStorage).
|
||||||
|
|
||||||
|
#### Scenario: Theme persists across reload
|
||||||
|
- **WHEN** the user selects `light` theme and reloads the page
|
||||||
|
- **THEN** the `light` theme remains active
|
||||||
|
|
||||||
|
### Requirement: Default theme selection
|
||||||
|
If the user has not explicitly selected a theme, the site MUST choose a default theme using environment signals.
|
||||||
|
|
||||||
|
Default selection order:
|
||||||
|
1) If forced colors / high-contrast mode is active, default to `high-contrast`
|
||||||
|
2) Else if the system prefers light color scheme, default to `light`
|
||||||
|
3) Else default to `dark`
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system settings
|
||||||
|
- **WHEN** the user has no stored theme preference
|
||||||
|
- **THEN** the site selects a default theme based on forced-colors and prefers-color-scheme
|
||||||
|
|
||||||
|
### Requirement: Theme switching transition
|
||||||
|
Theme changes initiated by the user MUST transition smoothly.
|
||||||
|
|
||||||
|
The transition MUST be disabled or substantially reduced when `prefers-reduced-motion: reduce` is set.
|
||||||
|
|
||||||
|
#### Scenario: Smooth transition on switch
|
||||||
|
- **WHEN** the user switches from `dark` to `light` theme
|
||||||
|
- **THEN** theme-affecting properties transition smoothly instead of abruptly switching
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables theme animation
|
||||||
|
- **WHEN** `prefers-reduced-motion: reduce` is set and the user switches theme
|
||||||
|
- **THEN** the theme change occurs without noticeable animation
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Floating theme switcher notch
|
||||||
|
The site MUST provide a floating theme switcher control anchored to the right side of the viewport.
|
||||||
|
|
||||||
|
The control MUST be positioned below the primary navigation bar and MUST leave sufficient vertical space for secondary navigation.
|
||||||
|
|
||||||
|
#### Scenario: Notch positioned below header
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** the theme switcher notch is visible on the right side and does not overlap the sticky header or sub-navigation
|
||||||
|
|
||||||
|
### Requirement: Notch interaction and animation
|
||||||
|
The notch MUST provide a hover affordance (a small, tasteful animation) that indicates it is interactive.
|
||||||
|
|
||||||
|
The hover animation MUST be disabled or substantially reduced under `prefers-reduced-motion: reduce`.
|
||||||
|
|
||||||
|
#### Scenario: Hover animation present
|
||||||
|
- **WHEN** a pointer user hovers the notch
|
||||||
|
- **THEN** the notch animates in a way that suggests it can be expanded or interacted with
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables hover animation
|
||||||
|
- **WHEN** `prefers-reduced-motion: reduce` is set
|
||||||
|
- **THEN** hovering the notch does not trigger a noticeable animation
|
||||||
|
|
||||||
|
### Requirement: Theme selection UI
|
||||||
|
The notch MUST expose the three theme options (`dark`, `light`, `high-contrast`) and allow the user to select one.
|
||||||
|
|
||||||
|
The control MUST be keyboard accessible:
|
||||||
|
- it MUST be reachable via `Tab`
|
||||||
|
- it MUST have a visible focus indicator
|
||||||
|
- selection MUST be possible using keyboard input
|
||||||
|
|
||||||
|
#### Scenario: Keyboard selects theme
|
||||||
|
- **WHEN** a keyboard user focuses the notch and selects `high-contrast`
|
||||||
|
- **THEN** the site updates to the `high-contrast` theme and the selection is persisted
|
||||||
|
|
||||||
|
### Requirement: Accessibility labels
|
||||||
|
The notch and theme options MUST have accessible labels.
|
||||||
|
|
||||||
|
#### Scenario: Screen reader announces theme switcher
|
||||||
|
- **WHEN** a screen reader user focuses the theme switcher control
|
||||||
|
- **THEN** it announces an appropriate label (e.g., "Theme" or "Theme switcher") and the currently selected theme
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Theme switching accessibility
|
||||||
|
Theme switching controls MUST be accessible and usable with keyboard and assistive technology.
|
||||||
|
|
||||||
|
The theme switcher control MUST:
|
||||||
|
- be reachable via keyboard navigation
|
||||||
|
- provide a visible focus indication
|
||||||
|
- expose an accessible name/label
|
||||||
|
- allow selecting any supported theme without requiring a pointer
|
||||||
|
|
||||||
|
#### Scenario: Theme switcher is keyboard reachable
|
||||||
|
- **WHEN** a keyboard user tabs through the page
|
||||||
|
- **THEN** the theme switcher notch receives focus and shows a visible focus indicator
|
||||||
|
|
||||||
|
#### Scenario: Theme switcher is labeled
|
||||||
|
- **WHEN** a screen reader user focuses the theme switcher
|
||||||
|
- **THEN** it announces a meaningful label and the current theme state
|
||||||
|
|
||||||
|
### Requirement: High contrast theme meets WCAG intent
|
||||||
|
The `high-contrast` theme MUST provide materially higher contrast than the default theme.
|
||||||
|
|
||||||
|
The theme MUST keep text readable and interactive affordances obvious, including:
|
||||||
|
- strong foreground/background contrast
|
||||||
|
- clearly visible focus ring
|
||||||
|
- strong borders on interactive elements
|
||||||
|
|
||||||
|
#### Scenario: High contrast theme improves readability
|
||||||
|
- **WHEN** the user enables `high-contrast` theme
|
||||||
|
- **THEN** primary text and secondary UI labels remain clearly readable and interactive elements are visually distinct
|
||||||
26
openspec/changes/archive/2026-02-11-dch-theming/tasks.md
Normal file
26
openspec/changes/archive/2026-02-11-dch-theming/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## 1. Theme Tokens And Application
|
||||||
|
|
||||||
|
- [x] 1.1 Add `data-theme` overrides in `site/public/styles/global.css` for `light` and `high-contrast` (keep `:root` as default dark)
|
||||||
|
- [x] 1.2 Add `color-scheme` rules per theme so native form controls match (dark/light)
|
||||||
|
- [x] 1.3 Add theme initialization script in `site/src/layouts/BaseLayout.astro` to set theme before first paint (stored preference → forced colors/high contrast → prefers-color-scheme)
|
||||||
|
- [x] 1.4 Persist theme selection to localStorage and update `data-theme` on change
|
||||||
|
- [x] 1.5 Implement scoped smooth transitions for user-initiated theme changes (no global transition on initial load)
|
||||||
|
|
||||||
|
## 2. Theme Switcher Notch UI
|
||||||
|
|
||||||
|
- [x] 2.1 Add markup for a fixed-position right-side notch in `site/src/layouts/BaseLayout.astro`
|
||||||
|
- [x] 2.2 Implement notch positioning below `.site-header` and below `.subnav` when present (compute top offset; handle resize)
|
||||||
|
- [x] 2.3 Add notch hover animation (expand/slide) and ensure it feels intentional
|
||||||
|
- [x] 2.4 Add keyboard and screen reader support (label, focus styles, keyboard selection)
|
||||||
|
- [x] 2.5 Ensure notch does not overlap critical content on mobile (responsive rules; safe-area)
|
||||||
|
|
||||||
|
## 3. High Contrast Theme Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Ensure high-contrast theme has strong fg/bg contrast, obvious focus ring, and strong strokes on interactive elements
|
||||||
|
- [x] 3.2 Add `@media (forced-colors: active)` adjustments to avoid illegible gradients and ensure system colors are respected
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [x] 4.1 Run `npm run build` and verify output HTML includes the head theme-init script
|
||||||
|
- [x] 4.2 Manual smoke test: switch themes on `/`, `/videos`, `/podcast`, `/blog` and verify persistence across reload
|
||||||
|
- [x] 4.3 Manual a11y checks: keyboard-only interaction, focus visibility, prefers-reduced-motion behavior
|
||||||
92
openspec/changes/lighthouse-remediation/design.md
Normal file
92
openspec/changes/lighthouse-remediation/design.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Chrome Lighthouse runs against `https://santhoshj.com/` (desktop + mobile) report several audits that prevent a 100 score.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
- Desktop report: `C:\Users\simpl\Downloads\santhoshj.com-20260210T182644.json`
|
||||||
|
- Mobile report: `C:\Users\simpl\Downloads\santhoshj.com-20260210T182538.json`
|
||||||
|
|
||||||
|
Key failing audits (non-exhaustive):
|
||||||
|
- Accessibility: `color-contrast`
|
||||||
|
- SEO: `robots-txt`, `crawlable-anchors`
|
||||||
|
- Best Practices: `inspector-issues` (Content Security Policy)
|
||||||
|
- Performance: `render-blocking-insight`, `image-delivery-insight`, `unminified-css`, `unused-css-rules`, `unused-javascript`, `cache-insight` (plus mobile LCP/TTI pressure)
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Site is a static Astro build served behind Docker Compose + reverse proxy.
|
||||||
|
- Some assets are third-party (YouTube thumbnails, CloudFront podcast images, Umami script). These can influence some performance/cache audits and must be handled carefully (reduce impact where possible, but avoid breaking content).
|
||||||
|
- Service worker script MUST remain at stable URL `/sw.js` and should not be versioned via query string.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Achieve a 100 Lighthouse rating on the homepage in all categories (Performance, Accessibility, Best Practices, SEO) using the audits provided.
|
||||||
|
- Make contrast compliant (WCAG AA) for secondary text and pill/chip labels.
|
||||||
|
- Ensure SEO hygiene:
|
||||||
|
- `robots.txt` includes a valid (absolute) sitemap URL
|
||||||
|
- interactive elements do not render anchor tags without `href`.
|
||||||
|
- Remove DevTools Issues panel findings related to CSP by implementing an explicit CSP baseline that matches site needs.
|
||||||
|
- Reduce render-blocking requests and improve asset delivery so mobile LCP/TTI is consistently fast.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Redesigning the site's visual identity or typography scale.
|
||||||
|
- Removing all third-party content sources (YouTube thumbnails, podcast cover images) or analytics.
|
||||||
|
- Building a full PWA manifest/offline-first experience (out of scope).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Contrast remediation via token-level CSS adjustments**
|
||||||
|
Rationale: Lighthouse flags specific selectors in card footers and pills. Fixing contrast at the token level (e.g., `--muted`, pill bg/fg) avoids per-component overrides and reduces regressions.
|
||||||
|
Alternatives:
|
||||||
|
- Component-local overrides (harder to maintain, easy to miss).
|
||||||
|
|
||||||
|
2. **Robots sitemap MUST be absolute**
|
||||||
|
Rationale: Lighthouse treats `Sitemap: /sitemap-index.xml` as invalid. Robots will be updated to point at the full absolute URL.
|
||||||
|
Alternatives:
|
||||||
|
- Switch to `sitemap.xml` only (not desired; site already emits sitemap-index).
|
||||||
|
|
||||||
|
3. **No anchor elements without href in rendered HTML**
|
||||||
|
Rationale: Lighthouse flags the media modal anchors because they exist at load time without `href` (populated later by JS). Fix by using buttons for non-navigational actions, and ensuring any `<a>` is rendered with a valid `href` in initial HTML (or not rendered until it has one).
|
||||||
|
Alternatives:
|
||||||
|
- Keep anchors and set `href="#"` (still crawlable but semantically wrong, and can degrade UX).
|
||||||
|
|
||||||
|
4. **CSP baseline implemented at the edge (reverse proxy), compatible with site JS**
|
||||||
|
Rationale: DevTools Issues panel reports CSP issues. Implement a CSP that matches current needs (site inline scripts, Umami, fonts, images, frames) and remove/avoid inline scripts where possible to keep CSP strict.
|
||||||
|
Alternatives:
|
||||||
|
- Avoid CSP entirely (does not resolve audit and leaves security posture ambiguous).
|
||||||
|
|
||||||
|
5. **Font delivery: prefer self-hosting to remove render-blocking third-party CSS**
|
||||||
|
Rationale: Lighthouse render-blocking points to Google Fonts stylesheet. Self-hosting WOFF2 and using `@font-face` reduces blocking and improves reliability.
|
||||||
|
Alternatives:
|
||||||
|
- Keep Google Fonts and rely on preload hints (still incurs third-party CSS request; harder to reach 100).
|
||||||
|
|
||||||
|
6. **CSS delivery: move global CSS into the build pipeline (minified output)**
|
||||||
|
Rationale: Lighthouse flags unminified/unused CSS. Keeping `global.css` as a raw file in `public/` makes it harder to guarantee minification/unused pruning. Prefer having Astro/Vite handle minification and (where possible) pruning.
|
||||||
|
Alternatives:
|
||||||
|
- Add a bespoke minify step for `public/styles/global.css` (works, but adds build complexity and can drift).
|
||||||
|
|
||||||
|
7. **Caching headers: stable URLs get revalidated; fingerprinted assets get long-lived caching**
|
||||||
|
Rationale: Lighthouse cache-lifetime audit penalizes short cache lifetimes on first-party CSS/JS. For assets that are not fingerprinted (e.g., `/sw.js`, possibly `/styles/global.css`), use `no-cache` or revalidation to avoid staleness. For fingerprinted build outputs, use long-lived caching.
|
||||||
|
Alternatives:
|
||||||
|
- Querystring versioning on SW (known pitfall; can break update chain).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[CSP breaks site behavior]** → Start with Report-Only CSP, verify in production, then enforce. Prefer eliminating inline scripts to avoid `unsafe-inline`.
|
||||||
|
- **[Self-hosting fonts changes appearance slightly]** → Keep the same Manrope font files and weights, verify typography visually.
|
||||||
|
- **[Optimizing images reduces perceived sharpness]** → Use responsive images and appropriate sizes; keep high-DPR support via srcset.
|
||||||
|
- **[Third-party cache lifetimes cannot be controlled]** → Focus on first-party cache headers and reduce critical path reliance on third-party where possible.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Reproduce Lighthouse findings from a clean Chrome profile (no extensions) for both mobile/desktop.
|
||||||
|
2. Apply fixes in small slices (contrast, robots/anchors, CSP, fonts, CSS pipeline/minify, image delivery).
|
||||||
|
3. Deploy behind the reverse proxy with Report-Only CSP first.
|
||||||
|
4. Re-run Lighthouse on production URL until 100 is reached and stable.
|
||||||
|
5. Enable enforced CSP after confirming no violations.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What exact CSP issue is being reported in the DevTools Issues panel (violation message)? Lighthouse only surfaces the issue type without sub-items.
|
||||||
|
- Do we want to keep Google Fonts or commit to self-hosting fonts for maximum Lighthouse consistency?
|
||||||
|
- For cache-lifetime scoring: do we want to introduce fingerprinted CSS output (preferred) or add explicit versioning for `/styles/global.css`?
|
||||||
37
openspec/changes/lighthouse-remediation/proposal.md
Normal file
37
openspec/changes/lighthouse-remediation/proposal.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Increase technical robustness by remediating the specific issues flagged by Chrome Lighthouse so the primary surface (homepage) can achieve a 100 score across categories.
|
||||||
|
|
||||||
|
Lighthouse sources:
|
||||||
|
- `C:\Users\simpl\Downloads\santhoshj.com-20260210T182644.json` (desktop)
|
||||||
|
- `C:\Users\simpl\Downloads\santhoshj.com-20260210T182538.json` (mobile)
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Fix accessibility contrast failures (WCAG AA) for card metadata and pill chips.
|
||||||
|
- Fix SEO hygiene issues:
|
||||||
|
- `robots.txt` must reference a valid sitemap URL
|
||||||
|
- eliminate non-crawlable anchor markup that Lighthouse flags (e.g., anchors without `href` in modal UI)
|
||||||
|
- Eliminate Best Practices "Issues" panel findings related to Content Security Policy.
|
||||||
|
- Improve Performance audits that prevent a perfect score, primarily on mobile:
|
||||||
|
- optimize above-the-fold image delivery (thumbnails/covers)
|
||||||
|
- reduce render-blocking resources (font CSS)
|
||||||
|
- ensure CSS/JS delivery is optimized (minification and unused code reduction)
|
||||||
|
- improve cache lifetimes where applicable for first-party assets, and mitigate third-party cache lifetime penalties where feasible.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `asset-delivery-optimization`: Ensure critical assets (CSS/fonts/images) are delivered in a Lighthouse-friendly way (minified, non-blocking where possible, and appropriately cached) and that mobile LCP is consistently fast.
|
||||||
|
- `security-headers`: Define and implement a CSP baseline and related headers that eliminate DevTools "Issues" panel findings without breaking third-party integrations.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `wcag-responsive-ui`: Strengthen the baseline to explicitly require sufficient text contrast for secondary/muted UI text and pill/chip labels so the site passes Lighthouse contrast checks.
|
||||||
|
- `seo-content-surface`: Strengthen robots + sitemap correctness and require that link-like UI uses crawlable markup (valid `href` when an anchor is used).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected UI/CSS: card footer metadata (`.muted`) and pill styling; modal CTA markup.
|
||||||
|
- Affected SEO assets: `robots.txt` sitemap line.
|
||||||
|
- Affected security posture: CSP and related headers (may require changes to how third-party scripts are loaded).
|
||||||
|
- Affected performance: image source/size strategy for thumbnails/covers; font delivery strategy; CSS/JS build pipeline and cache headers.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Render-blocking resources are minimized
|
||||||
|
The site MUST minimize render-blocking resources on the critical path.
|
||||||
|
|
||||||
|
Font delivery MUST NOT rely on a render-blocking third-party stylesheet.
|
||||||
|
|
||||||
|
#### Scenario: Homepage avoids render-blocking font CSS
|
||||||
|
- **WHEN** Lighthouse audits the homepage
|
||||||
|
- **THEN** the Google Fonts stylesheet request is not present as a render-blocking resource (fonts are self-hosted or otherwise delivered without a blocking CSS request)
|
||||||
|
|
||||||
|
### Requirement: First-party CSS and JS are optimized for Lighthouse
|
||||||
|
First-party CSS and JS delivered to the browser MUST be minified in production builds.
|
||||||
|
|
||||||
|
The site MUST minimize unused CSS and unused JavaScript on the homepage.
|
||||||
|
|
||||||
|
#### Scenario: CSS is minified
|
||||||
|
- **WHEN** a production build is served
|
||||||
|
- **THEN** `styles/global.css` (or its replacement) is minified
|
||||||
|
|
||||||
|
#### Scenario: Homepage avoids unused JS penalties
|
||||||
|
- **WHEN** Lighthouse audits the homepage
|
||||||
|
- **THEN** the amount of unused JavaScript on initial load is below Lighthouse's failing threshold
|
||||||
|
|
||||||
|
### Requirement: Images are delivered efficiently
|
||||||
|
Images used on listing surfaces MUST be delivered in a size appropriate to their rendered dimensions.
|
||||||
|
|
||||||
|
For thumbnail-like images, the site SHOULD prefer image sources that support resizing or multiple resolutions when feasible.
|
||||||
|
|
||||||
|
#### Scenario: Podcast cover image is not oversized
|
||||||
|
- **WHEN** the homepage renders a podcast episode card
|
||||||
|
- **THEN** the fetched cover image size is reasonably close to the displayed size (no large wasted bytes flagged by Lighthouse)
|
||||||
|
|
||||||
|
### Requirement: Cache lifetimes are efficient for first-party assets
|
||||||
|
First-party static assets (CSS/JS/fonts/images served from the site origin) MUST be served with cache headers that enable efficient repeat visits.
|
||||||
|
|
||||||
|
Non-fingerprinted assets MUST be served with revalidation (e.g., `no-cache` or `max-age=0,must-revalidate`) to avoid staleness.
|
||||||
|
|
||||||
|
Fingerprinted assets (build outputs) MUST be served with a long-lived immutable cache policy.
|
||||||
|
|
||||||
|
#### Scenario: First-party CSS has efficient caching
|
||||||
|
- **WHEN** Lighthouse audits the homepage
|
||||||
|
- **THEN** first-party CSS cache lifetimes are not flagged as inefficient
|
||||||
|
|
||||||
|
#### Scenario: Service worker script is revalidated
|
||||||
|
- **WHEN** the browser checks `/sw.js` for updates
|
||||||
|
- **THEN** the HTTP cache is bypassed or revalidated so an updated service worker can be fetched promptly
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Content Security Policy baseline
|
||||||
|
The deployed site MUST include a Content Security Policy (CSP) that is compatible with the site's runtime behavior and third-party integrations.
|
||||||
|
|
||||||
|
The CSP MUST be strict enough to avoid DevTools Issues panel findings related to CSP and MUST NOT rely on a permissive `*` wildcard for script sources.
|
||||||
|
|
||||||
|
The CSP MUST allow:
|
||||||
|
- the site's own scripts and styles
|
||||||
|
- the configured analytics script origin (Umami)
|
||||||
|
- required image origins (e.g., YouTube thumbnail host and podcast image CDN)
|
||||||
|
- required frame origins (e.g., YouTube and Spotify embeds)
|
||||||
|
|
||||||
|
#### Scenario: No CSP issues logged
|
||||||
|
- **WHEN** a user loads the homepage in Chrome
|
||||||
|
- **THEN** no CSP-related issues are reported in the DevTools Issues panel
|
||||||
|
|
||||||
|
### Requirement: Avoid inline-script CSP violations
|
||||||
|
The site SHOULD minimize the use of inline scripts to avoid requiring `unsafe-inline` in CSP.
|
||||||
|
|
||||||
|
If inline scripts are necessary, the CSP MUST use a nonce-based or hash-based approach.
|
||||||
|
|
||||||
|
#### Scenario: Inline scripts do not require unsafe-inline
|
||||||
|
- **WHEN** the site is served with CSP enabled
|
||||||
|
- **THEN** the policy does not require `script-src 'unsafe-inline'` to function
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Sitemap and robots
|
||||||
|
The site MUST provide:
|
||||||
|
- `sitemap.xml` enumerating indexable pages
|
||||||
|
- `robots.txt` that allows indexing of indexable pages
|
||||||
|
|
||||||
|
The sitemap MUST include the blog surface routes:
|
||||||
|
- `/blog`
|
||||||
|
- blog post detail routes
|
||||||
|
- blog page detail routes
|
||||||
|
- blog category listing routes
|
||||||
|
|
||||||
|
`robots.txt` MUST include a `Sitemap:` directive with an absolute URL to the sitemap (or sitemap index) and MUST NOT use a relative sitemap URL.
|
||||||
|
|
||||||
|
#### Scenario: Sitemap is available
|
||||||
|
- **WHEN** a crawler requests `/sitemap.xml`
|
||||||
|
- **THEN** the server returns an XML sitemap listing `/`, `/videos`, `/podcast`, `/about`, and `/blog`
|
||||||
|
|
||||||
|
#### Scenario: Blog URLs appear in sitemap
|
||||||
|
- **WHEN** WordPress content is available in the cache at build time
|
||||||
|
- **THEN** the generated sitemap includes the blog detail URLs for those items
|
||||||
|
|
||||||
|
#### Scenario: robots.txt includes absolute sitemap URL
|
||||||
|
- **WHEN** a crawler requests `/robots.txt`
|
||||||
|
- **THEN** the response includes a `Sitemap:` directive with an absolute URL (e.g., `https://<domain>/sitemap-index.xml`) that Lighthouse and crawlers can parse
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Crawlable link markup
|
||||||
|
The site MUST NOT render anchor elements (`<a>`) without an `href` attribute.
|
||||||
|
|
||||||
|
Interactive UI that does not navigate MUST use a `<button>` element (or equivalent role) instead of a placeholder anchor.
|
||||||
|
|
||||||
|
If an anchor is used for navigation, it MUST include a valid `href` in the initial HTML (not only populated later by client-side JavaScript).
|
||||||
|
|
||||||
|
#### Scenario: Modal CTAs are crawlable
|
||||||
|
- **WHEN** the homepage is loaded and the media modal markup exists in the DOM
|
||||||
|
- **THEN** any CTA anchors within the modal have valid `href` values, or are not rendered as anchors until an `href` is available
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Color contrast for secondary UI text and chips
|
||||||
|
The site MUST meet WCAG 2.2 AA contrast requirements for non-decorative text, including secondary/muted metadata text and pill/chip labels.
|
||||||
|
|
||||||
|
This includes (but is not limited to):
|
||||||
|
- card footer date and view-count text
|
||||||
|
- pill/chip labels (e.g., source labels)
|
||||||
|
|
||||||
|
The contrast ratio MUST be at least 4.5:1 for normal text.
|
||||||
|
|
||||||
|
#### Scenario: Card metadata contrast passes
|
||||||
|
- **WHEN** a content card is rendered with date and view-count metadata
|
||||||
|
- **THEN** the metadata text has a contrast ratio of at least 4.5:1 against its background
|
||||||
|
|
||||||
|
#### Scenario: Pill label contrast passes
|
||||||
|
- **WHEN** a pill/chip label is rendered (e.g., a source label)
|
||||||
|
- **THEN** the pill label text has a contrast ratio of at least 4.5:1 against the pill background
|
||||||
39
openspec/changes/lighthouse-remediation/tasks.md
Normal file
39
openspec/changes/lighthouse-remediation/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
## 1. Baseline And Repro
|
||||||
|
|
||||||
|
- [ ] 1.1 Run Lighthouse from a clean Chrome profile (no extensions) for both Mobile and Desktop and save reports (JSON)
|
||||||
|
- [ ] 1.2 Record current failing audits and their affected selectors/URLs (from the Lighthouse "details" tables)
|
||||||
|
|
||||||
|
## 2. Accessibility Contrast
|
||||||
|
|
||||||
|
- [ ] 2.1 Adjust global CSS tokens/styles so `.muted` card metadata meets 4.5:1 contrast on cards
|
||||||
|
- [ ] 2.2 Adjust pill/chip background + text colors to meet 4.5:1 contrast (e.g., `.pill` and source variants)
|
||||||
|
- [ ] 2.3 Re-run Lighthouse accessibility category and confirm `color-contrast` passes
|
||||||
|
|
||||||
|
## 3. SEO Hygiene (robots + crawlable links)
|
||||||
|
|
||||||
|
- [ ] 3.1 Update `site/public/robots.txt` to use an absolute sitemap URL (e.g., `Sitemap: https://santhoshj.com/sitemap-index.xml`)
|
||||||
|
- [ ] 3.2 Fix non-crawlable anchors in the media modal by ensuring anchors always have `href` in initial HTML or switching to buttons until navigable
|
||||||
|
- [ ] 3.3 Re-run Lighthouse SEO category and confirm `robots-txt` and `crawlable-anchors` pass
|
||||||
|
|
||||||
|
## 4. CSP / Best Practices
|
||||||
|
|
||||||
|
- [ ] 4.1 Identify the exact CSP-related DevTools Issue message (Chrome DevTools → Issues) and capture the text
|
||||||
|
- [ ] 4.2 Implement a CSP baseline at the reverse proxy/origin that allows required resources (self + Umami + image/frame origins) and avoids permissive wildcards
|
||||||
|
- [ ] 4.3 Reduce inline scripts that force `unsafe-inline` (move registration / modal scripts to external files or use nonce/hash approach)
|
||||||
|
- [ ] 4.4 Re-run Lighthouse Best Practices and confirm `inspector-issues` passes
|
||||||
|
|
||||||
|
## 5. Performance: Fonts, CSS/JS, And Images
|
||||||
|
|
||||||
|
- [ ] 5.1 Remove render-blocking third-party font stylesheet by self-hosting Manrope and loading via `@font-face`
|
||||||
|
- [ ] 5.2 Ensure production CSS is minified (move global CSS into the build pipeline or add a build minification step)
|
||||||
|
- [ ] 5.3 Reduce unused CSS on the homepage (prune unused selectors or split critical vs non-critical styles)
|
||||||
|
- [ ] 5.4 Reduce unused JS on the homepage (remove unnecessary scripts; ensure analytics is async/defer; avoid extra inline code)
|
||||||
|
- [ ] 5.5 Improve thumbnail image delivery (use responsive `srcset` / resized sources where feasible; avoid oversized podcast covers)
|
||||||
|
- [ ] 5.6 Improve cache lifetimes for first-party static assets (fingerprint + immutable cache for build assets; revalidate non-fingerprinted)
|
||||||
|
- [ ] 5.7 Re-run Lighthouse Performance (mobile + desktop) and confirm 100 score
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
- [ ] 6.1 Run `npm run build` and ensure build succeeds
|
||||||
|
- [ ] 6.2 Smoke test site locally (`npm run preview`) including modal, analytics script load, and service worker registration
|
||||||
|
- [ ] 6.3 Deploy and confirm production Lighthouse scores are 100/100/100/100
|
||||||
59
openspec/specs/site-theming/spec.md
Normal file
59
openspec/specs/site-theming/spec.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define site-wide theme support (dark, light, high-contrast) using CSS tokens and an application mechanism that can switch across the entire UI.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Site themes
|
||||||
|
The site MUST support three themes:
|
||||||
|
- `dark`
|
||||||
|
- `light`
|
||||||
|
- `high-contrast`
|
||||||
|
|
||||||
|
Themes MUST be applied by setting a `data-theme` attribute on the root document element (`<html>`).
|
||||||
|
|
||||||
|
#### Scenario: Dark theme active
|
||||||
|
- **WHEN** `data-theme="dark"` is set on `<html>`
|
||||||
|
- **THEN** the site's background, text, and component styling reflect the dark palette
|
||||||
|
|
||||||
|
#### Scenario: Light theme active
|
||||||
|
- **WHEN** `data-theme="light"` is set on `<html>`
|
||||||
|
- **THEN** the site's background, text, and component styling reflect the light palette
|
||||||
|
|
||||||
|
#### Scenario: High contrast theme active
|
||||||
|
- **WHEN** `data-theme="high-contrast"` is set on `<html>`
|
||||||
|
- **THEN** the site uses a high-contrast palette with a clearly visible focus ring and high-contrast borders
|
||||||
|
|
||||||
|
### Requirement: Theme persistence
|
||||||
|
The site MUST persist the user's theme selection so it is retained across page loads and navigations.
|
||||||
|
|
||||||
|
Persistence MUST be stored locally in the browser (e.g., localStorage).
|
||||||
|
|
||||||
|
#### Scenario: Theme persists across reload
|
||||||
|
- **WHEN** the user selects `light` theme and reloads the page
|
||||||
|
- **THEN** the `light` theme remains active
|
||||||
|
|
||||||
|
### Requirement: Default theme selection
|
||||||
|
If the user has not explicitly selected a theme, the site MUST choose a default theme using environment signals.
|
||||||
|
|
||||||
|
Default selection order:
|
||||||
|
1) If forced colors / high-contrast mode is active, default to `high-contrast`
|
||||||
|
2) Else if the system prefers light color scheme, default to `light`
|
||||||
|
3) Else default to `dark`
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system settings
|
||||||
|
- **WHEN** the user has no stored theme preference
|
||||||
|
- **THEN** the site selects a default theme based on forced-colors and prefers-color-scheme
|
||||||
|
|
||||||
|
### Requirement: Theme switching transition
|
||||||
|
Theme changes initiated by the user MUST transition smoothly.
|
||||||
|
|
||||||
|
The transition MUST be disabled or substantially reduced when `prefers-reduced-motion: reduce` is set.
|
||||||
|
|
||||||
|
#### Scenario: Smooth transition on switch
|
||||||
|
- **WHEN** the user switches from `dark` to `light` theme
|
||||||
|
- **THEN** theme-affecting properties transition smoothly instead of abruptly switching
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables theme animation
|
||||||
|
- **WHEN** `prefers-reduced-motion: reduce` is set and the user switches theme
|
||||||
|
- **THEN** the theme change occurs without noticeable animation
|
||||||
46
openspec/specs/theme-switcher-notch/spec.md
Normal file
46
openspec/specs/theme-switcher-notch/spec.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the requirements for a floating, accessible theme switcher notch anchored to the right side of the viewport.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Floating theme switcher notch
|
||||||
|
The site MUST provide a floating theme switcher control anchored to the right side of the viewport.
|
||||||
|
|
||||||
|
The control MUST be positioned below the primary navigation bar and MUST leave sufficient vertical space for secondary navigation.
|
||||||
|
|
||||||
|
#### Scenario: Notch positioned below header
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** the theme switcher notch is visible on the right side and does not overlap the sticky header or sub-navigation
|
||||||
|
|
||||||
|
### Requirement: Notch interaction and animation
|
||||||
|
The notch MUST provide a hover affordance (a small, tasteful animation) that indicates it is interactive.
|
||||||
|
|
||||||
|
The hover animation MUST be disabled or substantially reduced under `prefers-reduced-motion: reduce`.
|
||||||
|
|
||||||
|
#### Scenario: Hover animation present
|
||||||
|
- **WHEN** a pointer user hovers the notch
|
||||||
|
- **THEN** the notch animates in a way that suggests it can be expanded or interacted with
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables hover animation
|
||||||
|
- **WHEN** `prefers-reduced-motion: reduce` is set
|
||||||
|
- **THEN** hovering the notch does not trigger a noticeable animation
|
||||||
|
|
||||||
|
### Requirement: Theme selection UI
|
||||||
|
The notch MUST expose the three theme options (`dark`, `light`, `high-contrast`) and allow the user to select one.
|
||||||
|
|
||||||
|
The control MUST be keyboard accessible:
|
||||||
|
- it MUST be reachable via `Tab`
|
||||||
|
- it MUST have a visible focus indicator
|
||||||
|
- selection MUST be possible using keyboard input
|
||||||
|
|
||||||
|
#### Scenario: Keyboard selects theme
|
||||||
|
- **WHEN** a keyboard user focuses the notch and selects `high-contrast`
|
||||||
|
- **THEN** the site updates to the `high-contrast` theme and the selection is persisted
|
||||||
|
|
||||||
|
### Requirement: Accessibility labels
|
||||||
|
The notch and theme options MUST have accessible labels.
|
||||||
|
|
||||||
|
#### Scenario: Screen reader announces theme switcher
|
||||||
|
- **WHEN** a screen reader user focuses the theme switcher control
|
||||||
|
- **THEN** it announces an appropriate label (e.g., "Theme" or "Theme switcher") and the currently selected theme
|
||||||
@@ -69,3 +69,31 @@ The site MUST ensure text remains readable:
|
|||||||
- **WHEN** a user navigates between pages
|
- **WHEN** a user navigates between pages
|
||||||
- **THEN** typography (font family and basic scale) remains consistent
|
- **THEN** typography (font family and basic scale) remains consistent
|
||||||
|
|
||||||
|
### Requirement: Theme switching accessibility
|
||||||
|
Theme switching controls MUST be accessible and usable with keyboard and assistive technology.
|
||||||
|
|
||||||
|
The theme switcher control MUST:
|
||||||
|
- be reachable via keyboard navigation
|
||||||
|
- provide a visible focus indication
|
||||||
|
- expose an accessible name/label
|
||||||
|
- allow selecting any supported theme without requiring a pointer
|
||||||
|
|
||||||
|
#### Scenario: Theme switcher is keyboard reachable
|
||||||
|
- **WHEN** a keyboard user tabs through the page
|
||||||
|
- **THEN** the theme switcher notch receives focus and shows a visible focus indicator
|
||||||
|
|
||||||
|
#### Scenario: Theme switcher is labeled
|
||||||
|
- **WHEN** a screen reader user focuses the theme switcher
|
||||||
|
- **THEN** it announces a meaningful label and the current theme state
|
||||||
|
|
||||||
|
### Requirement: High contrast theme meets WCAG intent
|
||||||
|
The `high-contrast` theme MUST provide materially higher contrast than the default theme.
|
||||||
|
|
||||||
|
The theme MUST keep text readable and interactive affordances obvious, including:
|
||||||
|
- strong foreground/background contrast
|
||||||
|
- clearly visible focus ring
|
||||||
|
- strong borders on interactive elements
|
||||||
|
|
||||||
|
#### Scenario: High contrast theme improves readability
|
||||||
|
- **WHEN** the user enables `high-contrast` theme
|
||||||
|
- **THEN** primary text and secondary UI labels remain clearly readable and interactive elements are visually distinct
|
||||||
|
|||||||
@@ -9,6 +9,85 @@
|
|||||||
--accent: #ffcd4a;
|
--accent: #ffcd4a;
|
||||||
--accent2: #5ee4ff;
|
--accent2: #5ee4ff;
|
||||||
--focus: rgba(94, 228, 255, 0.95);
|
--focus: rgba(94, 228, 255, 0.95);
|
||||||
|
|
||||||
|
--stroke-weak: rgba(255, 255, 255, 0.08);
|
||||||
|
--stroke-mid: rgba(255, 255, 255, 0.12);
|
||||||
|
--stroke-strong: rgba(255, 255, 255, 0.18);
|
||||||
|
|
||||||
|
--layer-1: rgba(255, 255, 255, 0.04);
|
||||||
|
--layer-2: rgba(255, 255, 255, 0.06);
|
||||||
|
--layer-3: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
--surface-0: rgba(10, 14, 28, 0.7);
|
||||||
|
--surface-1: rgba(10, 14, 28, 0.92);
|
||||||
|
|
||||||
|
--glow-a: rgba(94, 228, 255, 0.22);
|
||||||
|
--glow-b: rgba(255, 205, 74, 0.18);
|
||||||
|
--glow-c: rgba(140, 88, 255, 0.14);
|
||||||
|
|
||||||
|
--theme-notch-top: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg0: #f7f9fc;
|
||||||
|
--bg1: #e9eef6;
|
||||||
|
--fg: #0b1224;
|
||||||
|
--muted: rgba(11, 18, 36, 0.7);
|
||||||
|
--card: rgba(0, 0, 0, 0.03);
|
||||||
|
--card2: rgba(0, 0, 0, 0.05);
|
||||||
|
--stroke: rgba(0, 0, 0, 0.14);
|
||||||
|
--accent: #b45309;
|
||||||
|
--accent2: #0ea5b7;
|
||||||
|
--focus: rgba(14, 165, 183, 0.85);
|
||||||
|
|
||||||
|
--stroke-weak: rgba(0, 0, 0, 0.08);
|
||||||
|
--stroke-mid: rgba(0, 0, 0, 0.12);
|
||||||
|
--stroke-strong: rgba(0, 0, 0, 0.18);
|
||||||
|
|
||||||
|
--layer-1: rgba(0, 0, 0, 0.03);
|
||||||
|
--layer-2: rgba(0, 0, 0, 0.045);
|
||||||
|
--layer-3: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
--surface-0: rgba(255, 255, 255, 0.78);
|
||||||
|
--surface-1: rgba(255, 255, 255, 0.92);
|
||||||
|
|
||||||
|
--glow-a: rgba(14, 165, 183, 0.18);
|
||||||
|
--glow-b: rgba(180, 83, 9, 0.16);
|
||||||
|
--glow-c: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="high-contrast"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg0: #000000;
|
||||||
|
--bg1: #000000;
|
||||||
|
--fg: #ffffff;
|
||||||
|
--muted: rgba(255, 255, 255, 0.92);
|
||||||
|
--card: rgba(0, 0, 0, 0.85);
|
||||||
|
--card2: rgba(0, 0, 0, 0.92);
|
||||||
|
--stroke: rgba(255, 255, 255, 0.85);
|
||||||
|
--accent: #ffcd4a;
|
||||||
|
--accent2: #5ee4ff;
|
||||||
|
--focus: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
--stroke-weak: rgba(255, 255, 255, 0.55);
|
||||||
|
--stroke-mid: rgba(255, 255, 255, 0.75);
|
||||||
|
--stroke-strong: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
--layer-1: rgba(255, 255, 255, 0.08);
|
||||||
|
--layer-2: rgba(255, 255, 255, 0.12);
|
||||||
|
--layer-3: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
--surface-0: rgba(0, 0, 0, 0.9);
|
||||||
|
--surface-1: rgba(0, 0, 0, 0.96);
|
||||||
|
|
||||||
|
--glow-a: transparent;
|
||||||
|
--glow-b: transparent;
|
||||||
|
--glow-c: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -48,9 +127,9 @@ body::before {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 800px at 10% 10%, rgba(94, 228, 255, 0.22), transparent 60%),
|
radial-gradient(1200px 800px at 10% 10%, var(--glow-a), transparent 60%),
|
||||||
radial-gradient(1100px 800px at 90% 20%, rgba(255, 205, 74, 0.18), transparent 58%),
|
radial-gradient(1100px 800px at 90% 20%, var(--glow-b), transparent 58%),
|
||||||
radial-gradient(1200px 900px at 30% 90%, rgba(140, 88, 255, 0.14), transparent 62%);
|
radial-gradient(1200px 900px at 30% 90%, var(--glow-c), transparent 62%);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -75,8 +154,8 @@ textarea:focus-visible {
|
|||||||
z-index: 999;
|
z-index: 999;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid var(--stroke-strong);
|
||||||
background: rgba(10, 14, 28, 0.92);
|
background: var(--surface-1);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
transform: translateY(-220%);
|
transform: translateY(-220%);
|
||||||
@@ -98,8 +177,8 @@ textarea:focus-visible {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
background: rgba(10, 14, 28, 0.7);
|
background: var(--surface-0);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--stroke-weak);
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,8 +214,8 @@ textarea:focus-visible {
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid var(--stroke-mid);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--layer-1);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +234,12 @@ textarea:focus-visible {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(242, 244, 255, 0.92);
|
background: color-mix(in srgb, var(--fg) 92%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-icon::before {
|
.nav-toggle-icon::before {
|
||||||
top: 0;
|
top: 0;
|
||||||
box-shadow: 0 5px 0 rgba(242, 244, 255, 0.92);
|
box-shadow: 0 5px 0 color-mix(in srgb, var(--fg) 92%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-icon::after {
|
.nav-toggle-icon::after {
|
||||||
@@ -186,11 +265,11 @@ textarea:focus-visible {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid var(--stroke-mid);
|
||||||
background: rgba(10, 14, 28, 0.92);
|
background: var(--surface-1);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 60px rgba(0, 0, 0, 0.55),
|
0 18px 60px rgba(0, 0, 0, 0.55),
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
transition:
|
transition:
|
||||||
opacity 160ms ease,
|
opacity 160ms ease,
|
||||||
@@ -219,11 +298,224 @@ textarea:focus-visible {
|
|||||||
.nav a {
|
.nav a {
|
||||||
padding: 12px 12px;
|
padding: 12px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--layer-1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--stroke-weak);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-notch {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--theme-notch-top);
|
||||||
|
right: max(8px, env(safe-area-inset-right));
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-handle {
|
||||||
|
height: 46px;
|
||||||
|
width: 60px;
|
||||||
|
border-radius: 16px 0 0 16px;
|
||||||
|
border: 1px solid var(--stroke-mid);
|
||||||
|
border-right: 0;
|
||||||
|
background: linear-gradient(180deg, var(--layer-3), var(--layer-1));
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 42px rgba(0, 0, 0, 0.28),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-glyph {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 60px;
|
||||||
|
top: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--stroke-mid);
|
||||||
|
background: var(--surface-1);
|
||||||
|
box-shadow:
|
||||||
|
0 18px 60px rgba(0, 0, 0, 0.32),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--stroke-weak) 60%, transparent) inset;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(10px) scale(0.98);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
visibility 0s linear 160ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch:hover .theme-notch-panel,
|
||||||
|
.theme-notch[data-open="true"] .theme-notch-panel,
|
||||||
|
.theme-notch:focus-within .theme-notch-panel {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch:hover .theme-notch-handle,
|
||||||
|
.theme-notch:focus-within .theme-notch-handle,
|
||||||
|
.theme-notch[data-open="true"] .theme-notch-handle {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 160px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--stroke-weak);
|
||||||
|
background: var(--layer-1);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
background 140ms ease,
|
||||||
|
border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: var(--layer-2);
|
||||||
|
border-color: var(--stroke-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--stroke-mid);
|
||||||
|
background: transparent;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option[aria-checked="true"] {
|
||||||
|
border-color: color-mix(in srgb, var(--accent2) 50%, var(--stroke-mid));
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent2) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option[aria-checked="true"] .theme-notch-dot {
|
||||||
|
border-color: var(--accent2);
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.theme-notch {
|
||||||
|
right: max(10px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-panel {
|
||||||
|
right: 60px;
|
||||||
|
width: min(80vw, 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
body {
|
||||||
|
background: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header,
|
||||||
|
.nav,
|
||||||
|
.card,
|
||||||
|
.hero,
|
||||||
|
.theme-notch-panel,
|
||||||
|
.theme-notch-handle,
|
||||||
|
.theme-notch-option {
|
||||||
|
background: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
border-color: CanvasText;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-option[aria-checked="true"] {
|
||||||
|
outline: 2px solid Highlight;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-notch-dot {
|
||||||
|
border-color: CanvasText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme-transition="on"] body {
|
||||||
|
transition:
|
||||||
|
background 220ms ease,
|
||||||
|
color 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme-transition="on"] body::before {
|
||||||
|
transition: opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme-transition="on"] .site-header,
|
||||||
|
html[data-theme-transition="on"] .nav-toggle,
|
||||||
|
html[data-theme-transition="on"] .nav,
|
||||||
|
html[data-theme-transition="on"] .card,
|
||||||
|
html[data-theme-transition="on"] .cta,
|
||||||
|
html[data-theme-transition="on"] .subnav a,
|
||||||
|
html[data-theme-transition="on"] .pill,
|
||||||
|
html[data-theme-transition="on"] dialog,
|
||||||
|
html[data-theme-transition="on"] .theme-notch-panel,
|
||||||
|
html[data-theme-transition="on"] .theme-notch-handle,
|
||||||
|
html[data-theme-transition="on"] .theme-notch-option {
|
||||||
|
transition:
|
||||||
|
background-color 220ms ease,
|
||||||
|
color 220ms ease,
|
||||||
|
border-color 220ms ease,
|
||||||
|
box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -242,15 +534,15 @@ textarea:focus-visible {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 18px 0 8px;
|
margin: 18px 0 8px;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--stroke-weak);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subnav a {
|
.subnav a {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--stroke-mid);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--layer-1);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -271,7 +563,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
color: rgba(242, 244, 255, 0.9);
|
color: color-mix(in srgb, var(--fg) 90%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
@@ -282,11 +574,11 @@ textarea:focus-visible {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--stroke-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid var(--stroke-weak);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -302,7 +594,7 @@ textarea:focus-visible {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--layer-1);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +626,7 @@ textarea:focus-visible {
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
background: linear-gradient(180deg, var(--layer-3), color-mix(in srgb, var(--layer-1) 75%, transparent));
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
@@ -378,8 +670,8 @@ textarea:focus-visible {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--stroke-mid);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
transition:
|
||||||
transform 120ms ease,
|
transform 120ms ease,
|
||||||
@@ -401,13 +693,13 @@ button.card {
|
|||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--card2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-media .img-shimmer-wrap {
|
.card-media .img-shimmer-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--stroke-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-media img {
|
.card-media img {
|
||||||
@@ -415,14 +707,14 @@ button.card {
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--stroke-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-placeholder {
|
.card-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--layer-2);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--stroke-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Image shimmer / lazy-load placeholder --- */
|
/* --- Image shimmer / lazy-load placeholder --- */
|
||||||
@@ -439,7 +731,7 @@ button.card {
|
|||||||
.img-shimmer-wrap {
|
.img-shimmer-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--layer-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-shimmer-wrap::before {
|
.img-shimmer-wrap::before {
|
||||||
@@ -450,9 +742,9 @@ button.card {
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
rgba(255, 255, 255, 0.12) 35%,
|
color-mix(in srgb, var(--fg) 10%, transparent) 35%,
|
||||||
rgba(255, 255, 255, 0.22) 50%,
|
color-mix(in srgb, var(--fg) 18%, transparent) 50%,
|
||||||
rgba(255, 255, 255, 0.12) 65%,
|
color-mix(in srgb, var(--fg) 10%, transparent) 65%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
animation: shimmer 1.6s ease-in-out infinite;
|
animation: shimmer 1.6s ease-in-out infinite;
|
||||||
@@ -492,7 +784,11 @@ button.card {
|
|||||||
.card-content {
|
.card-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 12px 12px;
|
padding: 12px 12px 12px;
|
||||||
background: linear-gradient(180deg, rgba(15, 27, 56, 0.75), rgba(11, 16, 32, 0.32));
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-1) 94%, transparent),
|
||||||
|
color-mix(in srgb, var(--surface-1) 68%, transparent)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -516,8 +812,8 @@ button.card {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid var(--stroke-weak);
|
||||||
background: rgba(11, 16, 32, 0.45);
|
background: color-mix(in srgb, var(--surface-1) 78%, transparent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,8 +827,8 @@ button.card {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid var(--stroke-mid);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--layer-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill-youtube {
|
.pill-youtube {
|
||||||
@@ -554,9 +850,9 @@ button.card {
|
|||||||
.empty {
|
.empty {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
border: 1px dashed var(--stroke-strong);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--layer-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instagram-media {
|
.instagram-media {
|
||||||
|
|||||||
@@ -50,6 +50,42 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
|
|||||||
|
|
||||||
<link rel="stylesheet" href="/styles/global.css" />
|
<link rel="stylesheet" href="/styles/global.css" />
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(() => {
|
||||||
|
const THEME_KEY = "site.theme";
|
||||||
|
|
||||||
|
const validate = (v) => (v === "dark" || v === "light" || v === "high-contrast" ? v : null);
|
||||||
|
|
||||||
|
const readStored = () => {
|
||||||
|
try {
|
||||||
|
return validate(window.localStorage.getItem(THEME_KEY));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forcedColors = () => {
|
||||||
|
try {
|
||||||
|
return window.matchMedia && window.matchMedia("(forced-colors: active)").matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefersLight = () => {
|
||||||
|
try {
|
||||||
|
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stored = readStored();
|
||||||
|
const theme = stored || (forcedColors() ? "high-contrast" : prefersLight() ? "light" : "dark");
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{
|
{
|
||||||
cfg.umami ? (
|
cfg.umami ? (
|
||||||
<script async defer data-website-id={cfg.umami.websiteId} src={cfg.umami.scriptUrl} />
|
<script async defer data-website-id={cfg.umami.websiteId} src={cfg.umami.scriptUrl} />
|
||||||
@@ -133,6 +169,44 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<aside class="theme-notch" aria-label="Theme" data-theme-notch data-open="false">
|
||||||
|
<div
|
||||||
|
class="theme-notch-panel"
|
||||||
|
id="theme-notch-panel"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Theme selector"
|
||||||
|
>
|
||||||
|
<button type="button" class="theme-notch-option" data-theme-option="dark" role="radio" aria-checked="false">
|
||||||
|
<span class="theme-notch-dot" aria-hidden="true"></span>
|
||||||
|
<span class="theme-notch-text">Dark</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="theme-notch-option" data-theme-option="light" role="radio" aria-checked="false">
|
||||||
|
<span class="theme-notch-dot" aria-hidden="true"></span>
|
||||||
|
<span class="theme-notch-text">Light</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-notch-option"
|
||||||
|
data-theme-option="high-contrast"
|
||||||
|
role="radio"
|
||||||
|
aria-checked="false"
|
||||||
|
>
|
||||||
|
<span class="theme-notch-dot" aria-hidden="true"></span>
|
||||||
|
<span class="theme-notch-text">Contrast</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-notch-handle"
|
||||||
|
aria-label="Theme"
|
||||||
|
aria-controls="theme-notch-panel"
|
||||||
|
aria-expanded="false"
|
||||||
|
data-theme-notch-handle
|
||||||
|
>
|
||||||
|
<span class="theme-notch-glyph" aria-hidden="true">Theme</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<main class="container" id="main-content">
|
<main class="container" id="main-content">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
@@ -208,6 +282,136 @@ const canonicalUrl = `${siteUrl}${canonicalPath.startsWith("/") ? canonicalPath
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(() => {
|
||||||
|
const THEME_KEY = "site.theme";
|
||||||
|
|
||||||
|
const validate = (v) => (v === "dark" || v === "light" || v === "high-contrast" ? v : null);
|
||||||
|
|
||||||
|
const setTheme = (next, opts) => {
|
||||||
|
const theme = validate(next);
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
if (opts && opts.withTransition) {
|
||||||
|
document.documentElement.dataset.themeTransition = "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts && opts.withTransition) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
delete document.documentElement.dataset.themeTransition;
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notch = document.querySelector("[data-theme-notch]");
|
||||||
|
const handle = document.querySelector("[data-theme-notch-handle]");
|
||||||
|
|
||||||
|
const syncRadios = (theme) => {
|
||||||
|
const options = document.querySelectorAll(".theme-notch-option[data-theme-option]");
|
||||||
|
for (let i = 0; i < options.length; i++) {
|
||||||
|
const el = options[i];
|
||||||
|
const v = el.getAttribute("data-theme-option");
|
||||||
|
el.setAttribute("aria-checked", v === theme ? "true" : "false");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOpen = (open) => {
|
||||||
|
if (!notch || !handle) return;
|
||||||
|
notch.dataset.open = open ? "true" : "false";
|
||||||
|
handle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNotchTop = () => {
|
||||||
|
const header = document.querySelector(".site-header");
|
||||||
|
const subnav = document.querySelector(".subnav");
|
||||||
|
const headerRect = header ? header.getBoundingClientRect() : null;
|
||||||
|
const headerH = headerRect ? headerRect.height : 0;
|
||||||
|
let top = headerH + 12;
|
||||||
|
|
||||||
|
if (subnav) {
|
||||||
|
const r = subnav.getBoundingClientRect();
|
||||||
|
if (r.top >= 0 && r.top < headerH + 140) {
|
||||||
|
top = Math.max(top, r.bottom + 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty("--theme-notch-top", `${Math.round(top)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialTheme = validate(document.documentElement.dataset.theme) || "dark";
|
||||||
|
syncRadios(initialTheme);
|
||||||
|
updateNotchTop();
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => updateNotchTop());
|
||||||
|
window.addEventListener("scroll", () => updateNotchTop(), { passive: true });
|
||||||
|
|
||||||
|
if (handle) {
|
||||||
|
handle.addEventListener("click", () => {
|
||||||
|
const open = notch && notch.dataset.open === "true";
|
||||||
|
setOpen(!open);
|
||||||
|
if (!open) {
|
||||||
|
const first = notch && notch.querySelector(".theme-notch-option");
|
||||||
|
if (first instanceof HTMLElement) first.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
if (handle) handle.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!active || !(active instanceof HTMLElement)) return;
|
||||||
|
if (!active.classList.contains("theme-notch-option")) return;
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const options = Array.from(
|
||||||
|
document.querySelectorAll(".theme-notch-option[data-theme-option]"),
|
||||||
|
);
|
||||||
|
const idx = options.indexOf(active);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const dir = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : -1;
|
||||||
|
const nextEl = options[(idx + dir + options.length) % options.length];
|
||||||
|
nextEl.focus({ preventScroll: true });
|
||||||
|
const v = nextEl.getAttribute("data-theme-option");
|
||||||
|
setTheme(v, { withTransition: true });
|
||||||
|
const theme = validate(document.documentElement.dataset.theme) || "dark";
|
||||||
|
syncRadios(theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (!t || !t.closest) return;
|
||||||
|
|
||||||
|
const opt = t.closest(".theme-notch-option[data-theme-option]");
|
||||||
|
if (opt) {
|
||||||
|
const next = opt.getAttribute("data-theme-option");
|
||||||
|
setTheme(next, { withTransition: true });
|
||||||
|
const active = validate(document.documentElement.dataset.theme) || "dark";
|
||||||
|
syncRadios(active);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notch && notch.dataset.open === "true") {
|
||||||
|
if (handle && t.closest("[data-theme-notch-handle]")) return;
|
||||||
|
if (t.closest("[data-theme-notch]")) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(() => {
|
(() => {
|
||||||
function reveal(img) {
|
function reveal(img) {
|
||||||
|
|||||||
Reference in New Issue
Block a user