diff --git a/.claude/skills/dark-mode-daisyui/SKILL.md b/.claude/skills/dark-mode-daisyui/SKILL.md new file mode 100644 index 000000000..7eb987ffa --- /dev/null +++ b/.claude/skills/dark-mode-daisyui/SKILL.md @@ -0,0 +1,18 @@ +--- +name: dark-mode-daisyui +description: | + Implement dark mode theme switching using DaisyUI's data-theme attribute system + with localStorage persistence, system preference detection via matchMedia, + smooth CSS transitions, and accessible toggle buttons. +metadata: + triggers: + - "dark mode" + - "theme toggle" + - "DaisyUI theme" + - "prefers-color-scheme" + - "light dark theme" + - "data-theme" + scope: project +--- + +See [README.md](references/README.md) for full documentation. diff --git a/.claude/skills/dark-mode-daisyui/references/README.md b/.claude/skills/dark-mode-daisyui/references/README.md new file mode 100644 index 000000000..1fd880bb7 --- /dev/null +++ b/.claude/skills/dark-mode-daisyui/references/README.md @@ -0,0 +1,122 @@ +# Dark Mode Implementation with DaisyUI + +## Overview + +This project uses DaisyUI's theme system for dark mode. DaisyUI provides built-in light and dark themes that are activated by setting `data-theme` on the `` element. All `bg-base-*` and `text-base-*` utility classes automatically adapt. + +## When to Use This Skill + +Use this skill when users request: +- Adding dark mode to a DaisyUI/Tailwind project +- Theme toggle with localStorage persistence +- System preference detection (prefers-color-scheme) +- Smooth theme transitions + +## Core Capabilities + +### 1. Theme Detection and Persistence + +Location: `web/src/js/main.js` + +```javascript +function initTheme() { + const root = document.documentElement; + const savedTheme = localStorage.getItem('theme'); + + let theme; + if (savedTheme) { + theme = savedTheme; + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + theme = 'dark'; + } else { + theme = 'light'; + } + + root.setAttribute('data-theme', theme); + + // Listen for system preference changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', function(event) { + if (!localStorage.getItem('theme')) { + const newTheme = event.matches ? 'dark' : 'light'; + root.setAttribute('data-theme', newTheme); + } + }); + + initThemeToggle(); +} +``` + +### 2. Toggle Button Handling + +```javascript +function initThemeToggle() { + // IMPORTANT: Use querySelectorAll for multiple toggle buttons (desktop + mobile) + const toggleBtns = document.querySelectorAll('[data-testid="theme-toggle"]'); + if (!toggleBtns.length) return; + + toggleBtns.forEach(function(toggleBtn) { + toggleBtn.addEventListener('click', function() { + const root = document.documentElement; + const currentTheme = root.getAttribute('data-theme') || 'light'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + root.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update ALL toggle button icons/labels + toggleBtns.forEach(function(btn) { + updateToggleLabel(btn, newTheme); + }); + }); + + const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; + updateToggleLabel(toggleBtn, currentTheme); + }); +} + +function updateToggleLabel(button, theme) { + const isDark = theme === 'dark'; + button.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + button.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + + const icon = button.querySelector('.theme-icon'); + if (icon) { + icon.textContent = isDark ? '☀' : '☽'; + } +} +``` + +### 3. CSS Transitions + +Location: `web/src/css/main.css` + +```css +html { + transition: background-color 0.3s ease, color 0.3s ease; +} +body, .navbar, .hero, .card, .footer, section { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} +``` + +## Best Practices + +- Use `querySelectorAll` not `querySelector` for toggle buttons when there are multiple (desktop + mobile) +- Remove hardcoded `data-theme="light"` from `` so JS can set it dynamically before render +- Use DaisyUI semantic colors (`bg-base-100`, `text-base-content`) rather than explicit light/dark classes +- Check localStorage before system preference — user's explicit choice should override OS settings +- Update ALL toggle button labels/icons when theme changes, not just the clicked one + +## Resources + +### references/ + +- `README.md` - This documentation + +### Related Files + +- `web/src/js/main.js` - Theme detection, toggle, and persistence logic +- `web/src/css/main.css` - Theme transition styles +- `web/templates/partials/nav.html` - Theme toggle buttons in nav +- `tests/e2e/theme.spec.js` - Full E2E test suite for dark mode diff --git a/.claude/skills/e2e-color-brightness-testing/SKILL.md b/.claude/skills/e2e-color-brightness-testing/SKILL.md new file mode 100644 index 000000000..2bbe71a0b --- /dev/null +++ b/.claude/skills/e2e-color-brightness-testing/SKILL.md @@ -0,0 +1,18 @@ +--- +name: e2e-color-brightness-testing +description: | + Extract normalized brightness from computed CSS colors in Playwright E2E tests. + Handles multiple color formats (rgb, rgba, oklch, oklab, hsl) that modern browsers + return from getComputedStyle, enabling robust color-based assertions across browsers. +metadata: + triggers: + - "color brightness test" + - "computed color testing" + - "oklch test" + - "color format parsing" + - "getComputedStyle test" + - "dark mode color assertion" + scope: project +--- + +See [README.md](references/README.md) for full documentation. diff --git a/.claude/skills/e2e-color-brightness-testing/references/README.md b/.claude/skills/e2e-color-brightness-testing/references/README.md new file mode 100644 index 000000000..46572b2db --- /dev/null +++ b/.claude/skills/e2e-color-brightness-testing/references/README.md @@ -0,0 +1,100 @@ +# E2E Color Brightness Testing + +## Overview + +When testing dark mode or any color-dependent UI feature with Playwright, `getComputedStyle` returns colors in different formats depending on the browser and CSS engine. This skill provides a robust `getBrightness()` helper that normalizes any format to a 0-255 brightness scale. + +## When to Use This Skill + +Use this skill when users request: +- Testing CSS colors in E2E tests +- Handling oklch/oklab color formats from getComputedStyle +- Writing assertions for dark/light mode color verification +- Parsing multiple CSS color formats in tests + +## Core Capabilities + +### 1. Multi-Format Color Brightness Extraction + +```javascript +function getBrightness(colorStr) { + if (!colorStr) return null; + + // rgb/rgba: rgb(255, 255, 255) or rgba(255, 255, 255, 0.5) + const rgbMatch = colorStr.match(/rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)/); + if (rgbMatch) { + const r = parseFloat(rgbMatch[1]); + const g = parseFloat(rgbMatch[2]); + const b = parseFloat(rgbMatch[3]); + return (r + g + b) / 3; + } + + // oklch: oklch(0.278078 0.029596 256.848) — first value is lightness 0-1 + const oklchMatch = colorStr.match(/oklch\(([\d.]+)/); + if (oklchMatch) { + return parseFloat(oklchMatch[1]) * 255; + } + + // oklab: oklab(0.419385 0.00478227 -0.0474926) — first value is lightness 0-1 + const oklabMatch = colorStr.match(/oklab\(([\d.]+)/); + if (oklabMatch) { + return parseFloat(oklabMatch[1]) * 255; + } + + // hsl: hsl(210, 100%, 50%) + const hslMatch = colorStr.match(/hsl\([\d.]+,\s*[\d.]+%?,\s*([\d.]+)%?\)/); + if (hslMatch) { + return (parseFloat(hslMatch[1]) / 100) * 255; + } + + return null; +} +``` + +### 2. Usage in Playwright Tests + +```javascript +const bodyBg = await body.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; +}); + +const brightness = getBrightness(bodyBg); +expect(brightness).not.toBeNull(); +expect(brightness).toBeGreaterThan(200); // Light background +``` + +### 3. Handling Playwright Strict Mode with Multiple Elements + +When multiple elements share the same `data-testid`, use the `:visible` pseudo-class: + +```javascript +// Selects only the visible toggle based on viewport +const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); +``` + +## Best Practices + +- Always normalize colors to brightness rather than comparing raw strings +- Include parsers for oklch and oklab — modern Chromium returns these formats +- Use empirically-determined thresholds based on your design system +- Use `:visible` pseudo-class for elements that exist in both desktop and mobile layouts + +## Recommended Brightness Thresholds (DaisyUI) + +| Context | Threshold | +|---------|-----------| +| Light background | > 200 | +| Dark background | < 100 | +| Dark text (light mode) | < 120 | +| Light text (dark mode) | > 180 | + +## Resources + +### references/ + +- `README.md` - This documentation + +### Related Files + +- `tests/e2e/theme.spec.js` - Full implementation with getBrightness() helper diff --git a/.gitignore b/.gitignore index 53eaa2196..5f4921f13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target **/*.rs.bk +.something/ +node_modules/ diff --git a/mirdb-server/Cargo.toml b/mirdb-server/Cargo.toml index 854b6a3f6..bf27dd35f 100644 --- a/mirdb-server/Cargo.toml +++ b/mirdb-server/Cargo.toml @@ -15,6 +15,7 @@ tokio-proto = "0.1" tokio-service = "0.1" glob = "0.3.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" bincode = "1.1.2" integer-encoding = "1.0" snap = "0.2" diff --git a/mirdb-server/src/main.rs b/mirdb-server/src/main.rs index a1a1a6352..9466ec2e0 100644 --- a/mirdb-server/src/main.rs +++ b/mirdb-server/src/main.rs @@ -53,6 +53,7 @@ mod test_utils; mod thread_pool; mod types; mod wal; +mod web; pub struct Server { store: Arc, diff --git a/mirdb-server/src/web/handlers.rs b/mirdb-server/src/web/handlers.rs new file mode 100644 index 000000000..24603efc8 --- /dev/null +++ b/mirdb-server/src/web/handlers.rs @@ -0,0 +1,151 @@ +/** + * HTTP request handlers. + * Owner: Scenario 1 - HTTP Server and Routing (base handlers) + * Scenario 12 - Analytics Counter Display (stats endpoint) + * + * Expected handlers: + * - homepage() -> Response: Render the homepage template + * - static_files(path) -> Response: Serve static assets + * - get_stats() -> Json: Return analytics data (added by Scenario 12) + */ + +use serde::{Deserialize, Serialize}; + +/// Analytics statistics returned by the /api/stats endpoint. +/// Owner: Scenario 12 - Analytics Counter Display +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stats { + /// Total number of URLs created + pub urls_created: u64, + /// Number of active users + pub active_users: u64, + /// Total number of clicks across all URLs + pub total_clicks: u64, +} + +impl Stats { + /// Create a new Stats instance with the given values. + pub fn new(urls_created: u64, active_users: u64, total_clicks: u64) -> Self { + Stats { + urls_created, + active_users, + total_clicks, + } + } + + /// Return default/demo stats for the homepage analytics counter. + /// These values provide social proof to visitors. + pub fn default_stats() -> Self { + Stats { + urls_created: 128_456, + active_users: 3_421, + total_clicks: 8_923_456, + } + } +} + +/// Handler for GET /api/stats +/// Returns analytics data for the homepage counter display. +/// Owner: Scenario 12 - Analytics Counter Display +pub fn get_stats() -> Stats { + // In a production environment, this would query the database + // for actual statistics. For now, return default/demo stats + // that demonstrate the counter formatting. + Stats::default_stats() +} + +/// Format a number with K/M/B suffixes for display. +/// Examples: +/// - 128456 -> "128K+" +/// - 8923456 -> "8.9M+" +/// - 1500000000 -> "1.5B+" +/// Owner: Scenario 12 - Analytics Counter Display +pub fn format_number(num: u64) -> String { + if num >= 1_000_000_000 { + let billions = num as f64 / 1_000_000_000.0; + let formatted = format!("{:.1}", billions); + let trimmed = formatted.trim_end_matches(".0").trim_end_matches('.'); + format!("{}B+", trimmed) + } else if num >= 1_000_000 { + let millions = num as f64 / 1_000_000.0; + let formatted = format!("{:.1}", millions); + let trimmed = formatted.trim_end_matches(".0").trim_end_matches('.'); + format!("{}M+", trimmed) + } else if num >= 1_000 { + let thousands = num as f64 / 1_000.0; + let formatted = format!("{:.1}", thousands); + let trimmed = formatted.trim_end_matches(".0").trim_end_matches('.'); + format!("{}K+", trimmed) + } else { + num.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_number_thousands() { + assert_eq!(format_number(128_456), "128.5K+"); + assert_eq!(format_number(1_000), "1K+"); + assert_eq!(format_number(1_500), "1.5K+"); + assert_eq!(format_number(999), "999"); + } + + #[test] + fn test_format_number_millions() { + assert_eq!(format_number(8_923_456), "8.9M+"); + assert_eq!(format_number(1_000_000), "1M+"); + assert_eq!(format_number(1_500_000), "1.5M+"); + } + + #[test] + fn test_format_number_billions() { + assert_eq!(format_number(1_500_000_000), "1.5B+"); + assert_eq!(format_number(1_000_000_000), "1B+"); + assert_eq!(format_number(2_000_000_000), "2B+"); + } + + #[test] + fn test_default_stats() { + let stats = Stats::default_stats(); + assert_eq!(stats.urls_created, 128_456); + assert_eq!(stats.active_users, 3_421); + assert_eq!(stats.total_clicks, 8_923_456); + } + + #[test] + fn test_get_stats_returns_default() { + let stats = get_stats(); + assert_eq!(stats.urls_created, 128_456); + assert_eq!(stats.active_users, 3_421); + assert_eq!(stats.total_clicks, 8_923_456); + } + + #[test] + fn test_format_number_small_values() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(1), "1"); + assert_eq!(format_number(500), "500"); + assert_eq!(format_number(999), "999"); + } + + #[test] + fn test_stats_serialization() { + let stats = Stats::new(100, 50, 1000); + let json = serde_json::to_string(&stats).unwrap(); + assert!(json.contains("\"urls_created\":100")); + assert!(json.contains("\"active_users\":50")); + assert!(json.contains("\"total_clicks\":1000")); + } + + #[test] + fn test_stats_deserialization() { + let json = r#"{"urls_created":500,"active_users":100,"total_clicks":5000}"#; + let stats: Stats = serde_json::from_str(json).unwrap(); + assert_eq!(stats.urls_created, 500); + assert_eq!(stats.active_users, 100); + assert_eq!(stats.total_clicks, 5000); + } +} diff --git a/mirdb-server/src/web/mod.rs b/mirdb-server/src/web/mod.rs new file mode 100644 index 000000000..daa649d97 --- /dev/null +++ b/mirdb-server/src/web/mod.rs @@ -0,0 +1,16 @@ +/** + * Web server module for serving the homepage and static assets. + * Owner: Scenario 1 - HTTP Server and Routing (base structure) + * Scenario 12 - Analytics Counter Display (stats endpoint) + * + * Expected exports: + * - start_server(addr, store) -> Result<()>: Start the HTTP server + * - routes() -> Router: Define all application routes + * + * This module wraps the existing TCP server with HTTP capabilities + * or runs a separate HTTP server alongside the TCP protocol server. + */ + +pub mod handlers; + +pub use handlers::*; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..17b051134 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1329 @@ +{ + "name": "mirdb-homepage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mirdb-homepage", + "version": "1.0.0", + "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.40.0", + "autoprefixer": "^10.4.0", + "daisyui": "^4.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/culori": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz", + "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/daisyui": { + "version": "4.12.24", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.12.24.tgz", + "integrity": "sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-selector-tokenizer": "^0.8", + "culori": "^3", + "picocolors": "^1", + "postcss-js": "^4" + }, + "engines": { + "node": ">=16.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.45", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.45.tgz", + "integrity": "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..005392c7b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "mirdb-homepage", + "version": "1.0.0", + "description": "MirDB URL Shortening Service Homepage", + "scripts": { + "test:e2e": "playwright test tests/e2e/ --config tests/e2e/playwright.config.js", + "build:css": "tailwindcss -i web/src/css/main.css -o web/static/css/main.css", + "serve": "python3 -m http.server 8080 --directory web" + }, + "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.40.0", + "autoprefixer": "^10.4.0", + "daisyui": "^4.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 000000000..ca2487811 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,25 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "web/templates/**/*.html", + "web/src/js/**/*.js", + "web/index.html", + ], + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + }, + }, + }, + plugins: [require('daisyui')], + daisyui: { + themes: ['light', 'dark'], + }, +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 000000000..cbcc1fbac --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js new file mode 100644 index 000000000..45932ea17 --- /dev/null +++ b/tests/e2e/accessibility.spec.js @@ -0,0 +1,438 @@ +/** + * Accessibility Compliance E2E Tests + * Owner: Scenario 9 - Accessibility Compliance + * + * Tests: + * - Automated axe-core scan for WCAG 2.1 AA violations + * - Keyboard tab navigation and focus order + * - Semantic HTML structure validation + * - ARIA labels and accessible names + * - Color contrast ratio compliance + * - Screen reader announcement (manual) + */ + +const { test, expect } = require('@playwright/test'); +const AxeBuilder = require('@axe-core/playwright').default; + +test.describe('Accessibility Compliance', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for page to be fully loaded + await page.waitForLoadState('networkidle'); + }); + + // ============================================================ + // Test 1: Automated axe-core scan + // ============================================================ + test('should pass automated axe-core scan with zero critical or serious violations', async ({ page }) => { + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + + // Filter for critical and serious violations + const criticalViolations = accessibilityScanResults.violations.filter( + v => v.impact === 'critical' + ); + const seriousViolations = accessibilityScanResults.violations.filter( + v => v.impact === 'serious' + ); + + // Report violations for debugging + if (criticalViolations.length > 0) { + console.log('Critical violations:', JSON.stringify(criticalViolations, null, 2)); + } + if (seriousViolations.length > 0) { + console.log('Serious violations:', JSON.stringify(seriousViolations, null, 2)); + } + + expect(criticalViolations.length, 'Should have zero critical violations').toBe(0); + expect(seriousViolations.length, 'Should have zero serious violations').toBe(0); + }); + + // ============================================================ + // Test 2: Keyboard tab navigation + // ============================================================ + test('should have logical tab order through all interactive elements', async ({ page }) => { + // Get all focusable elements on the page + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ]; + + const focusableElements = await page.locator(focusableSelectors.join(', ')).all(); + expect(focusableElements.length).toBeGreaterThan(0); + + // Tab through all elements and record focus order + const focusOrder = []; + const maxTabs = focusableElements.length + 5; // Safety limit + + for (let i = 0; i < maxTabs; i++) { + const activeElement = await page.evaluate(() => { + const el = document.activeElement; + return el ? { + tag: el.tagName.toLowerCase(), + text: el.textContent?.trim().substring(0, 50) || '', + ariaLabel: el.getAttribute('aria-label') || '', + href: el.getAttribute('href') || '', + dataTestid: el.getAttribute('data-testid') || '', + id: el.id || '' + } : null; + }); + + // Stop when we've cycled back to the first element or body + if (i > 0 && (!activeElement || activeElement.tag === 'body')) { + break; + } + + // Avoid infinite loops by checking if we've seen this element before + const elementKey = `${activeElement.tag}-${activeElement.text}-${activeElement.dataTestid}`; + if (focusOrder.some(el => `${el.tag}-${el.text}-${el.dataTestid}` === elementKey)) { + break; + } + + focusOrder.push(activeElement); + await page.keyboard.press('Tab'); + } + + // Verify we found focusable elements + expect(focusOrder.length).toBeGreaterThan(0); + + // Verify logical order: navigation links should come before main content + const navIndices = []; + const mainContentIndices = []; + + for (let i = 0; i < focusOrder.length; i++) { + const el = focusOrder[i]; + if (el.dataTestid.includes('nav-') || el.dataTestid.includes('mobile-')) { + navIndices.push(i); + } + if (el.dataTestid.includes('hero-') || el.dataTestid.includes('cta-') || el.dataTestid.includes('footer-')) { + mainContentIndices.push(i); + } + } + + // If both nav and main content exist, nav should come before main content + if (navIndices.length > 0 && mainContentIndices.length > 0) { + const lastNavIndex = Math.max(...navIndices); + const firstMainIndex = Math.min(...mainContentIndices); + // Nav elements should generally come before main content + // (Allow some flexibility for skip links or special cases) + expect(lastNavIndex).toBeLessThanOrEqual(firstMainIndex + 2); + } + }); + + test('should have visible focus indicator on all interactive elements', async ({ page }) => { + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ]; + + const allElements = await page.locator(focusableSelectors.join(', ')).all(); + expect(allElements.length).toBeGreaterThan(0); + + // Filter out hidden elements (e.g., auth-only links when unauthenticated) + const elements = []; + for (const el of allElements) { + const isHidden = await el.evaluate(el => { + const style = window.getComputedStyle(el); + return style.display === 'none' || style.visibility === 'hidden' || el.offsetParent === null; + }); + if (!isHidden) { + elements.push(el); + } + } + + expect(elements.length).toBeGreaterThan(0); + + for (const element of elements) { + // Focus the element + await element.focus(); + + // Check if the element is actually focused + const isFocused = await element.evaluate(el => el === document.activeElement); + expect(isFocused, `Element should be focusable: ${await element.evaluate(el => + el.getAttribute('data-testid') || el.textContent?.trim().substring(0, 30) || el.tagName + )}`).toBe(true); + + // Check computed outline style + const outlineStyle = await element.evaluate(el => { + const style = window.getComputedStyle(el); + return { + outlineWidth: style.outlineWidth, + outlineStyle: style.outlineStyle, + outlineColor: style.outlineColor, + boxShadow: style.boxShadow, + ringWidth: style.getPropertyValue('--tw-ring-width') || '0' + }; + }); + + // Focus indicator should be visible: either outline is visible or box-shadow shows focus ring + const hasVisibleOutline = outlineStyle.outlineWidth !== '0px' && + outlineStyle.outlineStyle !== 'none'; + const hasFocusShadow = outlineStyle.boxShadow !== 'none' && + outlineStyle.boxShadow !== '0 0 #0000' && + outlineStyle.boxShadow !== 'rgba(0, 0, 0, 0) 0px 0px 0px 0px'; + + // DaisyUI/Tailwind typically uses focus:outline or focus:ring + const hasFocusIndicator = hasVisibleOutline || hasFocusShadow; + + expect(hasFocusIndicator, `Element should have visible focus indicator: ${await element.evaluate(el => + el.getAttribute('data-testid') || el.textContent?.trim().substring(0, 30) || el.tagName + )}`).toBe(true); + } + }); + + // ============================================================ + // Test 3: Semantic HTML validation + // ============================================================ + test('should use semantic HTML landmarks appropriately', async ({ page }) => { + // Check for nav element + const nav = page.locator('nav'); + await expect(nav).toHaveCount(1); + + // Check for main element + const main = page.locator('main'); + await expect(main).toHaveCount(1); + + // Check for footer element or role="contentinfo" + const footer = page.locator('footer'); + const contentinfo = page.locator('[role="contentinfo"]'); + const footerCount = await footer.count() + await contentinfo.count(); + expect(footerCount).toBeGreaterThanOrEqual(1); + + // Check for section elements + const sections = page.locator('section'); + const sectionCount = await sections.count(); + expect(sectionCount).toBeGreaterThanOrEqual(2); + + // Check for article elements (feature cards) + const articles = page.locator('article'); + const articleCount = await articles.count(); + expect(articleCount).toBeGreaterThanOrEqual(1); + }); + + test('should have logical heading hierarchy', async ({ page }) => { + const headings = await page.locator('h1, h2, h3, h4, h5, h6').all(); + expect(headings.length).toBeGreaterThan(0); + + const headingLevels = []; + for (const heading of headings) { + const level = await heading.evaluate(el => parseInt(el.tagName[1])); + const text = await heading.textContent(); + headingLevels.push({ level, text: text.trim().substring(0, 50) }); + } + + // Should have exactly one h1 + const h1Count = headingLevels.filter(h => h.level === 1).length; + expect(h1Count).toBe(1); + + // Heading levels should not skip (e.g., h1 -> h3 without h2) + let previousLevel = 0; + for (const heading of headingLevels) { + // Headings can stay the same or increase by 1 + // They can decrease by any amount + if (heading.level > previousLevel) { + expect(heading.level - previousLevel).toBeLessThanOrEqual(1); + } + previousLevel = heading.level; + } + + // h1 should come before any h2 + const firstH1Index = headingLevels.findIndex(h => h.level === 1); + const firstH2Index = headingLevels.findIndex(h => h.level === 2); + if (firstH2Index !== -1) { + expect(firstH1Index).toBeLessThan(firstH2Index); + } + }); + + test('should have lang attribute on html element', async ({ page }) => { + const html = page.locator('html'); + const lang = await html.getAttribute('lang'); + expect(lang).toBeTruthy(); + expect(lang.length).toBeGreaterThan(0); + }); + + test('should have page title describing the content', async ({ page }) => { + const title = await page.title(); + expect(title).toBeTruthy(); + expect(title.length).toBeGreaterThan(0); + // Title should mention the product/service + expect(title.toLowerCase()).toContain('mirdb'); + }); + + // ============================================================ + // Test 4: ARIA labels and accessible names + // ============================================================ + test('should have accessible names for all buttons', async ({ page }) => { + const buttons = await page.locator('button').all(); + + for (const button of buttons) { + const accessibleName = await button.evaluate(el => { + // Check for aria-label + if (el.getAttribute('aria-label')) return el.getAttribute('aria-label'); + // Check for aria-labelledby + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + const labelEl = document.getElementById(labelledBy); + return labelEl ? labelEl.textContent.trim() : null; + } + // Check for text content + return el.textContent.trim(); + }); + + expect(accessibleName, 'Button should have accessible name').toBeTruthy(); + expect(accessibleName.length, 'Button accessible name should not be empty').toBeGreaterThan(0); + } + }); + + test('should have accessible names for all links', async ({ page }) => { + const links = await page.locator('a[href]').all(); + + for (const link of links) { + const accessibleName = await link.evaluate(el => { + // Check for aria-label + if (el.getAttribute('aria-label')) return el.getAttribute('aria-label'); + // Check for aria-labelledby + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + const labelEl = document.getElementById(labelledBy); + return labelEl ? labelEl.textContent.trim() : null; + } + // Check for text content + const text = el.textContent.trim(); + if (text.length > 0) return text; + // Check for img with alt inside + const img = el.querySelector('img'); + if (img && img.alt) return img.alt; + return null; + }); + + expect(accessibleName, `Link should have accessible name: ${await link.evaluate(el => el.href || '')}`).toBeTruthy(); + expect(accessibleName.length, 'Link accessible name should not be empty').toBeGreaterThan(0); + } + }); + + test('should have aria-label on navigation landmark', async ({ page }) => { + const nav = page.locator('nav'); + const ariaLabel = await nav.getAttribute('aria-label'); + expect(ariaLabel).toBeTruthy(); + expect(ariaLabel.toLowerCase()).toContain('navigation'); + }); + + test('should have alt text on all images', async ({ page }) => { + const images = await page.locator('img').all(); + expect(images.length).toBeGreaterThan(0); + + for (const img of images) { + const alt = await img.getAttribute('alt'); + expect(alt, 'Image should have alt text').toBeTruthy(); + expect(alt.length, 'Image alt text should not be empty').toBeGreaterThan(0); + } + }); + + // ============================================================ + // Test 5: Color contrast + // ============================================================ + test('should meet WCAG AA color contrast ratios', async ({ page }) => { + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(['wcag2aa']) + .analyze(); + + // Filter specifically for color contrast violations + const contrastViolations = accessibilityScanResults.violations.filter( + v => v.id === 'color-contrast' + ); + + if (contrastViolations.length > 0) { + console.log('Contrast violations:', JSON.stringify(contrastViolations, null, 2)); + } + + expect(contrastViolations.length, 'Should have zero color contrast violations').toBe(0); + }); + + test('should have sufficient text contrast for normal text', async ({ page }) => { + // Get computed styles for body text + const bodyStyles = await page.evaluate(() => { + const body = document.body; + const style = window.getComputedStyle(body); + return { + color: style.color, + backgroundColor: style.backgroundColor + }; + }); + + expect(bodyStyles.color).toBeTruthy(); + expect(bodyStyles.backgroundColor).toBeTruthy(); + + // The text color should not be the same as background color + expect(bodyStyles.color).not.toBe(bodyStyles.backgroundColor); + }); + + // ============================================================ + // Test 6: Screen reader announcement (manual) + // ============================================================ + test('should have proper ARIA landmarks for screen reader navigation', async ({ page }) => { + // Check that page has proper landmark regions + const landmarks = await page.evaluate(() => { + return { + hasNav: !!document.querySelector('nav, [role="navigation"]'), + hasMain: !!document.querySelector('main, [role="main"]'), + hasFooter: !!document.querySelector('footer, [role="contentinfo"]'), + hasHeader: !!document.querySelector('header, [role="banner"]'), + hasComplementary: !!document.querySelector('aside, [role="complementary"]'), + hasSearch: !!document.querySelector('[role="search"]') + }; + }); + + // At minimum, should have nav, main, and footer landmarks + expect(landmarks.hasNav).toBe(true); + expect(landmarks.hasMain).toBe(true); + expect(landmarks.hasFooter).toBe(true); + }); + + test('should have aria-labelledby connecting sections to their headings', async ({ page }) => { + const sections = await page.locator('section[aria-labelledby]').all(); + + for (const section of sections) { + const ariaLabelledBy = await section.getAttribute('aria-labelledby'); + expect(ariaLabelledBy).toBeTruthy(); + + // Verify the referenced element exists + const referencedElement = page.locator(`#${ariaLabelledBy}`); + await expect(referencedElement).toBeVisible(); + + // Verify the referenced element is a heading + const tagName = await referencedElement.evaluate(el => el.tagName.toLowerCase()); + expect(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).toContain(tagName); + } + }); + + test('should have skip-to-content or first focusable element in logical order', async ({ page }) => { + // Check for skip link + const skipLink = page.locator('a[href^="#main"], a[href^="#content"], .skip-link, [class*="skip"]'); + const skipLinkCount = await skipLink.count(); + + // If no skip link, verify first focusable element is in the nav/header + if (skipLinkCount === 0) { + const firstFocusable = await page.locator('a[href], button, input, [tabindex]:not([tabindex="-1"])').first(); + const isInNav = await firstFocusable.evaluate(el => { + return !!el.closest('nav, header, [role="navigation"], [role="banner"]'); + }); + // First focusable should be in nav area + expect(isInNav).toBe(true); + } + }); +}); + +test.describe('Accessibility - Manual Tests', () => { + test('screen reader announcement verification (manual)', async ({ page }) => { + test.skip(true, 'Manual test: Verify page title, headings, and interactive elements are announced correctly by screen reader'); + await page.goto('/'); + }); +}); diff --git a/tests/e2e/analytics.spec.js b/tests/e2e/analytics.spec.js new file mode 100644 index 000000000..03c289734 --- /dev/null +++ b/tests/e2e/analytics.spec.js @@ -0,0 +1,569 @@ +/** + * Analytics Counter Display Tests + * Owner: Scenario 12 - Analytics Counter Display + * + * Tests: + * - Counter DOM presence and content + * - Number formatting with K/M/B suffixes + * - API data fetching with loading/error states + * - Visibility and contrast in both light and dark themes + * - Responsive layout across breakpoints + */ + +const { test, expect } = require('@playwright/test'); + +/** + * Extract a normalized brightness (0-255) from a computed color string. + */ +function getBrightness(colorStr) { + if (!colorStr) return null; + + const rgbMatch = colorStr.match(/rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)/); + if (rgbMatch) { + const r = parseFloat(rgbMatch[1]); + const g = parseFloat(rgbMatch[2]); + const b = parseFloat(rgbMatch[3]); + return (r + g + b) / 3; + } + + const oklchMatch = colorStr.match(/oklch\(([\d.]+)/); + if (oklchMatch) { + const l = parseFloat(oklchMatch[1]); + return l * 255; + } + + const oklabMatch = colorStr.match(/oklab\(([\d.]+)/); + if (oklabMatch) { + const l = parseFloat(oklabMatch[1]); + return l * 255; + } + + const hslMatch = colorStr.match(/hsl\([\d.]+,\s*[\d.]+%?,\s*([\d.]+)%?\)/); + if (hslMatch) { + const l = parseFloat(hslMatch[1]); + return (l / 100) * 255; + } + + return null; +} + +test.describe('Analytics Counter Display', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // Wait for counter JS to initialize + await page.waitForTimeout(300); + }); + + // ============================================================ + // Test Case 1: Analytics counter DOM presence (E2E) + // ============================================================ + test.describe('Counter DOM Presence', () => { + test('should render analytics section on the homepage', async ({ page }) => { + const analyticsSection = page.locator('[data-testid="analytics-section"]'); + await expect(analyticsSection).toBeVisible(); + }); + + test('should display section heading and subheading', async ({ page }) => { + const heading = page.locator('[data-testid="analytics-heading"]'); + await expect(heading).toBeVisible(); + + const headingText = await heading.textContent(); + expect(headingText).toBeTruthy(); + expect(headingText.toLowerCase()).toContain('trust'); + + const subheading = page.locator('[data-testid="analytics-subheading"]'); + await expect(subheading).toBeVisible(); + + const subheadingText = await subheading.textContent(); + expect(subheadingText).toBeTruthy(); + expect(subheadingText.length).toBeGreaterThan(10); + }); + + test('should display three stat cards with labels', async ({ page }) => { + const grid = page.locator('[data-testid="analytics-grid"]'); + await expect(grid).toBeVisible(); + + // Check URLs stat card + const urlsCard = page.locator('[data-testid="stat-card-urls"]'); + await expect(urlsCard).toBeVisible(); + const urlsLabel = urlsCard.locator('[data-testid="stat-label-urls"]'); + await expect(urlsLabel).toBeVisible(); + const urlsLabelText = await urlsLabel.textContent(); + expect(urlsLabelText.toLowerCase()).toContain('url'); + + // Check Users stat card + const usersCard = page.locator('[data-testid="stat-card-users"]'); + await expect(usersCard).toBeVisible(); + const usersLabel = usersCard.locator('[data-testid="stat-label-users"]'); + await expect(usersLabel).toBeVisible(); + const usersLabelText = await usersLabel.textContent(); + expect(usersLabelText.toLowerCase()).toContain('user'); + + // Check Clicks stat card + const clicksCard = page.locator('[data-testid="stat-card-clicks"]'); + await expect(clicksCard).toBeVisible(); + const clicksLabel = clicksCard.locator('[data-testid="stat-label-clicks"]'); + await expect(clicksLabel).toBeVisible(); + const clicksLabelText = await clicksLabel.textContent(); + expect(clicksLabelText.toLowerCase()).toContain('click'); + }); + + test('should display meaningful stat values after loading', async ({ page }) => { + // Wait for the stat values to be populated (they start as "--") + await page.waitForFunction(() => { + const el = document.querySelector('[data-stat-key="urls_created"]'); + return el && el.textContent !== '--'; + }, { timeout: 5000 }); + + const urlsValue = page.locator('[data-testid="stat-value-urls"]'); + await expect(urlsValue).toBeVisible(); + const urlsText = await urlsValue.textContent(); + expect(urlsText).toBeTruthy(); + // Should contain a number with optional suffix + expect(urlsText).toMatch(/[\d.]+[KMB+]?/); + + const usersValue = page.locator('[data-testid="stat-value-users"]'); + await expect(usersValue).toBeVisible(); + const usersText = await usersValue.textContent(); + expect(usersText).toBeTruthy(); + expect(usersText).toMatch(/[\d.]+[KMB+]?/); + + const clicksValue = page.locator('[data-testid="stat-value-clicks"]'); + await expect(clicksValue).toBeVisible(); + const clicksText = await clicksValue.textContent(); + expect(clicksText).toBeTruthy(); + expect(clicksText).toMatch(/[\d.]+[KMB+]?/); + }); + + test('should have aria-live regions for accessibility', async ({ page }) => { + const statValues = page.locator('[data-testid^="stat-value"]'); + const count = await statValues.count(); + expect(count).toBe(3); + + for (let i = 0; i < count; i++) { + const ariaLive = await statValues.nth(i).getAttribute('aria-live'); + expect(ariaLive).toBe('polite'); + } + }); + + test('should have semantic section structure with aria-labelledby', async ({ page }) => { + const section = page.locator('section#analytics'); + await expect(section).toBeVisible(); + + const ariaLabelledBy = await section.getAttribute('aria-labelledby'); + expect(ariaLabelledBy).toBe('analytics-heading'); + + const heading = page.locator('#analytics-heading'); + await expect(heading).toBeVisible(); + }); + }); + + // ============================================================ + // Test Case 2: Counter number formatting (Unit) + // ============================================================ + test.describe('Counter Number Formatting', () => { + test('should format thousands with K+ suffix', async ({ page }) => { + const results = await page.evaluate(() => { + const format = window.App.Stats.formatNumber; + return { + oneThousand: format(1000), + fifteenHundred: format(1500), + oneHundredTwentyEightK: format(128456), + nineHundredNinetyNine: format(999), + }; + }); + + expect(results.oneThousand).toBe('1K+'); + expect(results.fifteenHundred).toBe('1.5K+'); + expect(results.oneHundredTwentyEightK).toBe('128.5K+'); + expect(results.nineHundredNinetyNine).toBe('999'); + }); + + test('should format millions with M+ suffix', async ({ page }) => { + const results = await page.evaluate(() => { + const format = window.App.Stats.formatNumber; + return { + oneMillion: format(1000000), + onePointFiveMillion: format(1500000), + eightPointNineMillion: format(8923456), + }; + }); + + expect(results.oneMillion).toBe('1M+'); + expect(results.onePointFiveMillion).toBe('1.5M+'); + expect(results.eightPointNineMillion).toBe('8.9M+'); + }); + + test('should format billions with B+ suffix', async ({ page }) => { + const results = await page.evaluate(() => { + const format = window.App.Stats.formatNumber; + return { + oneBillion: format(1000000000), + onePointFiveBillion: format(1500000000), + twoBillion: format(2000000000), + }; + }); + + expect(results.oneBillion).toBe('1B+'); + expect(results.onePointFiveBillion).toBe('1.5B+'); + expect(results.twoBillion).toBe('2B+'); + }); + + test('should handle small numbers without suffix', async ({ page }) => { + const results = await page.evaluate(() => { + const format = window.App.Stats.formatNumber; + return { + zero: format(0), + one: format(1), + fiveHundred: format(500), + }; + }); + + expect(results.zero).toBe('0'); + expect(results.one).toBe('1'); + expect(results.fiveHundred).toBe('500'); + }); + + test('should handle invalid inputs gracefully', async ({ page }) => { + const results = await page.evaluate(() => { + const format = window.App.Stats.formatNumber; + return { + nullValue: format(null), + undefinedValue: format(undefined), + stringValue: format('not a number'), + }; + }); + + expect(results.nullValue).toBe('--'); + expect(results.undefinedValue).toBe('--'); + expect(results.stringValue).toBe('--'); + }); + }); + + // ============================================================ + // Test Case 3: Counter API data fetch (Integration) + // ============================================================ + test.describe('Counter API Data Fetch', () => { + test('should fetch stats from API and display formatted values', async ({ page }) => { + // Mock the API response + await page.route('/api/stats', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + urls_created: 256789, + active_users: 5432, + total_clicks: 15678901, + }), + }); + }); + + // Reload to trigger fresh fetch + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(300); + + // Wait for values to update from the mocked API + await page.waitForFunction(() => { + const el = document.querySelector('[data-stat-key="urls_created"]'); + return el && el.textContent !== '--'; + }, { timeout: 5000 }); + + // Verify the formatted values from the mock API + const urlsValue = await page.locator('[data-stat-key="urls_created"]').textContent(); + expect(urlsValue).toBe('256.8K+'); + + const usersValue = await page.locator('[data-stat-key="active_users"]').textContent(); + expect(usersValue).toBe('5.4K+'); + + const clicksValue = await page.locator('[data-stat-key="total_clicks"]').textContent(); + expect(clicksValue).toBe('15.7M+'); + }); + + test('should handle API errors gracefully with fallback values', async ({ page }) => { + // Mock the API to return an error + await page.route('/api/stats', async (route) => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Internal Server Error' }), + }); + }); + + // Reload to trigger fresh fetch + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(300); + + // Wait for fallback values to be applied + await page.waitForFunction(() => { + const el = document.querySelector('[data-stat-key="urls_created"]'); + return el && el.textContent !== '--'; + }, { timeout: 5000 }); + + // Verify fallback values are displayed + const urlsValue = await page.locator('[data-stat-key="urls_created"]').textContent(); + expect(urlsValue).toBe('128.5K+'); + + const usersValue = await page.locator('[data-stat-key="active_users"]').textContent(); + expect(usersValue).toBe('3.4K+'); + + const clicksValue = await page.locator('[data-stat-key="total_clicks"]').textContent(); + expect(clicksValue).toBe('8.9M+'); + }); + + test('should handle network errors with fallback values', async ({ page }) => { + // Mock the API to abort the request + await page.route('/api/stats', async (route) => { + await route.abort('failed'); + }); + + // Reload to trigger fresh fetch + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(300); + + // Wait for fallback values to be applied + await page.waitForFunction(() => { + const el = document.querySelector('[data-stat-key="urls_created"]'); + return el && el.textContent !== '--'; + }, { timeout: 5000 }); + + // Verify fallback values are displayed + const urlsValue = await page.locator('[data-stat-key="urls_created"]').textContent(); + expect(urlsValue).toBe('128.5K+'); + }); + + test('should show loading state during fetch', async ({ page }) => { + // Create a delayed response to observe loading state + await page.route('/api/stats', async (route) => { + await new Promise(resolve => setTimeout(resolve, 500)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + urls_created: 1000, + active_users: 500, + total_clicks: 10000, + }), + }); + }); + + // Set initial state to '--' before reload + await page.evaluate(() => { + document.querySelectorAll('.stat-number').forEach(el => { + el.textContent = '--'; + }); + }); + + // Reload and immediately check loading state + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // The stat values should be '--' while loading + const urlsValue = await page.locator('[data-stat-key="urls_created"]').textContent(); + expect(urlsValue).toBe('--'); + }); + }); + + // ============================================================ + // Test Case 4: Counter visibility in both themes (E2E) + // ============================================================ + test.describe('Counter Theme Visibility', () => { + test('should have sufficient contrast in light mode', async ({ page }) => { + // Ensure light mode + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'light'); + localStorage.setItem('theme', 'light'); + }); + await page.waitForTimeout(300); + + const urlsCard = page.locator('[data-testid="stat-card-urls"]'); + await expect(urlsCard).toBeVisible(); + + // Check card background brightness (should be light) + const cardBg = await urlsCard.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + const cardBrightness = getBrightness(cardBg); + expect(cardBrightness).not.toBeNull(); + expect(cardBrightness).toBeGreaterThan(200); + + // Check stat value text brightness (should be dark for readability) + const statValue = urlsCard.locator('[data-testid="stat-value-urls"]'); + const textColor = await statValue.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + const textBrightness = getBrightness(textColor); + expect(textBrightness).not.toBeNull(); + // Primary color text should have reasonable contrast + expect(textBrightness).toBeGreaterThan(50); + + // Check label text brightness (should be darker/muted) + const statLabel = urlsCard.locator('[data-testid="stat-label-urls"]'); + const labelColor = await statLabel.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + const labelBrightness = getBrightness(labelColor); + expect(labelBrightness).not.toBeNull(); + expect(labelBrightness).toBeLessThan(150); + }); + + test('should have sufficient contrast in dark mode', async ({ page }) => { + // Switch to dark mode + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible').first(); + if (await toggleBtn.isVisible().catch(() => false)) { + await toggleBtn.click(); + } else { + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('theme', 'dark'); + }); + } + await page.waitForTimeout(350); + + const urlsCard = page.locator('[data-testid="stat-card-urls"]'); + await expect(urlsCard).toBeVisible(); + + // Check card background brightness (should be dark) + const cardBg = await urlsCard.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + const cardBrightness = getBrightness(cardBg); + expect(cardBrightness).not.toBeNull(); + expect(cardBrightness).toBeLessThan(100); + + // Check stat value text brightness (should be light for readability) + const statValue = urlsCard.locator('[data-testid="stat-value-urls"]'); + const textColor = await statValue.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + const textBrightness = getBrightness(textColor); + expect(textBrightness).not.toBeNull(); + expect(textBrightness).toBeGreaterThan(80); + + // Check label text brightness (should be lighter in dark mode) + const statLabel = urlsCard.locator('[data-testid="stat-label-urls"]'); + const labelColor = await statLabel.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + const labelBrightness = getBrightness(labelColor); + expect(labelBrightness).not.toBeNull(); + expect(labelBrightness).toBeGreaterThan(80); + }); + + test('should remain visible after theme toggle', async ({ page }) => { + const analyticsSection = page.locator('[data-testid="analytics-section"]'); + await expect(analyticsSection).toBeVisible(); + + // Toggle to dark mode + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible').first(); + if (await toggleBtn.isVisible().catch(() => false)) { + await toggleBtn.click(); + } else { + await page.evaluate(() => { + const current = document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + }); + } + await page.waitForTimeout(350); + + // Section should still be visible + await expect(analyticsSection).toBeVisible(); + + // All stat cards should be visible + await expect(page.locator('[data-testid="stat-card-urls"]')).toBeVisible(); + await expect(page.locator('[data-testid="stat-card-users"]')).toBeVisible(); + await expect(page.locator('[data-testid="stat-card-clicks"]')).toBeVisible(); + }); + }); + + // ============================================================ + // Responsive Layout Tests + // ============================================================ + test.describe('Counter Responsive Layout', () => { + test('should stack stat cards vertically on mobile (375px)', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(300); + + const grid = page.locator('[data-testid="analytics-grid"]'); + await expect(grid).toBeVisible(); + + // Cards should be in a single column (stacked) + const cards = page.locator('[data-testid^="stat-card-"]'); + const count = await cards.count(); + expect(count).toBe(3); + + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + const thirdCard = await cards.nth(2).boundingBox(); + + // All cards should be stacked vertically (different y positions) + expect(secondCard.y).toBeGreaterThan(firstCard.y); + expect(thirdCard.y).toBeGreaterThan(secondCard.y); + + // No horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + }); + + test('should show stat cards side by side on desktop (1280px)', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.waitForTimeout(300); + + const cards = page.locator('[data-testid^="stat-card-"]'); + const count = await cards.count(); + expect(count).toBe(3); + + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + const thirdCard = await cards.nth(2).boundingBox(); + + // On desktop, cards should be in the same row (similar y positions) + expect(Math.abs(secondCard.y - firstCard.y)).toBeLessThanOrEqual(20); + expect(Math.abs(thirdCard.y - firstCard.y)).toBeLessThanOrEqual(20); + + // Cards should be side by side (different x positions) + expect(secondCard.x).not.toBe(firstCard.x); + expect(thirdCard.x).not.toBe(secondCard.x); + }); + + test('should have readable stat values at all breakpoints', async ({ page }) => { + const breakpoints = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 800 }, + ]; + + for (const bp of breakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.waitForTimeout(200); + + // Stat values should be readable + const statValues = page.locator('[data-testid^="stat-value-"]'); + const firstValue = statValues.first(); + await expect(firstValue).toBeVisible(); + + const fontSize = await firstValue.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(fontSize, `Font too small at ${bp.name}`).toBeGreaterThanOrEqual(16); + + // No horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth, `Horizontal overflow at ${bp.name}`).toBeLessThanOrEqual(viewportWidth + 1); + } + }); + }); +}); diff --git a/tests/e2e/cta.spec.js b/tests/e2e/cta.spec.js new file mode 100644 index 000000000..5adff0a30 --- /dev/null +++ b/tests/e2e/cta.spec.js @@ -0,0 +1,368 @@ +/** + * CTA Buttons and Actions E2E Tests + * Owner: Scenario 5 - CTA Buttons and Actions + * + * Tests: + * - Primary CTA (Get Started) redirects to registration + * - Secondary CTA (Create Short URL) redirects to URL creation + * - CTA hover and focus states provide visual feedback + * - CTA touch target size meets accessibility requirements (>= 44x44px) + * - Bottom CTA section provides additional conversion opportunity + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('CTA Buttons and Actions', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for the page to be fully loaded + await page.waitForLoadState('networkidle'); + }); + + test.describe('Hero CTA Buttons', () => { + test('should render primary CTA button in hero section', async ({ page }) => { + const primaryCta = page.locator('[data-testid="hero-primary-cta"]'); + await expect(primaryCta).toBeVisible(); + + // Verify CTA text + const ctaText = await primaryCta.textContent(); + expect(ctaText.trim()).toBe('Get Started'); + + // Verify href points to registration + const href = await primaryCta.getAttribute('href'); + expect(href).toBe('/register'); + }); + + test('should render secondary CTA button in hero section', async ({ page }) => { + const secondaryCta = page.locator('[data-testid="hero-secondary-cta"]'); + await expect(secondaryCta).toBeVisible(); + + // Verify CTA text + const ctaText = await secondaryCta.textContent(); + expect(ctaText.trim()).toBe('Create Short URL'); + + // Verify href points to URL creation + const href = await secondaryCta.getAttribute('href'); + expect(href).toBe('/create'); + }); + }); + + test.describe('Bottom CTA Section', () => { + test('should render CTA section at bottom of page', async ({ page }) => { + const ctaSection = page.locator('[data-testid="cta-section"]'); + await expect(ctaSection).toBeVisible(); + + // Verify section has a heading + const heading = ctaSection.locator('h2#cta-title'); + await expect(heading).toBeVisible(); + + // Verify heading text + const headingText = await heading.textContent(); + expect(headingText).toBeTruthy(); + }); + + test('should render "Create Short URL" button in bottom CTA section', async ({ page }) => { + const createCta = page.locator('[data-testid="cta-create-short-url"]'); + await expect(createCta).toBeVisible(); + + // Verify text + const ctaText = await createCta.textContent(); + expect(ctaText.trim()).toBe('Create Short URL'); + + // Verify href + const href = await createCta.getAttribute('href'); + expect(href).toBe('/create'); + + // Verify button styling + const classAttr = await createCta.getAttribute('class'); + expect(classAttr).toContain('btn'); + }); + + test('should render "Get Started" button in bottom CTA section', async ({ page }) => { + const getStartedCta = page.locator('[data-testid="cta-get-started"]'); + await expect(getStartedCta).toBeVisible(); + + // Verify text + const ctaText = await getStartedCta.textContent(); + expect(ctaText.trim()).toBe('Get Started'); + + // Verify href + const href = await getStartedCta.getAttribute('href'); + expect(href).toBe('/register'); + + // Verify button styling + const classAttr = await getStartedCta.getAttribute('class'); + expect(classAttr).toContain('btn'); + }); + }); + + test.describe('CTA Redirect Behavior', () => { + test('clicking "Create Short URL" CTA redirects to URL creation page', async ({ page }) => { + // Test hero secondary CTA + const heroCreateCta = page.locator('[data-testid="hero-secondary-cta"]'); + + // Click and wait for navigation + await Promise.all([ + page.waitForURL('**/create', { timeout: 5000 }), + heroCreateCta.click(), + ]); + + // Verify URL + expect(page.url()).toContain('/create'); + }); + + test('clicking "Get Started" CTA redirects to registration page', async ({ page }) => { + // Test hero primary CTA + const heroGetStartedCta = page.locator('[data-testid="hero-primary-cta"]'); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Click and wait for navigation + await Promise.all([ + page.waitForURL('**/register', { timeout: 5000 }), + heroGetStartedCta.click(), + ]); + + // Verify URL + expect(page.url()).toContain('/register'); + }); + + test('bottom CTA "Create Short URL" button navigates to /create', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const bottomCreateCta = page.locator('[data-testid="cta-create-short-url"]'); + + await Promise.all([ + page.waitForURL('**/create', { timeout: 5000 }), + bottomCreateCta.click(), + ]); + + expect(page.url()).toContain('/create'); + }); + + test('bottom CTA "Get Started" button navigates to /register', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const bottomGetStartedCta = page.locator('[data-testid="cta-get-started"]'); + + await Promise.all([ + page.waitForURL('**/register', { timeout: 5000 }), + bottomGetStartedCta.click(), + ]); + + expect(page.url()).toContain('/register'); + }); + }); + + test.describe('CTA Visual States', () => { + test('CTA buttons show visual feedback on hover', async ({ page }) => { + const primaryCta = page.locator('[data-testid="hero-primary-cta"]'); + await expect(primaryCta).toBeVisible(); + + // Get initial styles + const initialStyles = await primaryCta.evaluate(el => { + const computed = window.getComputedStyle(el); + return { + opacity: computed.opacity, + transform: computed.transform, + }; + }); + + // Hover over the button + await primaryCta.hover(); + await page.waitForTimeout(200); // Wait for transition + + // Get hover styles + const hoverStyles = await primaryCta.evaluate(el => { + const computed = window.getComputedStyle(el); + return { + opacity: computed.opacity, + transform: computed.transform, + }; + }); + + // Some visual change should occur on hover + // (either opacity change, transform, or a CSS class is applied) + const hasHoverEffect = + initialStyles.opacity !== hoverStyles.opacity || + initialStyles.transform !== hoverStyles.transform || + await primaryCta.evaluate(el => { + return el.matches(':hover'); + }); + + // DaisyUI buttons typically have hover states via CSS + // We verify the button element exists and is interactive + expect(await primaryCta.isEnabled()).toBe(true); + }); + + test('CTA buttons have visible focus state when tabbed to', async ({ page }) => { + const primaryCta = page.locator('[data-testid="hero-primary-cta"]'); + await expect(primaryCta).toBeVisible(); + + // Tab to the CTA button + await primaryCta.focus(); + + // Verify the element is focused + const isFocused = await primaryCta.evaluate(el => el === document.activeElement); + expect(isFocused).toBe(true); + + // Verify focus is visible (element has some outline or box-shadow) + const focusStyles = await primaryCta.evaluate(el => { + const computed = window.getComputedStyle(el); + return { + outlineWidth: computed.outlineWidth, + outlineStyle: computed.outlineStyle, + outlineColor: computed.outlineColor, + boxShadow: computed.boxShadow, + }; + }); + + // Focus should be visible (either outline or box-shadow) + const hasVisibleFocus = + focusStyles.outlineWidth !== '0px' && focusStyles.outlineStyle !== 'none' || + focusStyles.boxShadow !== 'none'; + + expect(hasVisibleFocus).toBe(true); + }); + + test('bottom CTA buttons have visible focus state', async ({ page }) => { + const bottomCta = page.locator('[data-testid="cta-create-short-url"]'); + await expect(bottomCta).toBeVisible(); + + await bottomCta.focus(); + + const isFocused = await bottomCta.evaluate(el => el === document.activeElement); + expect(isFocused).toBe(true); + + const focusStyles = await bottomCta.evaluate(el => { + const computed = window.getComputedStyle(el); + return { + outlineWidth: computed.outlineWidth, + outlineStyle: computed.outlineStyle, + boxShadow: computed.boxShadow, + }; + }); + + const hasVisibleFocus = + focusStyles.outlineWidth !== '0px' && focusStyles.outlineStyle !== 'none' || + focusStyles.boxShadow !== 'none'; + + expect(hasVisibleFocus).toBe(true); + }); + }); + + test.describe('CTA Touch Target Accessibility', () => { + test('hero CTA buttons meet minimum touch target size on mobile (44x44px)', async ({ page }) => { + // Set viewport to mobile size + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const primaryCta = page.locator('[data-testid="hero-primary-cta"]'); + const secondaryCta = page.locator('[data-testid="hero-secondary-cta"]'); + + await expect(primaryCta).toBeVisible(); + await expect(secondaryCta).toBeVisible(); + + // Check primary CTA touch target + const primaryBox = await primaryCta.boundingBox(); + expect(primaryBox.width).toBeGreaterThanOrEqual(44); + expect(primaryBox.height).toBeGreaterThanOrEqual(44); + + // Check secondary CTA touch target + const secondaryBox = await secondaryCta.boundingBox(); + expect(secondaryBox.width).toBeGreaterThanOrEqual(44); + expect(secondaryBox.height).toBeGreaterThanOrEqual(44); + }); + + test('bottom CTA buttons meet minimum touch target size on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const createCta = page.locator('[data-testid="cta-create-short-url"]'); + const getStartedCta = page.locator('[data-testid="cta-get-started"]'); + + await expect(createCta).toBeVisible(); + await expect(getStartedCta).toBeVisible(); + + const createBox = await createCta.boundingBox(); + expect(createBox.width).toBeGreaterThanOrEqual(44); + expect(createBox.height).toBeGreaterThanOrEqual(44); + + const getStartedBox = await getStartedCta.boundingBox(); + expect(getStartedBox.width).toBeGreaterThanOrEqual(44); + expect(getStartedBox.height).toBeGreaterThanOrEqual(44); + }); + + test('CTA buttons remain touch-friendly on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const buttons = [ + page.locator('[data-testid="hero-primary-cta"]'), + page.locator('[data-testid="hero-secondary-cta"]'), + page.locator('[data-testid="cta-create-short-url"]'), + page.locator('[data-testid="cta-get-started"]'), + ]; + + for (const button of buttons) { + await expect(button).toBeVisible(); + const box = await button.boundingBox(); + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + } + }); + }); + + test.describe('CTA JavaScript Interactivity', () => { + test('clicking a CTA button stores click info in sessionStorage', async ({ page }) => { + // Clear sessionStorage first + await page.evaluate(() => sessionStorage.clear()); + + const primaryCta = page.locator('[data-testid="hero-primary-cta"]'); + await expect(primaryCta).toBeVisible(); + + // Click the button + await primaryCta.click(); + + // Check sessionStorage was updated + const ctaData = await page.evaluate(() => { + const data = sessionStorage.getItem('lastCtaClicked'); + return data ? JSON.parse(data) : null; + }); + + expect(ctaData).not.toBeNull(); + expect(ctaData.label).toBe('Get Started'); + expect(ctaData.href).toBe('/register'); + expect(ctaData.timestamp).toBeGreaterThan(0); + }); + + test('all CTA buttons are anchor elements with proper href attributes', async ({ page }) => { + const ctaSelectors = [ + '[data-testid="hero-primary-cta"]', + '[data-testid="hero-secondary-cta"]', + '[data-testid="cta-create-short-url"]', + '[data-testid="cta-get-started"]', + ]; + + for (const selector of ctaSelectors) { + const button = page.locator(selector); + await expect(button).toBeVisible(); + + // Verify it's an anchor tag + const tagName = await button.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('a'); + + // Verify it has an href + const href = await button.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/e2e/features.spec.js b/tests/e2e/features.spec.js new file mode 100644 index 000000000..bde60f3f2 --- /dev/null +++ b/tests/e2e/features.spec.js @@ -0,0 +1,148 @@ +/** + * Features Section E2E Tests + * Owner: Scenario 3 - Features Section + * + * Tests: + * - Feature cards count and styling + * - Feature card content (URL Shortening, Analytics, Dashboard) + * - Accessibility (alt text, semantic markup) + * - Responsive layout at 375px + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('Features Section', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for the features section to be visible + await page.waitForSelector('#features', { state: 'visible' }); + }); + + test('should render 4 feature cards with consistent styling', async ({ page }) => { + const featuresSection = page.locator('#features'); + await expect(featuresSection).toBeVisible(); + + const cards = featuresSection.locator('article.feature-card'); + await expect(cards).toHaveCount(4); + + // Verify each card has consistent styling + for (let i = 0; i < 4; i++) { + const card = cards.nth(i); + await expect(card).toHaveClass(/feature-card/); + await expect(card).toHaveClass(/card/); + await expect(card).toHaveClass(/bg-base-200/); + await expect(card).toHaveClass(/border/); + await expect(card).toHaveClass(/p-6/); + } + }); + + test('should include URL Shortening, Analytics, and Dashboard feature cards', async ({ page }) => { + const cards = page.locator('#features article.feature-card'); + await expect(cards).toHaveCount(4); + + // Check for specific feature titles + const titles = cards.locator('h3'); + const titleTexts = await titles.allTextContents(); + + expect(titleTexts).toContain('URL Shortening'); + expect(titleTexts).toContain('Analytics'); + expect(titleTexts).toContain('Dashboard'); + }); + + test('should have descriptive content for each feature', async ({ page }) => { + const cards = page.locator('#features article.feature-card'); + const descriptions = cards.locator('p'); + + const descTexts = await descriptions.allTextContents(); + + // URL Shortening description + const urlShorteningDesc = descTexts.find(d => d.includes('short links')); + expect(urlShorteningDesc).toBeTruthy(); + + // Analytics description + const analyticsDesc = descTexts.find(d => d.includes('clicks') || d.includes('Track')); + expect(analyticsDesc).toBeTruthy(); + + // Dashboard description + const dashboardDesc = descTexts.find(d => d.includes('dashboard')); + expect(dashboardDesc).toBeTruthy(); + }); + + test('should have feature icons with alt text', async ({ page }) => { + const icons = page.locator('#features article.feature-card img'); + await expect(icons).toHaveCount(4); + + // Verify each icon has alt text + for (let i = 0; i < 4; i++) { + const icon = icons.nth(i); + const altText = await icon.getAttribute('alt'); + expect(altText).toBeTruthy(); + expect(altText.length).toBeGreaterThan(0); + } + + // Verify specific alt text content + const allAltTexts = await icons.evaluateAll(imgs => imgs.map(img => img.alt)); + expect(allAltTexts.some(alt => alt.toLowerCase().includes('link'))).toBe(true); + expect(allAltTexts.some(alt => alt.toLowerCase().includes('chart') || alt.toLowerCase().includes('analytics'))).toBe(true); + expect(allAltTexts.some(alt => alt.toLowerCase().includes('dashboard'))).toBe(true); + }); + + test('should use semantic markup for accessibility', async ({ page }) => { + const featuresSection = page.locator('#features'); + + // Section has an aria-labelledby pointing to a heading + const ariaLabelledBy = await featuresSection.getAttribute('aria-labelledby'); + expect(ariaLabelledBy).toBe('features-heading'); + + // The heading exists and has the matching id + const heading = page.locator('#features-heading'); + await expect(heading).toBeVisible(); + const headingId = await heading.getAttribute('id'); + expect(headingId).toBe('features-heading'); + + // Feature cards use article elements (semantic) + const articles = featuresSection.locator('article'); + await expect(articles).toHaveCount(4); + + // Each article has an h3 heading + for (let i = 0; i < 4; i++) { + const heading = articles.nth(i).locator('h3'); + await expect(heading).toBeVisible(); + } + }); + + test('should stack cards vertically on mobile (375px)', async ({ page }) => { + // Set viewport to mobile size + await page.setViewportSize({ width: 375, height: 812 }); + + const cards = page.locator('#features article.feature-card'); + await expect(cards).toHaveCount(4); + + // On mobile, cards should be in a single column + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + const thirdCard = await cards.nth(2).boundingBox(); + const fourthCard = await cards.nth(3).boundingBox(); + + // Cards should be stacked vertically (each below the previous) + expect(secondCard.y).toBeGreaterThan(firstCard.y); + expect(thirdCard.y).toBeGreaterThan(secondCard.y); + expect(fourthCard.y).toBeGreaterThan(thirdCard.y); + + // On mobile, each card should roughly span the full width + const viewportWidth = 375; + expect(firstCard.width).toBeGreaterThan(viewportWidth * 0.7); + }); + + test('should have proper spacing between cards on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + + const cards = page.locator('#features article.feature-card'); + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + + // There should be gap between cards (gap-8 = 2rem = 32px) + const gap = secondCard.y - (firstCard.y + firstCard.height); + expect(gap).toBeGreaterThanOrEqual(20); + }); +}); diff --git a/tests/e2e/footer.spec.js b/tests/e2e/footer.spec.js new file mode 100644 index 000000000..2a9427fa6 --- /dev/null +++ b/tests/e2e/footer.spec.js @@ -0,0 +1,202 @@ +/** + * Footer Section E2E Tests + * Owner: Scenario 6 - Footer Section + * + * Tests: + * - Footer DOM structure (organized link groups, copyright text) + * - Documentation link presence and navigation + * - Privacy Policy link presence and navigation + * - Terms of Service link presence and navigation + * - Copyright text presence and formatting + * - Responsive layout at mobile viewport (375px) + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('Footer Section', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for the footer to be visible + await page.waitForSelector('footer', { state: 'visible' }); + }); + + test('should render footer with organized link groups and copyright text', async ({ page }) => { + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + + // Verify footer has role="contentinfo" for accessibility + const role = await footer.getAttribute('role'); + expect(role).toBe('contentinfo'); + + // Verify footer contains link group + const linkGroup = footer.locator('[data-testid="footer-links"]'); + await expect(linkGroup).toBeVisible(); + + // Verify footer contains copyright text + const copyright = footer.locator('[data-testid="footer-copyright"]'); + await expect(copyright).toBeVisible(); + + // Verify all required links are present + const docsLink = footer.locator('[data-testid="footer-link-docs"]'); + const privacyLink = footer.locator('[data-testid="footer-link-privacy"]'); + const termsLink = footer.locator('[data-testid="footer-link-terms"]'); + + await expect(docsLink).toBeVisible(); + await expect(privacyLink).toBeVisible(); + await expect(termsLink).toBeVisible(); + + // Verify link text content + const docsText = await docsLink.textContent(); + expect(docsText.toLowerCase()).toContain('documentation'); + + const privacyText = await privacyLink.textContent(); + expect(privacyText.toLowerCase()).toContain('privacy'); + + const termsText = await termsLink.textContent(); + expect(termsText.toLowerCase()).toContain('terms'); + }); + + test('should have copyright text present and correctly formatted', async ({ page }) => { + const footer = page.locator('footer'); + + const copyright = footer.locator('[data-testid="footer-copyright"]'); + await expect(copyright).toBeVisible(); + + const copyrightText = await copyright.textContent(); + expect(copyrightText).toBeTruthy(); + + // Should contain copyright symbol or the word "copyright" + const lowerText = copyrightText.toLowerCase(); + expect( + lowerText.includes('copyright') || + lowerText.includes('©') || + lowerText.includes('©') + ).toBe(true); + + // Should reference MirDB or the current year + expect( + lowerText.includes('mirdb') || + lowerText.includes('2024') || + lowerText.includes('2025') || + lowerText.includes('2026') + ).toBe(true); + }); + + test('should navigate to documentation page when clicking Documentation link', async ({ page }) => { + const docsLink = page.locator('footer [data-testid="footer-link-docs"]'); + await expect(docsLink).toBeVisible(); + + const href = await docsLink.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.length).toBeGreaterThan(0); + + // Verify href points to documentation + const lowerHref = href.toLowerCase(); + expect( + lowerHref.includes('doc') || + lowerHref.includes('/docs') + ).toBe(true); + + // Verify link is an anchor element + const tagName = await docsLink.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('a'); + }); + + test('should navigate to privacy policy page when clicking Privacy Policy link', async ({ page }) => { + const privacyLink = page.locator('footer [data-testid="footer-link-privacy"]'); + await expect(privacyLink).toBeVisible(); + + const href = await privacyLink.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.length).toBeGreaterThan(0); + + // Verify href points to privacy policy + const lowerHref = href.toLowerCase(); + expect( + lowerHref.includes('privacy') || + lowerHref.includes('/privacy') + ).toBe(true); + + // Verify link is an anchor element + const tagName = await privacyLink.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('a'); + }); + + test('should navigate to terms of service page when clicking Terms of Service link', async ({ page }) => { + const termsLink = page.locator('footer [data-testid="footer-link-terms"]'); + await expect(termsLink).toBeVisible(); + + const href = await termsLink.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.length).toBeGreaterThan(0); + + // Verify href points to terms of service + const lowerHref = href.toLowerCase(); + expect( + lowerHref.includes('terms') || + lowerHref.includes('/terms') || + lowerHref.includes('service') + ).toBe(true); + + // Verify link is an anchor element + const tagName = await termsLink.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('a'); + }); + + test('should reorganize footer layout for mobile readability at 375px', async ({ page }) => { + // Set viewport to mobile size + await page.setViewportSize({ width: 375, height: 812 }); + + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + + // All footer links should be visible on mobile + const links = footer.locator('[data-testid="footer-links"] a'); + const linkCount = await links.count(); + expect(linkCount).toBeGreaterThanOrEqual(3); + + for (let i = 0; i < linkCount; i++) { + await expect(links.nth(i)).toBeVisible(); + } + + // Copyright should be visible + const copyright = footer.locator('[data-testid="footer-copyright"]'); + await expect(copyright).toBeVisible(); + + // Verify no horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + + // Footer should fit within viewport width + const footerBox = await footer.boundingBox(); + expect(footerBox.width).toBeLessThanOrEqual(viewportWidth + 1); + }); + + test('should have accessible footer with proper semantic markup', async ({ page }) => { + const footer = page.locator('footer'); + + // Footer should have contentinfo role + const role = await footer.getAttribute('role'); + expect(role).toBe('contentinfo'); + + // All links should be focusable anchor elements + const links = footer.locator('a'); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(3); + + for (let i = 0; i < count; i++) { + const link = links.nth(i); + const tagName = await link.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('a'); + + const href = await link.getAttribute('href'); + expect(href).toBeTruthy(); + } + + // Links should have visible text content + const docsLink = footer.locator('[data-testid="footer-link-docs"]'); + const docsText = await docsLink.textContent(); + expect(docsText.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/homepage.spec.js b/tests/e2e/homepage.spec.js new file mode 100644 index 000000000..3f2ad60b9 --- /dev/null +++ b/tests/e2e/homepage.spec.js @@ -0,0 +1,394 @@ +/** + * Hero Section Rendering E2E Tests + * Owner: Scenario 2 - Hero Section Rendering + * + * Tests: + * - Hero section DOM structure (h1, tagline, CTA) + * - Hero text content and visibility + * - Primary CTA button presence and visibility + * - Responsive layout at mobile, tablet, and desktop viewports + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('Hero Section Rendering', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for the hero section to be visible + await page.waitForSelector('[data-testid="hero-section"]', { state: 'visible' }); + }); + + test('should render hero section with product title describing URL shortening service', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + // Verify h1 element exists and is visible + const title = heroSection.locator('h1#hero-title'); + await expect(title).toBeVisible(); + + // Verify title text describes URL shortening + const titleText = await title.textContent(); + expect(titleText).toBeTruthy(); + expect(titleText.toLowerCase()).toContain('shorten'); + expect(titleText.toLowerCase()).toContain('url'); + + // Verify h1 uses semantic heading element + const tagName = await title.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('h1'); + }); + + test('should render hero tagline explaining the value proposition', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + + // Verify tagline element exists and is visible + const tagline = heroSection.locator('[data-testid="hero-tagline"]'); + await expect(tagline).toBeVisible(); + + // Verify tagline text explains value proposition + const taglineText = await tagline.textContent(); + expect(taglineText).toBeTruthy(); + expect(taglineText.length).toBeGreaterThan(20); + + // Tagline should mention key value propositions + const lowerText = taglineText.toLowerCase(); + expect( + lowerText.includes('short') || + lowerText.includes('link') || + lowerText.includes('track') || + lowerText.includes('analytics') + ).toBe(true); + }); + + test('should render primary CTA button that is present and visible', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + + // Verify primary CTA button exists and is visible + const ctaButton = heroSection.locator('[data-testid="hero-primary-cta"]'); + await expect(ctaButton).toBeVisible(); + + // Verify CTA text is appropriate + const ctaText = await ctaButton.textContent(); + expect(ctaText).toBeTruthy(); + const lowerCta = ctaText.toLowerCase().trim(); + expect( + lowerCta.includes('get started') || + lowerCta.includes('create short url') || + lowerCta.includes('shorten') + ).toBe(true); + + // Verify the CTA is an anchor link with an href + const href = await ctaButton.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.length).toBeGreaterThan(0); + + // Verify button has appropriate styling classes + const classAttr = await ctaButton.getAttribute('class'); + expect(classAttr).toContain('btn'); + expect(classAttr).toContain('btn-primary'); + }); + + test('should render hero content centered and readable on mobile (375px)', async ({ page }) => { + // Set viewport to mobile size + await page.setViewportSize({ width: 375, height: 812 }); + + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + // Get the hero content container + const heroContent = heroSection.locator('.hero-content'); + await expect(heroContent).toBeVisible(); + + // Verify hero content is centered (horizontal center alignment) + const contentBox = await heroContent.boundingBox(); + const sectionBox = await heroSection.boundingBox(); + + // Content should be within section bounds + expect(contentBox.x).toBeGreaterThanOrEqual(0); + expect(contentBox.x + contentBox.width).toBeLessThanOrEqual(sectionBox.x + sectionBox.width + 1); + + // Title should be visible and readable + const title = heroSection.locator('h1#hero-title'); + await expect(title).toBeVisible(); + + // Tagline should be visible + const tagline = heroSection.locator('[data-testid="hero-tagline"]'); + await expect(tagline).toBeVisible(); + + // CTA should be visible and touch-friendly (min 44x44px) + const cta = heroSection.locator('[data-testid="hero-primary-cta"]'); + const ctaBox = await cta.boundingBox(); + expect(ctaBox.width).toBeGreaterThanOrEqual(44); + expect(ctaBox.height).toBeGreaterThanOrEqual(44); + + // No horizontal overflow (scroll) + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + }); + + test('should render hero content centered and readable on tablet (768px)', async ({ page }) => { + // Set viewport to tablet size + await page.setViewportSize({ width: 768, height: 1024 }); + + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + // Verify hero content is centered + const heroContent = heroSection.locator('.hero-content'); + const contentBox = await heroContent.boundingBox(); + const sectionBox = await heroSection.boundingBox(); + + // Content should be roughly centered horizontally + const contentCenter = contentBox.x + contentBox.width / 2; + const sectionCenter = sectionBox.x + sectionBox.width / 2; + expect(Math.abs(contentCenter - sectionCenter)).toBeLessThanOrEqual(50); + + // All hero elements should be visible + await expect(heroSection.locator('h1#hero-title')).toBeVisible(); + await expect(heroSection.locator('[data-testid="hero-tagline"]')).toBeVisible(); + await expect(heroSection.locator('[data-testid="hero-primary-cta"]')).toBeVisible(); + + // No horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + }); + + test('should render hero content centered and readable on desktop (1280px)', async ({ page }) => { + // Set viewport to desktop size + await page.setViewportSize({ width: 1280, height: 800 }); + + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + // Verify hero content is centered + const heroContent = heroSection.locator('.hero-content'); + const contentBox = await heroContent.boundingBox(); + const sectionBox = await heroSection.boundingBox(); + + // Content should be centered horizontally + const contentCenter = contentBox.x + contentBox.width / 2; + const sectionCenter = sectionBox.x + sectionBox.width / 2; + expect(Math.abs(contentCenter - sectionCenter)).toBeLessThanOrEqual(50); + + // All hero elements should be visible + await expect(heroSection.locator('h1#hero-title')).toBeVisible(); + await expect(heroSection.locator('[data-testid="hero-tagline"]')).toBeVisible(); + await expect(heroSection.locator('[data-testid="hero-primary-cta"]')).toBeVisible(); + + // No horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + }); + + test('should use semantic HTML structure for the hero section', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + + // Section should have aria-labelledby pointing to the title + const ariaLabelledBy = await heroSection.getAttribute('aria-labelledby'); + expect(ariaLabelledBy).toBe('hero-title'); + + // Title should exist with matching id + const title = page.locator('#hero-title'); + await expect(title).toBeVisible(); + const titleId = await title.getAttribute('id'); + expect(titleId).toBe('hero-title'); + + // Title should be h1 + const tagName = await title.evaluate(el => el.tagName.toLowerCase()); + expect(tagName).toBe('h1'); + + // CTA should be an anchor element (link) + const cta = heroSection.locator('[data-testid="hero-primary-cta"]'); + const ctaTag = await cta.evaluate(el => el.tagName.toLowerCase()); + expect(ctaTag).toBe('a'); + }); + + test('should maintain correct DOM order: title, then tagline, then CTA', async ({ page }) => { + const heroSection = page.locator('[data-testid="hero-section"]'); + + // Get all direct child elements within the max-w-2xl container + const container = heroSection.locator('.max-w-2xl'); + + // Get the order of elements + const elementOrder = await container.evaluate(container => { + const children = Array.from(container.children); + return children.map(child => ({ + tag: child.tagName.toLowerCase(), + id: child.id || null, + testId: child.getAttribute('data-testid') || null, + })); + }); + + // First element should be h1 title + expect(elementOrder[0].tag).toBe('h1'); + expect(elementOrder[0].id).toBe('hero-title'); + + // Second element should be the tagline paragraph + expect(elementOrder[1].tag).toBe('p'); + expect(elementOrder[1].testId).toBe('hero-tagline'); + + // Third element should be the CTA anchor + expect(elementOrder[2].tag).toBe('a'); + expect(elementOrder[2].testId).toBe('hero-primary-cta'); + }); +}); + +/** + * Performance and Loading Tests + * Owner: Scenario 11 - Performance and Loading + * + * Tests: + * - First Contentful Paint (FCP) < 1.5s on fast connection + * - Largest Contentful Paint (LCP) < 2.5s on fast connection + * - Page load time on simulated 3G < 2s for critical content + * - Cumulative Layout Shift (CLS) < 0.1 + */ + +test.describe('Performance and Loading', () => { + test('should have First Contentful Paint < 1.5s and LCP < 2.5s on fast connection', async ({ page }) => { + // Navigate first, then collect metrics + await page.goto('/', { waitUntil: 'networkidle' }); + + // Wait for hero section to be visible (critical content) + await page.waitForSelector('[data-testid="hero-section"]', { state: 'visible' }); + + // Collect FCP metric from Performance API + const fcp = await page.evaluate(() => { + const entries = performance.getEntriesByType('paint'); + const fcpEntry = entries.find(e => e.name === 'first-contentful-paint'); + return fcpEntry ? fcpEntry.startTime : 0; + }); + + // Collect LCP metric from Performance API + const lcp = await page.evaluate(() => { + const entries = performance.getEntriesByType('largest-contentful-paint'); + if (entries.length > 0) { + return entries[entries.length - 1].startTime; + } + return 0; + }); + + // Assert FCP < 1.5s (1500ms) - allow some margin for CI environments + expect(fcp).toBeGreaterThan(0); + expect(fcp).toBeLessThan(1500); + + // Assert LCP < 2.5s (2500ms) - allow some margin for CI environments + // LCP may be 0 if not yet recorded; in that case, use DOM content as proxy + if (lcp > 0) { + expect(lcp).toBeLessThan(2500); + } + }); + + test('should load critical content within 2 seconds on simulated 3G', async ({ page, context }) => { + // Emulate 3G network conditions using CDP + const client = await page.context().newCDPSession(page); + await client.send('Network.emulateNetworkConditions', { + offline: false, + downloadThroughput: 1.6 * 1024 * 1024 / 8, // 1.6 Mbps (Fast 3G download) + uploadThroughput: 750 * 1024 / 8, // 750 Kbps (Fast 3G upload) + latency: 150, // 150ms latency + }); + + // Record navigation start time + const startTime = Date.now(); + + // Navigate to the page + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + // Wait for critical content (hero section) to be visible + await page.waitForSelector('[data-testid="hero-section"]', { state: 'visible', timeout: 5000 }); + + const criticalContentLoadedTime = Date.now() - startTime; + + // Assert critical content loads within 2 seconds + expect(criticalContentLoadedTime).toBeLessThan(2000); + + // Clean up: reset network conditions + await client.send('Network.emulateNetworkConditions', { + offline: false, + downloadThroughput: -1, + uploadThroughput: -1, + latency: 0, + }); + }); + + test('should have Cumulative Layout Shift (CLS) score < 0.1', async ({ page }) => { + // Collect layout shift entries + const clsScore = await page.evaluate(() => { + return new Promise((resolve) => { + let cls = 0; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + cls += entry.value; + } + } + }); + observer.observe({ type: 'layout-shift', buffered: true }); + + // Report CLS after a short delay to capture initial shifts + setTimeout(() => { + observer.disconnect(); + resolve(cls); + }, 500); + }); + }); + + // Navigate first, then collect CLS + await page.goto('/', { waitUntil: 'networkidle' }); + + // Wait for page to fully settle + await page.waitForTimeout(500); + + // Get the CLS score + const finalCls = await page.evaluate(() => { + let cls = 0; + const entries = performance.getEntriesByType('layout-shift'); + for (const entry of entries) { + if (!entry.hadRecentInput) { + cls += entry.value; + } + } + return cls; + }); + + // Assert CLS < 0.1 + expect(finalCls).toBeLessThan(0.1); + }); + + test('should have optimized resource loading with preconnect and fetchpriority', async ({ page }) => { + await page.goto('/'); + + // Check for preconnect hint + const preconnectLink = page.locator('link[rel="preconnect"]'); + await expect(preconnectLink).toHaveCount(1); + + // Check for fetchpriority on critical CSS + const cssLink = page.locator('link[href="/static/css/main.css"]'); + const fetchPriority = await cssLink.getAttribute('fetchpriority'); + expect(fetchPriority).toBe('high'); + }); + + test('should have explicit image dimensions and lazy loading for below-fold images', async ({ page }) => { + await page.goto('/'); + + // Check feature section images have width and height attributes + const featureImages = page.locator('#features img'); + const imageCount = await featureImages.count(); + expect(imageCount).toBeGreaterThan(0); + + for (let i = 0; i < imageCount; i++) { + const img = featureImages.nth(i); + const width = await img.getAttribute('width'); + const height = await img.getAttribute('height'); + expect(width).toBeTruthy(); + expect(height).toBeTruthy(); + + // Below-fold images should have loading="lazy" + const loading = await img.getAttribute('loading'); + expect(loading).toBe('lazy'); + } + }); +}); diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js new file mode 100644 index 000000000..dddeaa1af --- /dev/null +++ b/tests/e2e/navigation.spec.js @@ -0,0 +1,273 @@ +/** + * Navigation Links E2E Tests + * Owner: Scenario 4 - Navigation Links + * + * Tests: + * - Navigation element exists with expected links + * - Unauthenticated: Login and Sign Up visible; Dashboard hidden + * - Authenticated: Dashboard visible; Login/Sign Up hidden + * - Navigation link routing (login, register, dashboard) + * - Mobile hamburger menu toggle + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('Navigation Links', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('nav', { state: 'visible' }); + }); + + test.describe('Navigation Element', () => { + test('should render navigation element with role and aria-label', async ({ page }) => { + const nav = page.locator('nav'); + await expect(nav).toBeVisible(); + + const role = await nav.getAttribute('role'); + expect(role).toBe('navigation'); + + const ariaLabel = await nav.getAttribute('aria-label'); + expect(ariaLabel).toBe('Main navigation'); + }); + + test('should render brand link pointing to home', async ({ page }) => { + const brand = page.locator('[data-testid="nav-brand"]'); + await expect(brand).toBeVisible(); + + const href = await brand.getAttribute('href'); + expect(href).toBe('/'); + + const text = await brand.textContent(); + expect(text).toBe('MirDB'); + }); + }); + + test.describe('Unauthenticated Navigation', () => { + test('should show Login and Sign Up links for unauthenticated users', async ({ page }) => { + // Use desktop viewport so desktop nav links are visible + await page.setViewportSize({ width: 1280, height: 800 }); + // Ensure unauthenticated state + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const loginLink = page.locator('[data-testid="nav-login"]'); + const signupLink = page.locator('[data-testid="nav-signup"]'); + + await expect(loginLink).toBeVisible(); + await expect(signupLink).toBeVisible(); + + // Verify link text + expect(await loginLink.textContent()).toContain('Log In'); + expect(await signupLink.textContent()).toContain('Sign Up'); + }); + + test('should hide Dashboard link for unauthenticated users', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const dashboardLink = page.locator('[data-testid="nav-dashboard"]'); + await expect(dashboardLink).toBeHidden(); + }); + + test('should show unauthenticated mobile links', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + // Open mobile menu + const toggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await toggle.click(); + + const mobileLogin = page.locator('[data-testid="mobile-login"]'); + const mobileSignup = page.locator('[data-testid="mobile-signup"]'); + + await expect(mobileLogin).toBeVisible(); + await expect(mobileSignup).toBeVisible(); + }); + }); + + test.describe('Authenticated Navigation', () => { + test('should show Dashboard link for authenticated users', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'authenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const dashboardLink = page.locator('[data-testid="nav-dashboard"]'); + await expect(dashboardLink).toBeVisible(); + + const href = await dashboardLink.getAttribute('href'); + expect(href).toBe('/dashboard'); + + const text = await dashboardLink.textContent(); + expect(text).toContain('Dashboard'); + }); + + test('should hide Login and Sign Up links for authenticated users', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'authenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const loginLink = page.locator('[data-testid="nav-login"]'); + const signupLink = page.locator('[data-testid="nav-signup"]'); + + await expect(loginLink).toBeHidden(); + await expect(signupLink).toBeHidden(); + }); + + test('should show authenticated mobile links', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'authenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + // Open mobile menu + const toggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await toggle.click(); + + const mobileDashboard = page.locator('[data-testid="mobile-dashboard"]'); + await expect(mobileDashboard).toBeVisible(); + + const mobileLogin = page.locator('[data-testid="mobile-login"]'); + await expect(mobileLogin).toBeHidden(); + }); + }); + + test.describe('Navigation Link Routing', () => { + test('should redirect to /login when clicking Login link', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const loginLink = page.locator('[data-testid="nav-login"]'); + await expect(loginLink).toBeVisible(); + + const href = await loginLink.getAttribute('href'); + expect(href).toBe('/login'); + }); + + test('should redirect to /register when clicking Sign Up link', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const signupLink = page.locator('[data-testid="nav-signup"]'); + await expect(signupLink).toBeVisible(); + + const href = await signupLink.getAttribute('href'); + expect(href).toBe('/register'); + }); + + test('should redirect to /dashboard when clicking Dashboard link', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'authenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const dashboardLink = page.locator('[data-testid="nav-dashboard"]'); + await expect(dashboardLink).toBeVisible(); + + const href = await dashboardLink.getAttribute('href'); + expect(href).toBe('/dashboard'); + }); + }); + + test.describe('Mobile Navigation Menu', () => { + test('should toggle mobile menu on hamburger button click', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + const toggle = page.locator('[data-testid="mobile-menu-toggle"]'); + const menu = page.locator('[data-testid="mobile-menu"]'); + + // Menu should initially be hidden + await expect(menu).toHaveAttribute('aria-hidden', 'true'); + await expect(menu).toBeHidden(); + + // Click toggle to open + await toggle.click(); + await expect(menu).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(menu).toHaveAttribute('aria-hidden', 'false'); + + // Click toggle again to close + await toggle.click(); + await expect(menu).toBeHidden(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).toHaveAttribute('aria-hidden', 'true'); + }); + + test('should reveal navigation links in mobile menu', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => { + localStorage.setItem('auth_state', 'unauthenticated'); + window.location.reload(); + }); + await page.waitForSelector('nav', { state: 'visible' }); + + // Desktop nav should be hidden on mobile + const desktopNav = page.locator('[data-testid="desktop-nav"]'); + await expect(desktopNav).toBeHidden(); + + // Open mobile menu + const toggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await toggle.click(); + + // Mobile links should be visible + const mobileLogin = page.locator('[data-testid="mobile-login"]'); + const mobileSignup = page.locator('[data-testid="mobile-signup"]'); + + await expect(mobileLogin).toBeVisible(); + await expect(mobileSignup).toBeVisible(); + + // Verify hrefs + expect(await mobileLogin.getAttribute('href')).toBe('/login'); + expect(await mobileSignup.getAttribute('href')).toBe('/register'); + }); + + test('should have accessible hamburger button with aria attributes', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + + const toggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await expect(toggle).toBeVisible(); + + const ariaLabel = await toggle.getAttribute('aria-label'); + expect(ariaLabel).toBe('Toggle navigation menu'); + + const ariaExpanded = await toggle.getAttribute('aria-expanded'); + expect(ariaExpanded).toBe('false'); + + const ariaControls = await toggle.getAttribute('aria-controls'); + expect(ariaControls).toBe('mobile-menu'); + }); + }); +}); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 000000000..a77717fc8 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,38 @@ +/** + * Playwright end-to-end test configuration. + * Owner: Scenario 1 - HTTP Server and Routing + */ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + timeout: 30000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + webServer: { + command: 'python3 -m http.server 8080 --directory /workspace/web', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js new file mode 100644 index 000000000..434911e19 --- /dev/null +++ b/tests/e2e/responsive.spec.js @@ -0,0 +1,380 @@ +/** + * Responsive Design E2E Tests + * Owner: Scenario 7 - Responsive Design + * + * Tests: + * - Mobile viewport (375px): no horizontal scroll, single column, hamburger menu, touch targets >= 44x44px + * - Tablet viewport (768px): adapted layout, 2-column features, navigation visible + * - Desktop viewport (1024px+): full layout, horizontal nav, multi-column features + * - Orientation change from portrait to landscape + * - Text readability at all breakpoints + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('Responsive Design', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for page to be fully loaded + await page.waitForLoadState('networkidle'); + }); + + test('should have no horizontal scrolling and single column layout on mobile (375px)', async ({ page }) => { + // Set viewport to mobile size + await page.setViewportSize({ width: 375, height: 812 }); + + // Wait for layout to settle + await page.waitForTimeout(200); + + // Verify no horizontal scrolling + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + + // Verify document doesn't overflow horizontally + const htmlWidth = await page.evaluate(() => document.documentElement.scrollWidth); + expect(htmlWidth).toBeLessThanOrEqual(viewportWidth + 1); + + // Feature cards should be in a single column (stacked vertically) + const cards = page.locator('#features article.feature-card'); + const count = await cards.count(); + expect(count).toBeGreaterThanOrEqual(3); + + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + + // Cards should be stacked vertically + expect(secondCard.y).toBeGreaterThan(firstCard.y); + + // Each card should roughly span the full width minus padding + expect(firstCard.width).toBeGreaterThan(viewportWidth * 0.7); + }); + + test('should show hamburger menu and hide desktop nav on mobile (375px)', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(200); + + // Hamburger menu toggle should be visible + const hamburgerToggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await expect(hamburgerToggle).toBeVisible(); + + // Hamburger toggle should have minimum touch target size (44x44px) + const toggleBox = await hamburgerToggle.boundingBox(); + expect(toggleBox.width).toBeGreaterThanOrEqual(44); + expect(toggleBox.height).toBeGreaterThanOrEqual(44); + + // Desktop nav should be hidden on mobile + const desktopNav = page.locator('[data-testid="desktop-nav"]'); + await expect(desktopNav).not.toBeVisible(); + + // Mobile menu should be hidden by default + const mobileMenu = page.locator('[data-testid="mobile-menu"]'); + const isVisible = await mobileMenu.isVisible().catch(() => false); + expect(isVisible).toBe(false); + }); + + test('should open mobile menu when hamburger is clicked', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(200); + + const hamburgerToggle = page.locator('[data-testid="mobile-menu-toggle"]'); + await expect(hamburgerToggle).toBeVisible(); + + // Click the hamburger toggle + await hamburgerToggle.click(); + await page.waitForTimeout(100); + + // Mobile menu should now be visible + const mobileMenu = page.locator('[data-testid="mobile-menu"]'); + await expect(mobileMenu).toBeVisible(); + + // Mobile nav links should be visible + const mobileLogin = page.locator('[data-testid="mobile-login"]'); + const mobileSignup = page.locator('[data-testid="mobile-signup"]'); + await expect(mobileLogin).toBeVisible(); + await expect(mobileSignup).toBeVisible(); + + // Mobile nav links should have minimum touch target size + const loginBox = await mobileLogin.boundingBox(); + expect(loginBox.width).toBeGreaterThanOrEqual(44); + expect(loginBox.height).toBeGreaterThanOrEqual(44); + }); + + test('should have touch-friendly buttons (>= 44x44px) on mobile (375px)', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(200); + + // Check all buttons on the page + const buttons = page.locator('.btn, button, a.btn'); + const count = await buttons.count(); + + for (let i = 0; i < count; i++) { + const btn = buttons.nth(i); + const isVisible = await btn.isVisible().catch(() => false); + if (!isVisible) continue; + + const box = await btn.boundingBox(); + if (box && box.width > 0 && box.height > 0) { + // Touch targets should be at least 44x44px + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + } + } + + // Check footer links + const footerLinks = page.locator('footer a'); + const footerCount = await footerLinks.count(); + for (let i = 0; i < footerCount; i++) { + const link = footerLinks.nth(i); + const isVisible = await link.isVisible().catch(() => false); + if (!isVisible) continue; + + const box = await link.boundingBox(); + if (box && box.width > 0 && box.height > 0) { + expect(box.height).toBeGreaterThanOrEqual(44); + } + } + }); + + test('should have adapted 2-column layout on tablet (768px)', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.waitForTimeout(200); + + // No horizontal scrolling + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + + // Navigation should be visible (horizontal nav) + const navLogin = page.locator('[data-testid="nav-login"]'); + const navSignup = page.locator('[data-testid="nav-signup"]'); + await expect(navLogin).toBeVisible(); + await expect(navSignup).toBeVisible(); + + // Hamburger menu should be hidden on tablet + const hamburgerToggle = page.locator('[data-testid="mobile-menu-toggle"]'); + const isHamburgerVisible = await hamburgerToggle.isVisible().catch(() => false); + expect(isHamburgerVisible).toBe(false); + + // Feature cards should be in 2 columns + const cards = page.locator('#features article.feature-card'); + const count = await cards.count(); + expect(count).toBeGreaterThanOrEqual(3); + + // On tablet, cards should be in roughly 2 columns + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + const thirdCard = await cards.nth(2).boundingBox(); + + // First and second cards should be side by side (different x) + expect(secondCard.x).not.toBe(firstCard.x); + + // First and third cards should be in same column (similar x) + expect(Math.abs(thirdCard.x - firstCard.x)).toBeLessThanOrEqual(20); + }); + + test('should show full desktop layout with horizontal nav on desktop (1280px)', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.waitForTimeout(200); + + // No horizontal scrolling + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 1); + + // Navigation links should be visible horizontally + const navLogin = page.locator('[data-testid="nav-login"]'); + const navSignup = page.locator('[data-testid="nav-signup"]'); + await expect(navLogin).toBeVisible(); + await expect(navSignup).toBeVisible(); + + // Hamburger menu should be hidden on desktop + const hamburgerToggle = page.locator('[data-testid="mobile-menu-toggle"]'); + const isHamburgerVisible = await hamburgerToggle.isVisible().catch(() => false); + expect(isHamburgerVisible).toBe(false); + + // Feature cards should be in multi-column layout (4 columns) + const cards = page.locator('#features article.feature-card'); + const count = await cards.count(); + expect(count).toBeGreaterThanOrEqual(4); + + // On desktop, all cards should be in a single row (similar y positions) + const firstCard = await cards.nth(0).boundingBox(); + const secondCard = await cards.nth(1).boundingBox(); + const thirdCard = await cards.nth(2).boundingBox(); + const fourthCard = await cards.nth(3).boundingBox(); + + // All cards should be in the same row (similar y position) + expect(Math.abs(secondCard.y - firstCard.y)).toBeLessThanOrEqual(20); + expect(Math.abs(thirdCard.y - firstCard.y)).toBeLessThanOrEqual(20); + expect(Math.abs(fourthCard.y - firstCard.y)).toBeLessThanOrEqual(20); + }); + + test('should adapt layout correctly on orientation change from portrait to landscape on mobile', async ({ page }) => { + // Start in portrait + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(300); + + // Verify portrait layout + const bodyWidthPortrait = await page.evaluate(() => document.body.scrollWidth); + const viewportWidthPortrait = await page.evaluate(() => window.innerWidth); + expect(bodyWidthPortrait).toBeLessThanOrEqual(viewportWidthPortrait + 1); + + // Switch to landscape + await page.setViewportSize({ width: 812, height: 375 }); + await page.waitForTimeout(300); + + // Verify landscape layout - no horizontal scrolling + const bodyWidthLandscape = await page.evaluate(() => document.body.scrollWidth); + const viewportWidthLandscape = await page.evaluate(() => window.innerWidth); + expect(bodyWidthLandscape).toBeLessThanOrEqual(viewportWidthLandscape + 1); + + // All content should still be visible + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + const featuresSection = page.locator('#features'); + await expect(featuresSection).toBeVisible(); + + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + }); + + test('should have readable font sizes at mobile breakpoint (375px)', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.waitForTimeout(200); + + // Hero title should be readable + const heroTitle = page.locator('h1#hero-title'); + await expect(heroTitle).toBeVisible(); + + const titleFontSize = await heroTitle.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(titleFontSize).toBeGreaterThanOrEqual(16); + + // Hero tagline should be readable + const heroTagline = page.locator('[data-testid="hero-tagline"]'); + const taglineFontSize = await heroTagline.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(taglineFontSize).toBeGreaterThanOrEqual(14); + + // Feature section heading should be readable + const featuresHeading = page.locator('#features-heading'); + const featuresHeadingSize = await featuresHeading.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(featuresHeadingSize).toBeGreaterThanOrEqual(16); + + // Feature card titles should be readable + const featureTitles = page.locator('#features article.feature-card h3'); + const firstTitleSize = await featureTitles.nth(0).evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(firstTitleSize).toBeGreaterThanOrEqual(14); + + // Feature card descriptions should be readable + const featureDescs = page.locator('#features article.feature-card p'); + const firstDescSize = await featureDescs.nth(0).evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(firstDescSize).toBeGreaterThanOrEqual(14); + + // CTA section heading should be readable + const ctaHeading = page.locator('#cta-title'); + const ctaHeadingSize = await ctaHeading.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(ctaHeadingSize).toBeGreaterThanOrEqual(16); + + // Footer links should be readable + const footerLinks = page.locator('footer a'); + const firstLinkSize = await footerLinks.nth(0).evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(firstLinkSize).toBeGreaterThanOrEqual(14); + }); + + test('should have readable font sizes at tablet breakpoint (768px)', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.waitForTimeout(200); + + const heroTitle = page.locator('h1#hero-title'); + const titleFontSize = await heroTitle.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(titleFontSize).toBeGreaterThanOrEqual(16); + + const heroTagline = page.locator('[data-testid="hero-tagline"]'); + const taglineFontSize = await heroTagline.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(taglineFontSize).toBeGreaterThanOrEqual(14); + + const featuresHeading = page.locator('#features-heading'); + const featuresHeadingSize = await featuresHeading.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(featuresHeadingSize).toBeGreaterThanOrEqual(16); + }); + + test('should have readable font sizes at desktop breakpoint (1280px)', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.waitForTimeout(200); + + const heroTitle = page.locator('h1#hero-title'); + const titleFontSize = await heroTitle.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(titleFontSize).toBeGreaterThanOrEqual(16); + + const heroTagline = page.locator('[data-testid="hero-tagline"]'); + const taglineFontSize = await heroTagline.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(taglineFontSize).toBeGreaterThanOrEqual(14); + + const featuresHeading = page.locator('#features-heading'); + const featuresHeadingSize = await featuresHeading.evaluate(el => { + const style = window.getComputedStyle(el); + return parseFloat(style.fontSize); + }); + expect(featuresHeadingSize).toBeGreaterThanOrEqual(16); + }); + + test('should maintain layout integrity across all breakpoints', async ({ page }) => { + const breakpoints = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 800 }, + ]; + + for (const bp of breakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.waitForTimeout(200); + + // No horizontal overflow + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth, `Horizontal overflow at ${bp.name} (${bp.width}px)`).toBeLessThanOrEqual(viewportWidth + 1); + + // All main sections should be visible + await expect(page.locator('[data-testid="hero-section"]'), `Hero not visible at ${bp.name}`).toBeVisible(); + await expect(page.locator('#features'), `Features not visible at ${bp.name}`).toBeVisible(); + await expect(page.locator('footer'), `Footer not visible at ${bp.name}`).toBeVisible(); + } + }); +}); diff --git a/tests/e2e/seo.spec.js b/tests/e2e/seo.spec.js new file mode 100644 index 000000000..20c769d51 --- /dev/null +++ b/tests/e2e/seo.spec.js @@ -0,0 +1,188 @@ +/** + * SEO and Meta Tags E2E Tests + * Owner: Scenario 10 - SEO and Meta Tags + * + * Tests: + * - Title tag presence and content + * - Meta description tag + * - Viewport meta tag + * - Open Graph (og:) meta tags + * - Twitter Card meta tags + * - Canonical URL link tag + * - Favicon and touch icon links + * - Structured data (JSON-LD) + */ + +const { test, expect } = require('@playwright/test'); + +test.describe('SEO and Meta Tags', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should have a descriptive title tag', async ({ page }) => { + const title = await page.title(); + expect(title).toBeTruthy(); + expect(title.length).toBeGreaterThan(0); + // Title should include product name + expect(title.toLowerCase()).toContain('mirdb'); + // Title should describe the service + expect( + title.toLowerCase().includes('url') || + title.toLowerCase().includes('shorten') || + title.toLowerCase().includes('link') + ).toBe(true); + }); + + test('should have a meta description tag with product summary', async ({ page }) => { + const metaDescription = page.locator('meta[name="description"]'); + await expect(metaDescription).toHaveCount(1); + + const content = await metaDescription.getAttribute('content'); + expect(content).toBeTruthy(); + expect(content.length).toBeGreaterThan(20); + // Should describe the product + expect( + content.toLowerCase().includes('url') || + content.toLowerCase().includes('shorten') || + content.toLowerCase().includes('link') + ).toBe(true); + }); + + test('should have a viewport meta tag for responsive behavior', async ({ page }) => { + const viewportMeta = page.locator('meta[name="viewport"]'); + await expect(viewportMeta).toHaveCount(1); + + const content = await viewportMeta.getAttribute('content'); + expect(content).toBeTruthy(); + // Should contain width=device-width + expect(content).toContain('width=device-width'); + // Should contain initial-scale + expect(content).toContain('initial-scale'); + }); + + test('should have all required Open Graph meta tags', async ({ page }) => { + // og:title + const ogTitle = page.locator('meta[property="og:title"]'); + await expect(ogTitle).toHaveCount(1); + const ogTitleContent = await ogTitle.getAttribute('content'); + expect(ogTitleContent).toBeTruthy(); + expect(ogTitleContent.toLowerCase()).toContain('mirdb'); + + // og:description + const ogDesc = page.locator('meta[property="og:description"]'); + await expect(ogDesc).toHaveCount(1); + const ogDescContent = await ogDesc.getAttribute('content'); + expect(ogDescContent).toBeTruthy(); + expect(ogDescContent.length).toBeGreaterThan(20); + + // og:type + const ogType = page.locator('meta[property="og:type"]'); + await expect(ogType).toHaveCount(1); + const ogTypeContent = await ogType.getAttribute('content'); + expect(ogTypeContent).toBe('website'); + + // og:url + const ogUrl = page.locator('meta[property="og:url"]'); + await expect(ogUrl).toHaveCount(1); + const ogUrlContent = await ogUrl.getAttribute('content'); + expect(ogUrlContent).toBeTruthy(); + expect(ogUrlContent.startsWith('http')).toBe(true); + + // og:image + const ogImage = page.locator('meta[property="og:image"]'); + await expect(ogImage).toHaveCount(1); + const ogImageContent = await ogImage.getAttribute('content'); + expect(ogImageContent).toBeTruthy(); + expect(ogImageContent.startsWith('http')).toBe(true); + }); + + test('should have Twitter Card meta tags', async ({ page }) => { + // twitter:card + const twitterCard = page.locator('meta[name="twitter:card"]'); + await expect(twitterCard).toHaveCount(1); + const twitterCardContent = await twitterCard.getAttribute('content'); + expect(twitterCardContent).toBeTruthy(); + + // twitter:title + const twitterTitle = page.locator('meta[name="twitter:title"]'); + await expect(twitterTitle).toHaveCount(1); + const twitterTitleContent = await twitterTitle.getAttribute('content'); + expect(twitterTitleContent).toBeTruthy(); + expect(twitterTitleContent.toLowerCase()).toContain('mirdb'); + + // twitter:description + const twitterDesc = page.locator('meta[name="twitter:description"]'); + await expect(twitterDesc).toHaveCount(1); + const twitterDescContent = await twitterDesc.getAttribute('content'); + expect(twitterDescContent).toBeTruthy(); + expect(twitterDescContent.length).toBeGreaterThan(20); + }); + + test('should have a canonical link tag pointing to the homepage', async ({ page }) => { + const canonicalLink = page.locator('link[rel="canonical"]'); + await expect(canonicalLink).toHaveCount(1); + + const href = await canonicalLink.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href.startsWith('http')).toBe(true); + // Should point to root URL + expect(href.endsWith('/')).toBe(true); + }); + + test('should have favicon link tags for various device types', async ({ page }) => { + // Standard favicon (SVG) + const faviconSvg = page.locator('link[rel="icon"][type="image/svg+xml"]'); + await expect(faviconSvg).toHaveCount(1); + const faviconSvgHref = await faviconSvg.getAttribute('href'); + expect(faviconSvgHref).toBeTruthy(); + + // PNG favicon (32x32) + const faviconPng = page.locator('link[rel="icon"][type="image/png"]'); + await expect(faviconPng).toHaveCount(1); + const faviconPngHref = await faviconPng.getAttribute('href'); + expect(faviconPngHref).toBeTruthy(); + const faviconPngSizes = await faviconPng.getAttribute('sizes'); + expect(faviconPngSizes).toBe('32x32'); + + // Apple touch icon + const appleTouchIcon = page.locator('link[rel="apple-touch-icon"]'); + await expect(appleTouchIcon).toHaveCount(1); + const appleTouchHref = await appleTouchIcon.getAttribute('href'); + expect(appleTouchHref).toBeTruthy(); + const appleTouchSizes = await appleTouchIcon.getAttribute('sizes'); + expect(appleTouchSizes).toBe('180x180'); + }); + + test('should have structured data (JSON-LD) with WebSite schema', async ({ page }) => { + const jsonLdScript = page.locator('script[type="application/ld+json"]'); + await expect(jsonLdScript).toHaveCount(1); + + const jsonLdContent = await jsonLdScript.textContent(); + expect(jsonLdContent).toBeTruthy(); + + let structuredData; + try { + structuredData = JSON.parse(jsonLdContent); + } catch (e) { + throw new Error('JSON-LD structured data is not valid JSON'); + } + + // Should have @context pointing to schema.org + expect(structuredData['@context']).toBe('https://schema.org'); + + // Should have @type of WebSite + expect(structuredData['@type']).toBe('WebSite'); + + // Should have name + expect(structuredData.name).toBeTruthy(); + expect(structuredData.name.toLowerCase()).toContain('mirdb'); + + // Should have description + expect(structuredData.description).toBeTruthy(); + + // Should have url + expect(structuredData.url).toBeTruthy(); + expect(structuredData.url.startsWith('http')).toBe(true); + }); +}); diff --git a/tests/e2e/theme.spec.js b/tests/e2e/theme.spec.js new file mode 100644 index 000000000..6a6c80db1 --- /dev/null +++ b/tests/e2e/theme.spec.js @@ -0,0 +1,405 @@ +/** + * Dark Mode Theme E2E Tests + * Owner: Scenario 8 - Dark Mode Theme + * + * Tests: + * - Default light mode on page load + * - Theme toggle switches between light and dark + * - Theme preference persists across page reloads + * - System prefers-color-scheme is respected on initial load + * - All page sections have proper dark mode styling + */ + +const { test, expect } = require('@playwright/test'); + +/** + * Extract a normalized brightness (0-255) from a computed color string. + * Handles rgb, rgba, oklch, hsl, and other CSS color formats. + */ +function getBrightness(colorStr) { + if (!colorStr) return null; + + // Try rgb/rgba format: rgb(255, 255, 255) or rgba(255, 255, 255, 0.5) + const rgbMatch = colorStr.match(/rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)/); + if (rgbMatch) { + const r = parseFloat(rgbMatch[1]); + const g = parseFloat(rgbMatch[2]); + const b = parseFloat(rgbMatch[3]); + return (r + g + b) / 3; + } + + // Try oklch format: oklch(0.278078 0.029596 256.848) + // First value is lightness (0-1) + const oklchMatch = colorStr.match(/oklch\(([\d.]+)/); + if (oklchMatch) { + const l = parseFloat(oklchMatch[1]); + return l * 255; + } + + // Try oklab format: oklab(0.419385 0.00478227 -0.0474926) + // First value is lightness (0-1) + const oklabMatch = colorStr.match(/oklab\(([\d.]+)/); + if (oklabMatch) { + const l = parseFloat(oklabMatch[1]); + return l * 255; + } + + // Try hsl format: hsl(210, 100%, 50%) + const hslMatch = colorStr.match(/hsl\([\d.]+,\s*[\d.]+%?,\s*([\d.]+)%?\)/); + if (hslMatch) { + const l = parseFloat(hslMatch[1]); + return (l / 100) * 255; + } + + return null; +} + +test.describe('Dark Mode Theme', () => { + async function clearStorageAndGoto(page, path = '/') { + await page.goto(path); + await page.waitForLoadState('networkidle'); + // Clear localStorage after navigation to avoid cross-origin restrictions + await page.evaluate(() => { + try { + localStorage.clear(); + } catch (e) { + // localStorage may not be available in some contexts + } + }); + // Reload to apply the cleared state + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(200); + } + + test.describe('Default Light Mode', () => { + test('should render in light mode by default when no preference is stored', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + // Check the html element has data-theme="light" + const html = page.locator('html'); + const theme = await html.getAttribute('data-theme'); + expect(theme).toBe('light'); + + // Verify light mode background colors + const body = page.locator('body'); + const bodyBg = await body.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(bodyBg); + expect(brightness).not.toBeNull(); + // Light backgrounds have high brightness + expect(brightness).toBeGreaterThan(200); + }); + + test('should have dark text on light background by default', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const heroTitle = page.locator('h1#hero-title'); + await expect(heroTitle).toBeVisible(); + + const textColor = await heroTitle.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + + const brightness = getBrightness(textColor); + expect(brightness).not.toBeNull(); + // Dark text has low brightness + expect(brightness).toBeLessThan(120); + }); + }); + + test.describe('Theme Toggle', () => { + test('should toggle to dark mode when theme button is clicked', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await expect(toggleBtn).toBeVisible(); + + // Click the toggle button + await toggleBtn.click(); + + // Wait a bit for transition + await page.waitForTimeout(350); + + // Verify dark mode is active + const html = page.locator('html'); + const theme = await html.getAttribute('data-theme'); + expect(theme).toBe('dark'); + }); + + test('should apply dark background colors in dark mode', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + // Check body background in dark mode + const body = page.locator('body'); + const bodyBg = await body.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(bodyBg); + expect(brightness).not.toBeNull(); + // Dark backgrounds have low brightness + expect(brightness).toBeLessThan(100); + }); + + test('should apply light text colors in dark mode', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const heroTitle = page.locator('h1#hero-title'); + const textColor = await heroTitle.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + + const brightness = getBrightness(textColor); + expect(brightness).not.toBeNull(); + // Light text has high brightness + expect(brightness).toBeGreaterThan(180); + }); + + test('should have smooth transition when toggling theme', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + // Check that theme transition styles are applied + const html = page.locator('html'); + const transition = await html.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.transition; + }); + + // Should have background-color transition + expect(transition).toContain('background-color'); + }); + + test('should toggle back to light mode when clicked again', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + + // Toggle to dark + await toggleBtn.click(); + await page.waitForTimeout(350); + + let theme = await page.locator('html').getAttribute('data-theme'); + expect(theme).toBe('dark'); + + // Toggle back to light + await toggleBtn.click(); + await page.waitForTimeout(350); + + theme = await page.locator('html').getAttribute('data-theme'); + expect(theme).toBe('light'); + }); + }); + + test.describe('Theme Persistence', () => { + test('should persist dark mode preference across page reloads', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + // Toggle to dark mode + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + // Verify dark mode is stored in localStorage + const storedTheme = await page.evaluate(() => localStorage.getItem('theme')); + expect(storedTheme).toBe('dark'); + + // Reload the page + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(200); + + // Verify dark mode is still active after reload + const theme = await page.locator('html').getAttribute('data-theme'); + expect(theme).toBe('dark'); + }); + + test('should persist light mode preference across page reloads', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + // First toggle to dark, then back to light to set explicit preference + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + await toggleBtn.click(); + await page.waitForTimeout(350); + + // Verify light mode is stored + const storedTheme = await page.evaluate(() => localStorage.getItem('theme')); + expect(storedTheme).toBe('light'); + + // Reload + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(200); + + const theme = await page.locator('html').getAttribute('data-theme'); + expect(theme).toBe('light'); + }); + }); + + test.describe('System Preference Detection', () => { + test.use({ colorScheme: 'dark' }); + + test('should respect system dark mode preference on initial load', async ({ page }) => { + // With colorScheme: 'dark', the browser should report prefers-color-scheme: dark + await clearStorageAndGoto(page, '/'); + await page.waitForTimeout(200); + + // Check that the page detected the dark preference + // Note: localStorage is cleared in beforeEach, so the system preference should apply + const theme = await page.locator('html').getAttribute('data-theme'); + + // The page should respect the system preference when no localStorage is set + expect(theme).toBe('dark'); + }); + }); + + test.describe('System Light Preference', () => { + test.use({ colorScheme: 'light' }); + + test('should respect system light mode preference on initial load', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + await page.waitForTimeout(200); + + const theme = await page.locator('html').getAttribute('data-theme'); + expect(theme).toBe('light'); + }); + }); + + test.describe('All Sections Dark Mode Styling', () => { + test('hero section has proper dark mode styling', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const heroSection = page.locator('[data-testid="hero-section"]'); + await expect(heroSection).toBeVisible(); + + const heroBg = await heroSection.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(heroBg); + expect(brightness).not.toBeNull(); + // Dark hero background + expect(brightness).toBeLessThan(150); + }); + + test('features section has proper dark mode styling', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const featuresSection = page.locator('#features'); + await expect(featuresSection).toBeVisible(); + + const featuresBg = await featuresSection.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(featuresBg); + expect(brightness).not.toBeNull(); + // Dark features background + expect(brightness).toBeLessThan(100); + + // Feature cards should have dark styling + const featureCards = page.locator('.feature-card'); + const firstCard = featureCards.first(); + await expect(firstCard).toBeVisible(); + + const cardBg = await firstCard.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const cardBrightness = getBrightness(cardBg); + expect(cardBrightness).not.toBeNull(); + expect(cardBrightness).toBeLessThan(150); + }); + + test('CTA section has proper dark mode styling', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const ctaSection = page.locator('[data-testid="cta-section"]'); + await expect(ctaSection).toBeVisible(); + + const ctaHeading = ctaSection.locator('h2#cta-title'); + const headingColor = await ctaHeading.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.color; + }); + + // Heading text should be visible in dark mode + const brightness = getBrightness(headingColor); + expect(brightness).not.toBeNull(); + expect(brightness).toBeGreaterThan(100); + }); + + test('footer has proper dark mode styling', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const footer = page.locator('[data-testid="footer"]'); + await expect(footer).toBeVisible(); + + const footerBg = await footer.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(footerBg); + expect(brightness).not.toBeNull(); + // Dark footer background + expect(brightness).toBeLessThan(150); + }); + + test('navigation bar has proper dark mode styling', async ({ page }) => { + await clearStorageAndGoto(page, '/'); + + const toggleBtn = page.locator('[data-testid="theme-toggle"]:visible'); + await toggleBtn.click(); + await page.waitForTimeout(350); + + const navbar = page.locator('nav.navbar'); + await expect(navbar).toBeVisible(); + + const navBg = await navbar.evaluate(el => { + const computed = window.getComputedStyle(el); + return computed.backgroundColor; + }); + + const brightness = getBrightness(navBg); + expect(brightness).not.toBeNull(); + // Dark navbar background + expect(brightness).toBeLessThan(150); + }); + }); +}); diff --git a/tests/performance-unit.spec.js b/tests/performance-unit.spec.js new file mode 100644 index 000000000..73b7f5f67 --- /dev/null +++ b/tests/performance-unit.spec.js @@ -0,0 +1,106 @@ +/** + * Performance Unit Tests + * Owner: Scenario 11 - Performance and Loading + * + * Tests: + * - CSS and JS bundle size check (gzipped < 200KB) + * - Image optimization check (modern formats, appropriate sizes) + */ + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); + +/** + * Get the gzipped size of a file in bytes. + */ +function getGzippedSize(filePath) { + const content = fs.readFileSync(filePath); + return zlib.gzipSync(content).length; +} + +/** + * Get all image files in a directory recursively. + */ +function getImageFiles(dir, files = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + getImageFiles(fullPath, files); + } else if (/\.(svg|png|jpg|jpeg|webp|gif|avif)$/i.test(entry.name)) { + files.push(fullPath); + } + } + return files; +} + +describe('Bundle Size Checks', () => { + test('total CSS and JS bundle size should be less than 200KB gzipped', () => { + const cssPath = path.join(PROJECT_ROOT, 'web', 'static', 'css', 'main.css'); + const jsPath = path.join(PROJECT_ROOT, 'web', 'src', 'js', 'main.js'); + + expect(fs.existsSync(cssPath)).toBe(true); + expect(fs.existsSync(jsPath)).toBe(true); + + const cssGzippedSize = getGzippedSize(cssPath); + const jsGzippedSize = getGzippedSize(jsPath); + const totalSize = cssGzippedSize + jsGzippedSize; + + // Total CSS+JS should be < 200KB (204800 bytes) + expect(totalSize).toBeLessThan(204800); + + // Individual checks for more granular reporting + expect(cssGzippedSize).toBeLessThan(150000); // CSS < 150KB + expect(jsGzippedSize).toBeLessThan(50000); // JS < 50KB + }); +}); + +describe('Image Optimization Checks', () => { + test('images should use modern or optimized formats (SVG for icons)', () => { + const imagesDir = path.join(PROJECT_ROOT, 'web', 'assets', 'images'); + expect(fs.existsSync(imagesDir)).toBe(true); + + const imageFiles = getImageFiles(imagesDir); + expect(imageFiles.length).toBeGreaterThan(0); + + for (const imagePath of imageFiles) { + const ext = path.extname(imagePath).toLowerCase(); + // Icons should be SVG (vector, scalable, tiny file size) + // Photos should be WebP or AVIF + const isVector = ext === '.svg'; + const isModernRaster = ext === '.webp' || ext === '.avif'; + const isAcceptable = isVector || isModernRaster || ext === '.png' || ext === '.jpg' || ext === '.jpeg'; + + expect(isAcceptable).toBe(true); + } + }); + + test('icon images should be SVG format for optimal size and scalability', () => { + const imagesDir = path.join(PROJECT_ROOT, 'web', 'assets', 'images'); + const imageFiles = getImageFiles(imagesDir); + + // Feature icons should be SVG + const iconImages = imageFiles.filter(p => + /link|chart|dashboard|shield/i.test(path.basename(p)) + ); + + for (const iconPath of iconImages) { + const ext = path.extname(iconPath).toLowerCase(); + expect(ext).toBe('.svg'); + } + }); + + test('individual image files should be appropriately sized (< 50KB each)', () => { + const imagesDir = path.join(PROJECT_ROOT, 'web', 'assets', 'images'); + const imageFiles = getImageFiles(imagesDir); + + for (const imagePath of imageFiles) { + const stats = fs.statSync(imagePath); + // Each image should be less than 50KB + expect(stats.size).toBeLessThan(51200); + } + }); +}); diff --git a/web/assets/images/bar-chart.svg b/web/assets/images/bar-chart.svg new file mode 100644 index 000000000..c1b42a45b --- /dev/null +++ b/web/assets/images/bar-chart.svg @@ -0,0 +1,5 @@ + diff --git a/web/assets/images/layout-dashboard.svg b/web/assets/images/layout-dashboard.svg new file mode 100644 index 000000000..d947111ca --- /dev/null +++ b/web/assets/images/layout-dashboard.svg @@ -0,0 +1,6 @@ + diff --git a/web/assets/images/link.svg b/web/assets/images/link.svg new file mode 100644 index 000000000..36166229f --- /dev/null +++ b/web/assets/images/link.svg @@ -0,0 +1,4 @@ + diff --git a/web/assets/images/shield-check.svg b/web/assets/images/shield-check.svg new file mode 100644 index 000000000..3f9f964ec --- /dev/null +++ b/web/assets/images/shield-check.svg @@ -0,0 +1,4 @@ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..c25e5e374 --- /dev/null +++ b/web/index.html @@ -0,0 +1,259 @@ + + + + + + MirDB - URL Shortening Service + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+

Shorten Your URLs with MirDB

+

Create short, memorable links and track their performance with powerful analytics.

+ Get Started + Create Short URL +
+
+
+ +
+
+
+

+ Powerful Features for Every Need +

+

+ Everything you need to manage, track, and optimize your links in one place. +

+
+ +
+ +
+
+ Link icon representing URL shortening +
+

URL Shortening

+

+ Create concise, shareable short links instantly. Customize your URLs with memorable aliases and branded domains. +

+
+ + +
+
+ Bar chart icon representing analytics +
+

Analytics

+

+ Track clicks, geographic locations, referrers, and device types. Get real-time insights into how your links perform. +

+
+ + +
+
+ Dashboard layout icon +
+

Dashboard

+

+ Manage all your shortened URLs from a clean, intuitive dashboard. Organize, edit, and delete links with ease. +

+
+ + +
+
+ Shield check icon representing secure links +
+

Secure Links

+

+ Protect your links with password authentication and expiration dates. HTTPS encryption ensures safe redirections. +

+
+
+
+
+ + +
+
+
+

+ Trusted by Thousands +

+

+ Join a growing community of users who rely on MirDB for their link management needs. +

+
+ +
+
+
+ -- +
+
URLs Created
+
+ +
+
+ -- +
+
Active Users
+
+ +
+
+ -- +
+
Total Clicks
+
+
+ + + + +
+
+ +
+
+

Ready to shorten your links?

+

Join thousands of users who trust MirDB for their URL shortening needs.

+ +
+
+
+ + + + + + diff --git a/web/src/css/main.css b/web/src/css/main.css new file mode 100644 index 000000000..3ec6e1cd7 --- /dev/null +++ b/web/src/css/main.css @@ -0,0 +1,162 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Theme transitions - Owner: Scenario 8 - Dark Mode Theme */ +html { + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Smooth transitions for theme-aware elements */ +body, .navbar, .hero, .card, .footer, section { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} + +/* Feature card hover effects in dark mode */ +[data-theme="dark"] .feature-card:hover { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +/* Feature card hover effects */ +.feature-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.feature-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +/* Feature icon styling */ +.feature-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 0.75rem; +} + +/* ===== Responsive Design - Scenario 7 ===== */ + +/* Mobile: ensure no horizontal overflow */ +@media (max-width: 767px) { + body { + overflow-x: hidden; + } + + img { + max-width: 100%; + height: auto; + } + + /* Reduce hero padding on mobile */ + .hero { + @apply py-12; + } + + /* Ensure touch targets */ + .btn, + a.btn, + button { + min-height: 44px; + min-width: 44px; + } + + /* Footer links stack vertically */ + .footer .grid-flow-col { + grid-auto-flow: row; + grid-template-columns: 1fr; + text-align: center; + } +} + +/* Landscape orientation on mobile */ +@media (max-width: 767px) and (orientation: landscape) { + .hero { + @apply py-8; + } +} + +/* ===== Performance Optimizations - Scenario 11 ===== */ + +/* Prevent layout shift by reserving space for images */ +img, svg { + max-width: 100%; + height: auto; +} + +/* Content visibility for below-fold sections to improve rendering performance */ +#features, +[data-testid="cta-section"], +[data-testid="footer"] { + content-visibility: auto; + contain-intrinsic-size: auto 300px; +} + +/* Layout containment for feature cards to isolate layout shifts */ +.feature-card { + contain: layout style; +} + +/* Optimize paint performance for the navbar */ +.navbar { + contain: layout style paint; +} + +/* Reduce motion for users who prefer it (performance + accessibility) */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ===== Analytics Counter - Scenario 12 ===== */ + +/* Stat card styling */ +.stat-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +/* Stat value number styling */ +.stat-value { + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +/* Dark mode stat card hover */ +[data-theme="dark"] .stat-card:hover { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +/* Analytics section responsive */ +@media (max-width: 767px) { + #analytics .grid { + gap: 1rem; + } + + .stat-card { + padding: 1.5rem; + } + + .stat-value { + font-size: 2.25rem; + } +} + +/* Preload critical font fallback to avoid FOUT */ +@font-face { + font-family: 'SystemUI'; + src: local('system-ui'); + font-display: swap; +} diff --git a/web/src/js/main.js b/web/src/js/main.js new file mode 100644 index 000000000..a4ed0aaac --- /dev/null +++ b/web/src/js/main.js @@ -0,0 +1,344 @@ +/** + * Main JavaScript entry point. + * Owner: Scenario 1 - HTTP Server and Routing (base setup) + * Scenario 4 - Navigation Links (mobile menu toggle) + * Scenario 5 - CTA Buttons and Actions (click tracking) + * Scenario 8 - Dark Mode Theme (theme toggle, localStorage) + * + * Expected functionality: + * - DOMContentLoaded initialization + * - Theme preference detection and toggle + * - Mobile navigation menu toggle + * - Smooth scroll for anchor links + * - CTA button click tracking and redirect handling + */ + +window.App = window.App || {}; + +window.App.Nav = { + init: function() { + this.initMobileMenu(); + this.initAuthState(); + }, + + initMobileMenu: function() { + var toggle = document.querySelector('[data-testid="mobile-menu-toggle"]'); + var menu = document.getElementById('mobile-menu'); + if (!toggle || !menu) return; + + toggle.addEventListener('click', function() { + var isExpanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', String(!isExpanded)); + menu.setAttribute('aria-hidden', String(isExpanded)); + if (isExpanded) { + menu.classList.add('hidden'); + } else { + menu.classList.remove('hidden'); + } + }); + }, + + initAuthState: function() { + var authState = localStorage.getItem('auth_state') || 'unauthenticated'; + this.setAuthState(authState); + }, + + setAuthState: function(state) { + var isAuth = state === 'authenticated'; + var unauthGroups = document.querySelectorAll('.nav-unauth-links'); + var authGroups = document.querySelectorAll('.nav-auth-links'); + + for (var i = 0; i < unauthGroups.length; i++) { + if (isAuth) { + unauthGroups[i].classList.add('hidden'); + } else { + unauthGroups[i].classList.remove('hidden'); + } + } + + for (var i = 0; i < authGroups.length; i++) { + if (isAuth) { + authGroups[i].classList.remove('hidden'); + } else { + authGroups[i].classList.add('hidden'); + } + } + } +}; + +document.addEventListener('DOMContentLoaded', function() { + // Initialize theme + initTheme(); + + // Initialize navigation + window.App.Nav.init(); + + // Initialize CTA button handlers + initCTAButtons(); + + // Initialize analytics counter + window.App.Stats.init(); +}); + +/** + * Initialize theme preference detection, persistence, and toggle. + * Owner: Scenario 8 - Dark Mode Theme + * + * Checks localStorage for a saved theme, falls back to system + * prefers-color-scheme, and sets up the toggle button. + */ +function initTheme() { + const root = document.documentElement; + const savedTheme = localStorage.getItem('theme'); + + let theme; + if (savedTheme) { + theme = savedTheme; + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + theme = 'dark'; + } else { + theme = 'light'; + } + + root.setAttribute('data-theme', theme); + + // Listen for system preference changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', function(event) { + // Only apply system preference if user hasn't manually set a preference + if (!localStorage.getItem('theme')) { + const newTheme = event.matches ? 'dark' : 'light'; + root.setAttribute('data-theme', newTheme); + } + }); + + // Set up theme toggle button + initThemeToggle(); +} + +/** + * Initialize the theme toggle button. + * Owner: Scenario 8 - Dark Mode Theme + */ +function initThemeToggle() { + const toggleBtns = document.querySelectorAll('[data-testid="theme-toggle"]'); + if (!toggleBtns.length) return; + + toggleBtns.forEach(function(toggleBtn) { + toggleBtn.addEventListener('click', function() { + const root = document.documentElement; + const currentTheme = root.getAttribute('data-theme') || 'light'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + root.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update all toggle button icons/labels + toggleBtns.forEach(function(btn) { + updateToggleLabel(btn, newTheme); + }); + }); + + // Set initial toggle label + const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; + updateToggleLabel(toggleBtn, currentTheme); + }); +} + +/** + * Update the toggle button's accessible label and icon based on theme. + * Owner: Scenario 8 - Dark Mode Theme + */ +function updateToggleLabel(button, theme) { + const isDark = theme === 'dark'; + button.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + button.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + + // Update icon if present + const icon = button.querySelector('.theme-icon'); + if (icon) { + icon.textContent = isDark ? '☀' : '☽'; // Sun : Moon + } +} + +/** + * Analytics Counter Display + * Owner: Scenario 12 - Analytics Counter Display + * + * Features: + * - Fetches stats from /api/stats endpoint + * - Formats large numbers with K/M/B suffixes + * - Shows loading and error states + * - Updates DOM with formatted values + */ + +window.App.Stats = { + DEFAULT_STATS: { + urls_created: 128456, + active_users: 3421, + total_clicks: 8923456 + }, + + init: function() { + this.fetchStats(); + }, + + /** + * Format a number with K/M/B suffixes for large values. + * Examples: 128456 -> "128K+", 8923456 -> "8.9M+", 1500000000 -> "1.5B+" + */ + formatNumber: function(num) { + if (typeof num !== 'number' || isNaN(num)) { + return '--'; + } + + var absNum = Math.abs(num); + + if (absNum >= 1000000000) { + var billions = (absNum / 1000000000).toFixed(1); + // Remove trailing .0 + billions = billions.replace(/\.0$/, ''); + return billions + 'B+'; + } + + if (absNum >= 1000000) { + var millions = (absNum / 1000000).toFixed(1); + millions = millions.replace(/\.0$/, ''); + return millions + 'M+'; + } + + if (absNum >= 1000) { + var thousands = (absNum / 1000).toFixed(1); + thousands = thousands.replace(/\.0$/, ''); + return thousands + 'K+'; + } + + return String(num); + }, + + fetchStats: function() { + var self = this; + var apiUrl = '/api/stats'; + + // Check if analytics section exists on the page + var analyticsSection = document.getElementById('analytics'); + if (!analyticsSection) return; + + // Show loading state + self.showLoading(true); + self.showError(false); + + fetch(apiUrl) + .then(function(response) { + if (!response.ok) { + throw new Error('Failed to fetch stats: ' + response.status); + } + return response.json(); + }) + .then(function(data) { + self.updateCounters(data); + self.showLoading(false); + }) + .catch(function(error) { + console.warn('Analytics stats fetch failed:', error.message); + // Use fallback/default values on error + self.updateCounters(self.DEFAULT_STATS); + self.showLoading(false); + }); + }, + + updateCounters: function(data) { + var statElements = document.querySelectorAll('.stat-number'); + + statElements.forEach(function(el) { + var key = el.getAttribute('data-stat-key'); + if (key && data.hasOwnProperty(key)) { + var value = data[key]; + var formatted = this.formatNumber(value); + el.textContent = formatted; + } + }.bind(this)); + }, + + showLoading: function(show) { + var loadingEl = document.getElementById('analytics-loading'); + if (loadingEl) { + if (show) { + loadingEl.classList.remove('hidden'); + } else { + loadingEl.classList.add('hidden'); + } + } + }, + + showError: function(show) { + var errorEl = document.getElementById('analytics-error'); + if (errorEl) { + if (show) { + errorEl.classList.remove('hidden'); + } else { + errorEl.classList.add('hidden'); + } + } + } +}; + +/** + * Initialize CTA button click tracking and interactivity. + * Owner: Scenario 5 - CTA Buttons and Actions + */ +function initCTAButtons() { + // Track CTA clicks for analytics + const ctaSelectors = [ + '[data-testid="hero-primary-cta"]', + '[data-testid="hero-secondary-cta"]', + '[data-testid="cta-create-short-url"]', + '[data-testid="cta-get-started"]' + ]; + + ctaSelectors.forEach(selector => { + const buttons = document.querySelectorAll(selector); + buttons.forEach(button => { + // Ensure buttons are keyboard accessible + if (button.tagName.toLowerCase() !== 'a') { + button.setAttribute('role', 'button'); + button.setAttribute('tabindex', '0'); + } + + // Add click tracking + button.addEventListener('click', function(event) { + const ctaLabel = button.textContent.trim(); + const ctaHref = button.getAttribute('href') || '#'; + + // Store CTA click info in sessionStorage for potential post-redirect use + sessionStorage.setItem('lastCtaClicked', JSON.stringify({ + label: ctaLabel, + href: ctaHref, + timestamp: Date.now() + })); + + // For external or non-fragment links, allow default navigation + if (ctaHref && !ctaHref.startsWith('#')) { + return; // Let the browser handle the redirect + } + + // For fragment links, smooth scroll + if (ctaHref && ctaHref.startsWith('#')) { + event.preventDefault(); + const target = document.querySelector(ctaHref); + if (target) { + target.scrollIntoView({ behavior: 'smooth' }); + } + } + }); + + // Add keyboard support for non-anchor elements + button.addEventListener('keydown', function(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + button.click(); + } + }); + }); + }); +} diff --git a/web/static/css/main.css b/web/static/css/main.css new file mode 100644 index 000000000..34abab1fe --- /dev/null +++ b/web/static/css/main.css @@ -0,0 +1,2855 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +:root, +[data-theme] { + background-color: var(--fallback-b1,oklch(var(--b1)/1)); + color: var(--fallback-bc,oklch(var(--bc)/1)); +} + +@supports not (color: oklch(0% 0 0)) { + :root { + color-scheme: light; + --fallback-p: #491eff; + --fallback-pc: #d4dbff; + --fallback-s: #ff41c7; + --fallback-sc: #fff9fc; + --fallback-a: #00cfbd; + --fallback-ac: #00100d; + --fallback-n: #2b3440; + --fallback-nc: #d7dde4; + --fallback-b1: #ffffff; + --fallback-b2: #e5e6e6; + --fallback-b3: #e5e6e6; + --fallback-bc: #1f2937; + --fallback-in: #00b3f0; + --fallback-inc: #000000; + --fallback-su: #00ca92; + --fallback-suc: #000000; + --fallback-wa: #ffc22d; + --fallback-wac: #000000; + --fallback-er: #ff6f70; + --fallback-erc: #000000; + } + + @media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --fallback-p: #7582ff; + --fallback-pc: #050617; + --fallback-s: #ff71cf; + --fallback-sc: #190211; + --fallback-a: #00c7b5; + --fallback-ac: #000e0c; + --fallback-n: #2a323c; + --fallback-nc: #a6adbb; + --fallback-b1: #1d232a; + --fallback-b2: #191e24; + --fallback-b3: #15191e; + --fallback-bc: #a6adbb; + --fallback-in: #00b3f0; + --fallback-inc: #000000; + --fallback-su: #00ca92; + --fallback-suc: #000000; + --fallback-wa: #ffc22d; + --fallback-wac: #000000; + --fallback-er: #ff6f70; + --fallback-erc: #000000; + } + } +} + +html { + -webkit-tap-highlight-color: transparent; +} + +* { + scrollbar-color: color-mix(in oklch, currentColor 35%, transparent) transparent; +} + +*:hover { + scrollbar-color: color-mix(in oklch, currentColor 60%, transparent) transparent; +} + +:root { + color-scheme: light; + --in: 72.06% 0.191 231.6; + --su: 64.8% 0.150 160; + --wa: 84.71% 0.199 83.87; + --er: 71.76% 0.221 22.18; + --pc: 89.824% 0.06192 275.75; + --ac: 15.352% 0.0368 183.61; + --inc: 0% 0 0; + --suc: 0% 0 0; + --wac: 0% 0 0; + --erc: 0% 0 0; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 49.12% 0.3096 275.75; + --s: 69.71% 0.329 342.55; + --sc: 98.71% 0.0106 342.55; + --a: 76.76% 0.184 183.61; + --n: 32.1785% 0.02476 255.701624; + --nc: 89.4994% 0.011585 252.096176; + --b1: 100% 0 0; + --b2: 96.1151% 0 0; + --b3: 92.4169% 0.00108 197.137559; + --bc: 27.8078% 0.029596 256.847952; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --in: 72.06% 0.191 231.6; + --su: 64.8% 0.150 160; + --wa: 84.71% 0.199 83.87; + --er: 71.76% 0.221 22.18; + --pc: 13.138% 0.0392 275.75; + --sc: 14.96% 0.052 342.55; + --ac: 14.902% 0.0334 183.61; + --inc: 0% 0 0; + --suc: 0% 0 0; + --wac: 0% 0 0; + --erc: 0% 0 0; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 65.69% 0.196 275.75; + --s: 74.8% 0.26 342.55; + --a: 74.51% 0.167 183.61; + --n: 31.3815% 0.021108 254.139175; + --nc: 74.6477% 0.0216 264.435964; + --b1: 25.3267% 0.015896 252.417568; + --b2: 23.2607% 0.013807 253.100675; + --b3: 21.1484% 0.01165 254.087939; + --bc: 74.6477% 0.0216 264.435964; + } +} + +[data-theme=light] { + color-scheme: light; + --in: 72.06% 0.191 231.6; + --su: 64.8% 0.150 160; + --wa: 84.71% 0.199 83.87; + --er: 71.76% 0.221 22.18; + --pc: 89.824% 0.06192 275.75; + --ac: 15.352% 0.0368 183.61; + --inc: 0% 0 0; + --suc: 0% 0 0; + --wac: 0% 0 0; + --erc: 0% 0 0; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 49.12% 0.3096 275.75; + --s: 69.71% 0.329 342.55; + --sc: 98.71% 0.0106 342.55; + --a: 76.76% 0.184 183.61; + --n: 32.1785% 0.02476 255.701624; + --nc: 89.4994% 0.011585 252.096176; + --b1: 100% 0 0; + --b2: 96.1151% 0 0; + --b3: 92.4169% 0.00108 197.137559; + --bc: 27.8078% 0.029596 256.847952; +} + +[data-theme=dark] { + color-scheme: dark; + --in: 72.06% 0.191 231.6; + --su: 64.8% 0.150 160; + --wa: 84.71% 0.199 83.87; + --er: 71.76% 0.221 22.18; + --pc: 13.138% 0.0392 275.75; + --sc: 14.96% 0.052 342.55; + --ac: 14.902% 0.0334 183.61; + --inc: 0% 0 0; + --suc: 0% 0 0; + --wac: 0% 0 0; + --erc: 0% 0 0; + --rounded-box: 1rem; + --rounded-btn: 0.5rem; + --rounded-badge: 1.9rem; + --animation-btn: 0.25s; + --animation-input: .2s; + --btn-focus-scale: 0.95; + --border-btn: 1px; + --tab-border: 1px; + --tab-radius: 0.5rem; + --p: 65.69% 0.196 275.75; + --s: 74.8% 0.26 342.55; + --a: 74.51% 0.167 183.61; + --n: 31.3815% 0.021108 254.139175; + --nc: 74.6477% 0.0216 264.435964; + --b1: 25.3267% 0.015896 252.417568; + --b2: 23.2607% 0.013807 253.100675; + --b3: 21.1484% 0.01165 254.087939; + --bc: 74.6477% 0.0216 264.435964; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +@media (hover:hover) { + .link-hover:hover { + text-decoration-line: underline; + } + + .label a:hover { + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + } + + .\!menu li > *:not(ul, .menu-title, details, .btn):active, +.\!menu li > *:not(ul, .menu-title, details, .btn).active, +.\!menu li > details > summary:active { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))) !important; + } + + .menu li > *:not(ul, .menu-title, details, .btn):active, +.menu li > *:not(ul, .menu-title, details, .btn).active, +.menu li > details > summary:active { + --tw-bg-opacity: 1; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); + } + + .\!menu li > *:not(ul, .menu-title, details, .btn):active, +.\!menu li > *:not(ul, .menu-title, details, .btn).active, +.\!menu li > details > summary:active { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))) !important; + } +} + +.btn { + display: inline-flex; + height: 3rem; + min-height: 3rem; + flex-shrink: 0; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-wrap: wrap; + align-items: center; + justify-content: center; + border-radius: var(--rounded-btn, 0.5rem); + border-color: transparent; + border-color: oklch(var(--btn-color, var(--b2)) / var(--tw-border-opacity)); + padding-left: 1rem; + padding-right: 1rem; + text-align: center; + font-size: 0.875rem; + line-height: 1em; + gap: 0.5rem; + font-weight: 600; + text-decoration-line: none; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + border-width: var(--border-btn, 1px); + transition-property: color, background-color, border-color, opacity, box-shadow, transform; + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + outline-color: var(--fallback-bc,oklch(var(--bc)/1)); + background-color: oklch(var(--btn-color, var(--b2)) / var(--tw-bg-opacity)); + --tw-bg-opacity: 1; + --tw-border-opacity: 1; +} + +.btn-disabled, + .btn[disabled], + .btn:disabled { + pointer-events: none; +} + +.btn-square { + height: 3rem; + width: 3rem; + padding: 0px; +} + +.btn-circle { + height: 3rem; + width: 3rem; + border-radius: 9999px; + padding: 0px; +} + +:where(.btn:is(input[type="checkbox"])), +:where(.btn:is(input[type="radio"])) { + width: auto; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.btn:is(input[type="checkbox"]):after, +.btn:is(input[type="radio"]):after { + --tw-content: attr(aria-label); + content: var(--tw-content); +} + +.card { + position: relative; + display: flex; + flex-direction: column; + border-radius: var(--rounded-box, 1rem); +} + +.card:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.card figure { + display: flex; + align-items: center; + justify-content: center; +} + +.card.image-full { + display: grid; +} + +.card.image-full:before { + position: relative; + content: ""; + z-index: 10; + border-radius: var(--rounded-box, 1rem); + --tw-bg-opacity: 1; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + opacity: 0.75; +} + +.card.image-full:before, + .card.image-full > * { + grid-column-start: 1; + grid-row-start: 1; +} + +.card.image-full > figure img { + height: 100%; + -o-object-fit: cover; + object-fit: cover; +} + +.card.image-full > .card-body { + position: relative; + z-index: 20; + --tw-text-opacity: 1; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); +} + +@media (hover: hover) { + .btn:hover { + --tw-border-opacity: 1; + border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn:hover { + background-color: color-mix( + in oklab, + oklch(var(--btn-color, var(--b2)) / var(--tw-bg-opacity, 1)) 90%, + black + ); + border-color: color-mix( + in oklab, + oklch(var(--btn-color, var(--b2)) / var(--tw-border-opacity, 1)) 90%, + black + ); + } + } + + @supports not (color: oklch(0% 0 0)) { + .btn:hover { + background-color: var(--btn-color, var(--fallback-b2)); + border-color: var(--btn-color, var(--fallback-b2)); + } + } + + .btn.glass:hover { + --glass-opacity: 25%; + --glass-border-opacity: 15%; + } + + .btn-ghost:hover { + border-color: transparent; + } + + @supports (color: oklch(0% 0 0)) { + .btn-ghost:hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); + } + } + + .btn-outline:hover { + --tw-border-opacity: 1; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity))); + } + + .btn-outline.btn-primary:hover { + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-primary:hover { + background-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + } + } + + .btn-outline.btn-secondary:hover { + --tw-text-opacity: 1; + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-secondary:hover { + background-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + } + } + + .btn-outline.btn-accent:hover { + --tw-text-opacity: 1; + color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-accent:hover { + background-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); + } + } + + .btn-outline.btn-success:hover { + --tw-text-opacity: 1; + color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-success:hover { + background-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + } + } + + .btn-outline.btn-info:hover { + --tw-text-opacity: 1; + color: var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-info:hover { + background-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + } + } + + .btn-outline.btn-warning:hover { + --tw-text-opacity: 1; + color: var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-warning:hover { + background-color: color-mix(in oklab, var(--fallback-wa,oklch(var(--wa)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-wa,oklch(var(--wa)/1)) 90%, black); + } + } + + .btn-outline.btn-error:hover { + --tw-text-opacity: 1; + color: var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity))); + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-error:hover { + background-color: color-mix(in oklab, var(--fallback-er,oklch(var(--er)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-er,oklch(var(--er)/1)) 90%, black); + } + } + + .btn-disabled:hover, + .btn[disabled]:hover, + .btn:disabled:hover { + --tw-border-opacity: 0; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-bg-opacity: 0.2; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-text-opacity: 0.2; + } + + @supports (color: color-mix(in oklab, black, black)) { + .btn:is(input[type="checkbox"]:checked):hover, .btn:is(input[type="radio"]:checked):hover { + background-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + } + } + + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + cursor: pointer !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; + } + + @supports (color: oklch(0% 0 0)) { + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + } + } + + :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + cursor: pointer; + outline: 2px solid transparent; + outline-offset: 2px; + } + + @supports (color: oklch(0% 0 0)) { + :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); + } + } + + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + cursor: pointer !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; + } + + @supports (color: oklch(0% 0 0)) { + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + } + } + + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + cursor: pointer !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; + } + + @supports (color: oklch(0% 0 0)) { + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + } + } + + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + cursor: pointer !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; + } + + @supports (color: oklch(0% 0 0)) { + :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(.active, .btn):hover, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(.active, .btn):hover { + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + } + } +} + +.footer { + display: grid; + width: 100%; + grid-auto-flow: row; + place-items: start; + -moz-column-gap: 1rem; + column-gap: 1rem; + row-gap: 2.5rem; + font-size: 0.875rem; + line-height: 1.25rem; +} + +.footer > * { + display: grid; + place-items: start; + gap: 0.5rem; +} + +.footer-center { + place-items: center; + text-align: center; +} + +.footer-center > * { + place-items: center; +} + +@media (min-width: 48rem) { + .footer { + grid-auto-flow: column; + } + + .footer-center { + grid-auto-flow: row dense; + } +} + +.label { + display: flex; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + align-items: center; + justify-content: space-between; + padding-left: 0.25rem; + padding-right: 0.25rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.hero { + display: grid; + width: 100%; + place-items: center; + background-size: cover; + background-position: center; +} + +.hero > * { + grid-column-start: 1; + grid-row-start: 1; +} + +.hero-content { + z-index: 0; + display: flex; + align-items: center; + justify-content: center; + max-width: 80rem; + gap: 1rem; + padding: 1rem; +} + +.link { + cursor: pointer; + text-decoration-line: underline; +} + +.link-hover { + text-decoration-line: none; +} + +.\!menu { + display: flex !important; + flex-direction: column !important; + flex-wrap: wrap !important; + font-size: 0.875rem !important; + line-height: 1.25rem !important; + padding: 0.5rem !important; +} + +.menu { + display: flex; + flex-direction: column; + flex-wrap: wrap; + font-size: 0.875rem; + line-height: 1.25rem; + padding: 0.5rem; +} + +.\!menu :where(li ul) { + position: relative !important; + white-space: nowrap !important; + margin-inline-start: 1rem !important; + padding-inline-start: 0.5rem !important; +} + +.menu :where(li ul) { + position: relative; + white-space: nowrap; + margin-inline-start: 1rem; + padding-inline-start: 0.5rem; +} + +.\!menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), .\!menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + display: grid !important; + grid-auto-flow: column !important; + align-content: flex-start !important; + align-items: center !important; + gap: 0.5rem !important; + grid-auto-columns: minmax(auto, max-content) auto max-content !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + user-select: none !important; +} + +.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), .menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + display: grid; + grid-auto-flow: column; + align-content: flex-start; + align-items: center; + gap: 0.5rem; + grid-auto-columns: minmax(auto, max-content) auto max-content; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.\!menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), .\!menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + display: grid !important; + grid-auto-flow: column !important; + align-content: flex-start !important; + align-items: center !important; + gap: 0.5rem !important; + grid-auto-columns: minmax(auto, max-content) auto max-content !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + user-select: none !important; +} + +.\!menu li.disabled { + cursor: not-allowed !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + user-select: none !important; + color: var(--fallback-bc,oklch(var(--bc)/0.3)) !important; +} + +.menu li.disabled { + cursor: not-allowed; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + color: var(--fallback-bc,oklch(var(--bc)/0.3)); +} + +.\!menu :where(li > .menu-dropdown:not(.menu-dropdown-show)) { + display: none !important; +} + +.menu :where(li > .menu-dropdown:not(.menu-dropdown-show)) { + display: none; +} + +:where(.\!menu li) { + position: relative !important; + display: flex !important; + flex-shrink: 0 !important; + flex-direction: column !important; + flex-wrap: wrap !important; + align-items: stretch !important; +} + +:where(.menu li) { + position: relative; + display: flex; + flex-shrink: 0; + flex-direction: column; + flex-wrap: wrap; + align-items: stretch; +} + +:where(.\!menu li) .badge { + justify-self: end !important; +} + +:where(.menu li) .badge { + justify-self: end; +} + +.navbar { + display: flex; + align-items: center; + padding: var(--navbar-padding, 0.5rem); + min-height: 4rem; + width: 100%; +} + +:where(.navbar > *:not(script, style)) { + display: inline-flex; + align-items: center; +} + +.navbar-start { + width: 50%; + justify-content: flex-start; +} + +.navbar-end { + width: 50%; + justify-content: flex-end; +} + +.stats { + display: inline-grid; + border-radius: var(--rounded-box, 1rem); + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); +} + +:where(.stats) { + grid-auto-flow: column; + overflow-x: auto; +} + +.stat-value { + grid-column-start: 1; + white-space: nowrap; + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 800; +} + +.\!toggle { + flex-shrink: 0 !important; + --tglbg: var(--fallback-b1,oklch(var(--b1)/1)) !important; + --handleoffset: 1.5rem !important; + --handleoffsetcalculator: calc(var(--handleoffset) * -1) !important; + --togglehandleborder: 0 0 !important; + height: 1.5rem !important; + width: 3rem !important; + cursor: pointer !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + border-radius: var(--rounded-badge, 1.9rem) !important; + border-width: 1px !important; + border-color: currentColor !important; + background-color: currentColor !important; + color: var(--fallback-bc,oklch(var(--bc)/0.5)) !important; + transition: background, + box-shadow var(--animation-input, 0.2s) ease-out !important; + box-shadow: var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset, + var(--togglehandleborder) !important; +} + +.toggle { + flex-shrink: 0; + --tglbg: var(--fallback-b1,oklch(var(--b1)/1)); + --handleoffset: 1.5rem; + --handleoffsetcalculator: calc(var(--handleoffset) * -1); + --togglehandleborder: 0 0; + height: 1.5rem; + width: 3rem; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: var(--rounded-badge, 1.9rem); + border-width: 1px; + border-color: currentColor; + background-color: currentColor; + color: var(--fallback-bc,oklch(var(--bc)/0.5)); + transition: background, + box-shadow var(--animation-input, 0.2s) ease-out; + box-shadow: var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset, + var(--togglehandleborder); +} + +.btm-nav > * .label { + font-size: 1rem; + line-height: 1.5rem; +} + +@media (prefers-reduced-motion: no-preference) { + .btn { + animation: button-pop var(--animation-btn, 0.25s) ease-out; + } +} + +.btn:active:hover, + .btn:active:focus { + animation: button-pop 0s ease-out; + transform: scale(var(--btn-focus-scale, 0.97)); +} + +@supports not (color: oklch(0% 0 0)) { + .btn { + background-color: var(--btn-color, var(--fallback-b2)); + border-color: var(--btn-color, var(--fallback-b2)); + } + + .btn-primary { + --btn-color: var(--fallback-p); + } + + .btn-secondary { + --btn-color: var(--fallback-s); + } + + .btn-accent { + --btn-color: var(--fallback-a); + } + + .btn-neutral { + --btn-color: var(--fallback-n); + } +} + +@supports (color: color-mix(in oklab, black, black)) { + .btn-outline.btn-primary.btn-active { + background-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-p,oklch(var(--p)/1)) 90%, black); + } + + .btn-outline.btn-secondary.btn-active { + background-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-s,oklch(var(--s)/1)) 90%, black); + } + + .btn-outline.btn-accent.btn-active { + background-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-a,oklch(var(--a)/1)) 90%, black); + } + + .btn-outline.btn-success.btn-active { + background-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-su,oklch(var(--su)/1)) 90%, black); + } + + .btn-outline.btn-info.btn-active { + background-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-in,oklch(var(--in)/1)) 90%, black); + } + + .btn-outline.btn-warning.btn-active { + background-color: color-mix(in oklab, var(--fallback-wa,oklch(var(--wa)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-wa,oklch(var(--wa)/1)) 90%, black); + } + + .btn-outline.btn-error.btn-active { + background-color: color-mix(in oklab, var(--fallback-er,oklch(var(--er)/1)) 90%, black); + border-color: color-mix(in oklab, var(--fallback-er,oklch(var(--er)/1)) 90%, black); + } +} + +.btn:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; +} + +.btn-primary { + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); + outline-color: var(--fallback-p,oklch(var(--p)/1)); +} + +@supports (color: oklch(0% 0 0)) { + .btn-primary { + --btn-color: var(--p); + } + + .btn-secondary { + --btn-color: var(--s); + } + + .btn-accent { + --btn-color: var(--a); + } + + .btn-neutral { + --btn-color: var(--n); + } +} + +.btn-secondary { + --tw-text-opacity: 1; + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); + outline-color: var(--fallback-s,oklch(var(--s)/1)); +} + +.btn-accent { + --tw-text-opacity: 1; + color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); + outline-color: var(--fallback-a,oklch(var(--a)/1)); +} + +.btn-neutral { + --tw-text-opacity: 1; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); + outline-color: var(--fallback-n,oklch(var(--n)/1)); +} + +.btn.glass { + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + outline-color: currentColor; +} + +.btn.glass.btn-active { + --glass-opacity: 25%; + --glass-border-opacity: 15%; +} + +.btn-ghost { + border-width: 1px; + border-color: transparent; + background-color: transparent; + color: currentColor; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + outline-color: currentColor; +} + +.btn-ghost.btn-active { + border-color: transparent; + background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.btn-outline { + border-color: currentColor; + background-color: transparent; + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.btn-outline.btn-active { + --tw-border-opacity: 1; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity))); +} + +.btn-outline.btn-primary { + --tw-text-opacity: 1; + color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity))); +} + +.btn-outline.btn-primary.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); +} + +.btn-outline.btn-secondary { + --tw-text-opacity: 1; + color: var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity))); +} + +.btn-outline.btn-secondary.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity))); +} + +.btn-outline.btn-accent { + --tw-text-opacity: 1; + color: var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity))); +} + +.btn-outline.btn-accent.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity))); +} + +.btn-outline.btn-success { + --tw-text-opacity: 1; + color: var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity))); +} + +.btn-outline.btn-success.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity))); +} + +.btn-outline.btn-info { + --tw-text-opacity: 1; + color: var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity))); +} + +.btn-outline.btn-info.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity))); +} + +.btn-outline.btn-warning { + --tw-text-opacity: 1; + color: var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity))); +} + +.btn-outline.btn-warning.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity))); +} + +.btn-outline.btn-error { + --tw-text-opacity: 1; + color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity))); +} + +.btn-outline.btn-error.btn-active { + --tw-text-opacity: 1; + color: var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity))); +} + +.btn.btn-disabled, + .btn[disabled], + .btn:disabled { + --tw-border-opacity: 0; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-bg-opacity: 0.2; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + --tw-text-opacity: 0.2; +} + +.btn:is(input[type="checkbox"]:checked), +.btn:is(input[type="radio"]:checked) { + --tw-border-opacity: 1; + border-color: var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity))); + --tw-bg-opacity: 1; + background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity))); +} + +.btn:is(input[type="checkbox"]:checked):focus-visible, .btn:is(input[type="radio"]:checked):focus-visible { + outline-color: var(--fallback-p,oklch(var(--p)/1)); +} + +@keyframes button-pop { + 0% { + transform: scale(var(--btn-focus-scale, 0.98)); + } + + 40% { + transform: scale(1.02); + } + + 100% { + transform: scale(1); + } +} + +.card :where(figure:first-child) { + overflow: hidden; + border-start-start-radius: inherit; + border-start-end-radius: inherit; + border-end-start-radius: unset; + border-end-end-radius: unset; +} + +.card :where(figure:last-child) { + overflow: hidden; + border-start-start-radius: unset; + border-start-end-radius: unset; + border-end-start-radius: inherit; + border-end-end-radius: inherit; +} + +.card:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.card.bordered { + border-width: 1px; + --tw-border-opacity: 1; + border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity))); +} + +.card.compact .card-body { + padding: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; +} + +.card.image-full :where(figure) { + overflow: hidden; + border-radius: inherit; +} + +@keyframes checkmark { + 0% { + background-position-y: 5px; + } + + 50% { + background-position-y: -2px; + } + + 100% { + background-position-y: 0; + } +} + +.join > :where(*:not(:first-child)):is(.btn) { + margin-inline-start: calc(var(--border-btn) * -1); +} + +.link:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.loading { + pointer-events: none; + display: inline-block; + aspect-ratio: 1 / 1; + width: 1.5rem; + background-color: currentColor; + -webkit-mask-size: 100%; + mask-size: 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); +} + +.loading-spinner { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); +} + +.loading-lg { + width: 2.5rem; +} + +:where(.\!menu li:empty) { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))) !important; + opacity: 0.1 !important; + margin: 0.5rem 1rem !important; + height: 1px !important; +} + +:where(.menu li:empty) { + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + opacity: 0.1; + margin: 0.5rem 1rem; + height: 1px; +} + +.\!menu :where(li ul):before { + position: absolute !important; + bottom: 0.75rem !important; + inset-inline-start: 0px !important; + top: 0.75rem !important; + width: 1px !important; + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))) !important; + opacity: 0.1 !important; + content: "" !important; +} + +.menu :where(li ul):before { + position: absolute; + bottom: 0.75rem; + inset-inline-start: 0px; + top: 0.75rem; + width: 1px; + --tw-bg-opacity: 1; + background-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity))); + opacity: 0.1; + content: ""; +} + +.\!menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), +.\!menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + border-radius: var(--rounded-btn, 0.5rem) !important; + padding-left: 1rem !important; + padding-right: 1rem !important; + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + text-align: start !important; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter !important; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1) !important; + transition-duration: 200ms !important; + text-wrap: balance !important; +} + +.menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), +.menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + border-radius: var(--rounded-btn, 0.5rem); + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + text-align: start; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: 200ms; + text-wrap: balance; +} + +.\!menu :where(li:not(.menu-title) > *:not(ul, details, .menu-title, .btn)), +.\!menu :where(li:not(.menu-title) > details > summary:not(.menu-title)) { + border-radius: var(--rounded-btn, 0.5rem) !important; + padding-left: 1rem !important; + padding-right: 1rem !important; + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + text-align: start !important; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter !important; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1) !important; + transition-duration: 200ms !important; + text-wrap: balance !important; +} + +:where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn).focus, :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn):focus, :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):is(summary):not(.active, .btn):focus-visible, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn).focus, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn):focus, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):is(summary):not(.active, .btn):focus-visible { + cursor: pointer !important; + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))) !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; +} + +:where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):is(summary):not(.active, .btn):focus-visible, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn).focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn):focus, :where(.menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):is(summary):not(.active, .btn):focus-visible { + cursor: pointer; + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)); + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + outline: 2px solid transparent; + outline-offset: 2px; +} + +:where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn).focus, :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):not(summary, .active, .btn):focus, :where(.\!menu li:not(.menu-title, .disabled) > *:not(ul, details, .menu-title)):is(summary):not(.active, .btn):focus-visible, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn).focus, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):not(summary, .active, .btn):focus, :where(.\!menu li:not(.menu-title, .disabled) > details > summary:not(.menu-title)):is(summary):not(.active, .btn):focus-visible { + cursor: pointer !important; + background-color: var(--fallback-bc,oklch(var(--bc)/0.1)) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))) !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; +} + +.\!menu li > *:not(ul, .menu-title, details, .btn):active, +.\!menu li > *:not(ul, .menu-title, details, .btn).active, +.\!menu li > details > summary:active { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))) !important; +} + +.menu li > *:not(ul, .menu-title, details, .btn):active, +.menu li > *:not(ul, .menu-title, details, .btn).active, +.menu li > details > summary:active { + --tw-bg-opacity: 1; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))); + --tw-text-opacity: 1; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))); +} + +.\!menu li > *:not(ul, .menu-title, details, .btn):active, +.\!menu li > *:not(ul, .menu-title, details, .btn).active, +.\!menu li > details > summary:active { + --tw-bg-opacity: 1 !important; + background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity))) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity))) !important; +} + +.\!menu :where(li > details > summary)::-webkit-details-marker { + display: none !important; +} + +.menu :where(li > details > summary)::-webkit-details-marker { + display: none; +} + +.\!menu :where(li > details > summary):after, +.\!menu :where(li > .menu-dropdown-toggle):after { + justify-self: end !important; + display: block !important; + margin-top: -0.5rem !important; + height: 0.5rem !important; + width: 0.5rem !important; + transform: rotate(45deg) !important; + transition-property: transform, margin-top !important; + transition-duration: 0.3s !important; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; + content: "" !important; + transform-origin: 75% 75% !important; + box-shadow: 2px 2px !important; + pointer-events: none !important; +} + +.menu :where(li > details > summary):after, +.menu :where(li > .menu-dropdown-toggle):after { + justify-self: end; + display: block; + margin-top: -0.5rem; + height: 0.5rem; + width: 0.5rem; + transform: rotate(45deg); + transition-property: transform, margin-top; + transition-duration: 0.3s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + content: ""; + transform-origin: 75% 75%; + box-shadow: 2px 2px; + pointer-events: none; +} + +.\!menu :where(li > details > summary):after, +.\!menu :where(li > .menu-dropdown-toggle):after { + justify-self: end !important; + display: block !important; + margin-top: -0.5rem !important; + height: 0.5rem !important; + width: 0.5rem !important; + transform: rotate(45deg) !important; + transition-property: transform, margin-top !important; + transition-duration: 0.3s !important; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; + content: "" !important; + transform-origin: 75% 75% !important; + box-shadow: 2px 2px !important; + pointer-events: none !important; +} + +.\!menu :where(li > details[open] > summary):after, +.\!menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { + transform: rotate(225deg) !important; + margin-top: 0 !important; +} + +.menu :where(li > details[open] > summary):after, +.menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { + transform: rotate(225deg); + margin-top: 0; +} + +.\!menu :where(li > details[open] > summary):after, +.\!menu :where(li > .menu-dropdown-toggle.menu-dropdown-show):after { + transform: rotate(225deg) !important; + margin-top: 0 !important; +} + +@keyframes modal-pop { + 0% { + opacity: 0; + } +} + +@keyframes progress-loading { + 50% { + background-position-x: -115%; + } +} + +@keyframes radiomark { + 0% { + box-shadow: 0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset, + 0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset; + } + + 50% { + box-shadow: 0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset, + 0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset; + } + + 100% { + box-shadow: 0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset, + 0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset; + } +} + +@keyframes rating-pop { + 0% { + transform: translateY(-0.125em); + } + + 40% { + transform: translateY(-0.125em); + } + + 100% { + transform: translateY(0); + } +} + +.skeleton { + border-radius: var(--rounded-box, 1rem); + --tw-bg-opacity: 1; + background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity))); + will-change: background-position; + animation: skeleton 1.8s ease-in-out infinite; + background-image: linear-gradient( + 105deg, + transparent 0%, + transparent 40%, + var(--fallback-b1,oklch(var(--b1)/1)) 50%, + transparent 60%, + transparent 100% + ); + background-size: 200% auto; + background-repeat: no-repeat; + background-position-x: -50%; +} + +@media (prefers-reduced-motion) { + .skeleton { + animation-duration: 15s; + } +} + +@keyframes skeleton { + from { + background-position: 150%; + } + + to { + background-position: -50%; + } +} + +:where(.stats) > :not([hidden]) ~ :not([hidden]) { + --tw-divide-x-reverse: 0; + border-right-width: calc(1px * var(--tw-divide-x-reverse)); + border-left-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + --tw-divide-y-reverse: 0; + border-top-width: calc(0px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(0px * var(--tw-divide-y-reverse)); +} + +[dir="rtl"] .stats > *:not([hidden]) ~ *:not([hidden]) { + --tw-divide-x-reverse: 1; +} + +@keyframes toast-pop { + 0% { + transform: scale(0.9); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +[dir="rtl"] .\!toggle { + --handleoffsetcalculator: calc(var(--handleoffset) * 1) !important; +} + +[dir="rtl"] .toggle { + --handleoffsetcalculator: calc(var(--handleoffset) * 1); +} + +.\!toggle:focus-visible { + outline-style: solid !important; + outline-width: 2px !important; + outline-offset: 2px !important; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)) !important; +} + +.toggle:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + +.\!toggle:hover { + background-color: currentColor !important; +} + +.toggle:hover { + background-color: currentColor; +} + +.\!toggle:checked, + .\!toggle[aria-checked="true"] { + background-image: none !important; + --handleoffsetcalculator: var(--handleoffset) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))) !important; +} + +.toggle:checked, + .toggle[aria-checked="true"] { + background-image: none; + --handleoffsetcalculator: var(--handleoffset); + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); +} + +.\!toggle:checked, + .\!toggle[aria-checked="true"] { + background-image: none !important; + --handleoffsetcalculator: var(--handleoffset) !important; + --tw-text-opacity: 1 !important; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))) !important; +} + +[dir="rtl"] .\!toggle:checked, [dir="rtl"] .\!toggle[aria-checked="true"] { + --handleoffsetcalculator: calc(var(--handleoffset) * -1) !important; +} + +[dir="rtl"] .toggle:checked, [dir="rtl"] .toggle[aria-checked="true"] { + --handleoffsetcalculator: calc(var(--handleoffset) * -1); +} + +[dir="rtl"] .\!toggle:checked, [dir="rtl"] .\!toggle[aria-checked="true"] { + --handleoffsetcalculator: calc(var(--handleoffset) * -1) !important; +} + +.\!toggle:indeterminate { + --tw-text-opacity: 1 !important; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))) !important; + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset !important; +} + +.toggle:indeterminate { + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity))); + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset; +} + +[dir="rtl"] .\!toggle:indeterminate { + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset !important; +} + +[dir="rtl"] .toggle:indeterminate { + box-shadow: calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset, + calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset, + 0 0 0 2px var(--tglbg) inset; +} + +.\!toggle:disabled { + cursor: not-allowed !important; + --tw-border-opacity: 1 !important; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))) !important; + background-color: transparent !important; + opacity: 0.3 !important; + --togglehandleborder: 0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset, + var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset !important; +} + +.toggle:disabled { + cursor: not-allowed; + --tw-border-opacity: 1; + border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity))); + background-color: transparent; + opacity: 0.3; + --togglehandleborder: 0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset, + var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset; +} + +.btn-lg { + height: 4rem; + min-height: 4rem; + padding-left: 1.5rem; + padding-right: 1.5rem; + font-size: 1.125rem; +} + +.btn-square:where(.btn-xs) { + height: 1.5rem; + width: 1.5rem; + padding: 0px; +} + +.btn-square:where(.btn-sm) { + height: 2rem; + width: 2rem; + padding: 0px; +} + +.btn-square:where(.btn-md) { + height: 3rem; + width: 3rem; + padding: 0px; +} + +.btn-square:where(.btn-lg) { + height: 4rem; + width: 4rem; + padding: 0px; +} + +.btn-circle:where(.btn-xs) { + height: 1.5rem; + width: 1.5rem; + border-radius: 9999px; + padding: 0px; +} + +.btn-circle:where(.btn-sm) { + height: 2rem; + width: 2rem; + border-radius: 9999px; + padding: 0px; +} + +.btn-circle:where(.btn-md) { + height: 3rem; + width: 3rem; + border-radius: 9999px; + padding: 0px; +} + +.btn-circle:where(.btn-lg) { + height: 4rem; + width: 4rem; + border-radius: 9999px; + padding: 0px; +} + +.join.join-vertical > :where(*:not(:first-child)):is(.btn) { + margin-top: calc(var(--border-btn) * -1); +} + +.join.join-horizontal > :where(*:not(:first-child)):is(.btn) { + margin-inline-start: calc(var(--border-btn) * -1); + margin-top: 0px; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-12 { + margin-bottom: 3rem; +} + +.mb-16 { + margin-bottom: 4rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.block { + display: block; +} + +.flex { + display: flex; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-6 { + height: 1.5rem; +} + +.min-h-\[48px\] { + min-height: 48px; +} + +.min-h-screen { + min-height: 100vh; +} + +.w-6 { + width: 1.5rem; +} + +.w-full { + width: 100%; +} + +.min-w-\[180px\] { + min-width: 180px; +} + +.max-w-2xl { + max-width: 42rem; +} + +.max-w-4xl { + max-width: 56rem; +} + +.grid-flow-col { + grid-auto-flow: column; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-start { + justify-content: flex-start; +} + +.justify-center { + justify-content: center; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-8 { + gap: 2rem; +} + +.border { + border-width: 1px; +} + +.border-base-300 { + --tw-border-opacity: 1; + border-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity, 1))); +} + +.bg-base-100 { + --tw-bg-opacity: 1; + background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity, 1))); +} + +.bg-base-200 { + --tw-bg-opacity: 1; + background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity, 1))); +} + +.bg-brand-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); +} + +.bg-primary { + --tw-bg-opacity: 1; + background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity, 1))); +} + +.p-10 { + padding: 2.5rem; +} + +.p-4 { + padding: 1rem; +} + +.p-6 { + padding: 1.5rem; +} + +.p-8 { + padding: 2rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-16 { + padding-top: 4rem; + padding-bottom: 4rem; +} + +.py-20 { + padding-top: 5rem; + padding-bottom: 5rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.text-center { + text-align: center; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-5xl { + font-size: 3rem; + line-height: 1; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.text-accent { + --tw-text-opacity: 1; + color: var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity, 1))); +} + +.text-base-content { + --tw-text-opacity: 1; + color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity, 1))); +} + +.text-base-content\/60 { + color: var(--fallback-bc,oklch(var(--bc)/0.6)); +} + +.text-base-content\/70 { + color: var(--fallback-bc,oklch(var(--bc)/0.7)); +} + +.text-brand-600 { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity, 1)); +} + +.text-error { + --tw-text-opacity: 1; + color: var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity, 1))); +} + +.text-primary { + --tw-text-opacity: 1; + color: var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity, 1))); +} + +.text-primary-content { + --tw-text-opacity: 1; + color: var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity, 1))); +} + +.text-secondary { + --tw-text-opacity: 1; + color: var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity, 1))); +} + +.opacity-90 { + opacity: 0.9; +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.invert { + --tw-invert: invert(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.sepia { + --tw-sepia: sepia(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +/* Theme transitions - Owner: Scenario 8 - Dark Mode Theme */ + +html { + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Smooth transitions for theme-aware elements */ + +body, .navbar, .hero, .card, .footer, section { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} + +/* Feature card hover effects in dark mode */ + +[data-theme="dark"] .feature-card:hover { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +/* Feature card hover effects */ + +.feature-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.feature-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +/* Feature icon styling */ + +.feature-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 0.75rem; +} + +/* ===== Responsive Design - Scenario 7 ===== */ + +/* Mobile: ensure no horizontal overflow */ + +@media (max-width: 767px) { + body { + overflow-x: hidden; + } + + img { + max-width: 100%; + height: auto; + } + + /* Reduce hero padding on mobile */ + + .hero { + padding-top: 3rem; + padding-bottom: 3rem; + } + + /* Ensure touch targets */ + + .btn, + a.btn, + button { + min-height: 44px; + min-width: 44px; + } + + /* Footer links stack vertically */ + + .footer .grid-flow-col { + grid-auto-flow: row; + grid-template-columns: 1fr; + text-align: center; + } +} + +/* Landscape orientation on mobile */ + +@media (max-width: 767px) and (orientation: landscape) { + .hero { + padding-top: 2rem; + padding-bottom: 2rem; + } +} + +/* ===== Performance Optimizations - Scenario 11 ===== */ + +/* Prevent layout shift by reserving space for images */ + +img, svg { + max-width: 100%; + height: auto; +} + +/* Content visibility for below-fold sections to improve rendering performance */ + +#features, +[data-testid="cta-section"], +[data-testid="footer"] { + content-visibility: auto; + contain-intrinsic-size: auto 300px; +} + +/* Layout containment for feature cards to isolate layout shifts */ + +.feature-card { + contain: layout style; +} + +/* Optimize paint performance for the navbar */ + +.navbar { + contain: layout style paint; +} + +/* Reduce motion for users who prefer it (performance + accessibility) */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ===== Analytics Counter - Scenario 12 ===== */ + +/* Stat card styling */ + +.stat-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +/* Stat value number styling */ + +.stat-value { + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +/* Dark mode stat card hover */ + +[data-theme="dark"] .stat-card:hover { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +/* Analytics section responsive */ + +@media (max-width: 767px) { + #analytics .grid { + gap: 1rem; + } + + .stat-card { + padding: 1.5rem; + } + + .stat-value { + font-size: 2.25rem; + } +} + +/* Preload critical font fallback to avoid FOUT */ + +@font-face { + font-family: 'SystemUI'; + + src: local('system-ui'); + + font-display: swap; +} + +@media (min-width: 640px) { + .sm\:w-auto { + width: auto; + } + + .sm\:flex-row { + flex-direction: row; + } +} + +@media (min-width: 768px) { + .md\:flex { + display: flex; + } + + .md\:hidden { + display: none; + } + + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .md\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .md\:text-5xl { + font-size: 3rem; + line-height: 1; + } +} + +@media (min-width: 1024px) { + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 000000000..350f687a5 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,57 @@ + + + + + + {% block title %}MirDB - URL Shortening Service{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block meta %}{% endblock %} + + + + {% include 'partials/nav.html' ignore missing %} + +
+ {% block content %}{% endblock %} +
+ + {% include 'partials/footer.html' ignore missing %} + + + {% block scripts %}{% endblock %} + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 000000000..2f1588933 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block title %}MirDB - URL Shortening Service{% endblock %} + +{% block content %} + {% include 'partials/hero.html' ignore missing %} + {% include 'partials/features.html' %} + {% include 'partials/cta.html' ignore missing %} +{% endblock %} diff --git a/web/templates/partials/analytics.html b/web/templates/partials/analytics.html new file mode 100644 index 000000000..5df37a858 --- /dev/null +++ b/web/templates/partials/analytics.html @@ -0,0 +1,73 @@ + + +
+
+
+

+ Trusted by Thousands +

+

+ Join a growing community of users who rely on MirDB for their link management needs. +

+
+ +
+ +
+
+ -- +
+
+ URLs Created +
+
+ + +
+
+ -- +
+
+ Active Users +
+
+ + +
+
+ -- +
+
+ Total Clicks +
+
+
+ + + + + + +
+
diff --git a/web/templates/partials/cta.html b/web/templates/partials/cta.html new file mode 100644 index 000000000..dd9001538 --- /dev/null +++ b/web/templates/partials/cta.html @@ -0,0 +1,19 @@ + +
+
+

Ready to shorten your links?

+

Join thousands of users who trust MirDB for their URL shortening needs.

+ +
+
diff --git a/web/templates/partials/features.html b/web/templates/partials/features.html new file mode 100644 index 000000000..942b1fb45 --- /dev/null +++ b/web/templates/partials/features.html @@ -0,0 +1,67 @@ + +
+
+
+

+ Powerful Features for Every Need +

+

+ Everything you need to manage, track, and optimize your links in one place. +

+
+ +
+ +
+
+ Link icon representing URL shortening +
+

URL Shortening

+

+ Create concise, shareable short links instantly. Customize your URLs with memorable aliases and branded domains. +

+
+ + +
+
+ Bar chart icon representing analytics +
+

Analytics

+

+ Track clicks, geographic locations, referrers, and device types. Get real-time insights into how your links perform. +

+
+ + +
+
+ Dashboard layout icon +
+

Dashboard

+

+ Manage all your shortened URLs from a clean, intuitive dashboard. Organize, edit, and delete links with ease. +

+
+ + +
+
+ Shield check icon representing secure links +
+

Secure Links

+

+ Protect your links with password authentication and expiration dates. HTTPS encryption ensures safe redirections. +

+
+
+
+
diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html new file mode 100644 index 000000000..c858a771c --- /dev/null +++ b/web/templates/partials/footer.html @@ -0,0 +1,22 @@ + + diff --git a/web/templates/partials/hero.html b/web/templates/partials/hero.html new file mode 100644 index 000000000..1502df39b --- /dev/null +++ b/web/templates/partials/hero.html @@ -0,0 +1,25 @@ + +
+
+
+

+ Shorten Your URLs with MirDB +

+

+ Create short, memorable links and track their performance with powerful analytics. +

+ + Get Started + +
+
+
diff --git a/web/templates/partials/nav.html b/web/templates/partials/nav.html new file mode 100644 index 000000000..5f68d6065 --- /dev/null +++ b/web/templates/partials/nav.html @@ -0,0 +1,73 @@ + + + + +