diff --git a/.env b/.env index 4cb0207..4d358da 100644 --- a/.env +++ b/.env @@ -10,4 +10,6 @@ ROYALTY_IMAGE_PROVIDERS=pixabay,unsplash,pexels,wikimedia,picsum PIXABAY_API_KEY=54637577-dbef68c927eec6553190fa4dc UNSPLASH_ACCESS_KEY= PEXELS_API_KEY=fRdPmXg16nsz1pPe0Zmp02eALJkhAz4sG7g4RN56Q3J90Qi6qV3Qvuz8 -SUMMARY_LENGTH_SCALE=3 \ No newline at end of file +SUMMARY_LENGTH_SCALE=3 +GITHUB_REPO_URL=https://github.com/santhoshjanan +CONTACT_EMAIL=santhoshj@gmail.com \ No newline at end of file diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..04082a0 --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,37 @@ +name: quality-gates + +on: + pull_request: + push: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install project dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + - name: Ruff lint + run: python -m ruff check backend tests + - name: Pytest coverage and contracts + run: python -m pytest + + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install scanner + run: | + python -m pip install --upgrade pip + pip install pip-audit + - name: Dependency vulnerability scan + run: pip-audit diff --git a/.sisyphus/notepads/sync-p12-p16/decisions.md b/.sisyphus/notepads/sync-p12-p16/decisions.md new file mode 100644 index 0000000..70318a4 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/decisions.md @@ -0,0 +1,5 @@ +## 2026-02-13 + +- Applied chronological conflict resolution order exactly as requested: p12 -> p13 -> p14 -> p15 -> p16, with p16 language/behavior taking precedence on overlapping requirement text. +- Created new capability specs in canonical form for all delta-marked additions missing from `openspec/specs/*`. +- Kept pre-existing requirements/scenarios unless superseded by explicit newer behavioral changes (e.g., footer policy navigation replaced with modal disclosure behavior). diff --git a/.sisyphus/notepads/sync-p12-p16/issues.md b/.sisyphus/notepads/sync-p12-p16/issues.md new file mode 100644 index 0000000..cabfc13 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/issues.md @@ -0,0 +1,3 @@ +## 2026-02-13 + +- Markdown LSP diagnostics are unavailable in this workspace (`No LSP server configured for extension: .md`), so spec-file verification relied on structural diff review instead of LSP validation. diff --git a/.sisyphus/notepads/sync-p12-p16/learnings.md b/.sisyphus/notepads/sync-p12-p16/learnings.md new file mode 100644 index 0000000..38afce8 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/learnings.md @@ -0,0 +1,5 @@ +## 2026-02-13 + +- Canonical OpenSpec sync files follow a stable template: `## Purpose` + `## Requirements`; preserving this structure keeps delta-to-main merges consistent. +- Conflict-heavy capability merges are safest when newest delta text updates requirement sentence semantics while older non-conflicting scenarios are retained. +- For policy/footer capabilities, behavior precedence can flip navigation contracts (page routes -> modals) while still preserving visibility and accessibility scenarios from older deltas. diff --git a/.sisyphus/notepads/sync-p12-p16/problems.md b/.sisyphus/notepads/sync-p12-p16/problems.md new file mode 100644 index 0000000..1c2abd3 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/problems.md @@ -0,0 +1,3 @@ +## 2026-02-13 + +- None unresolved in the p12-p16 spec merge scope. diff --git a/README.md b/README.md index 26623b3..42c0d60 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,20 @@ Exit codes: - `0`: Command completed successfully (including runs that store zero new rows) - `1`: Fatal command failure (for example missing API keys or unrecoverable runtime error) +## Quality and Test Suite + +Run local quality gates: + +```bash +pip install -e .[dev] +pytest +ruff check backend tests +``` + +CI quality gates are defined in `.github/workflows/quality-gates.yml`. + +Monitoring baseline, thresholds, and alert runbook are documented in `docs/quality-and-monitoring.md`. + ## Admin Maintenance Commands ClawFort includes an admin command suite to simplify operational recovery and maintenance. diff --git a/backend/__pycache__/cli.cpython-313.pyc b/backend/__pycache__/cli.cpython-313.pyc index 8ad57c9..e95c475 100644 Binary files a/backend/__pycache__/cli.cpython-313.pyc and b/backend/__pycache__/cli.cpython-313.pyc differ diff --git a/backend/__pycache__/config.cpython-313.pyc b/backend/__pycache__/config.cpython-313.pyc index 94ff737..b635616 100644 Binary files a/backend/__pycache__/config.cpython-313.pyc and b/backend/__pycache__/config.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 9301e86..b2b37d5 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/news_service.cpython-313.pyc b/backend/__pycache__/news_service.cpython-313.pyc index 0a9dae9..d36f2e6 100644 Binary files a/backend/__pycache__/news_service.cpython-313.pyc and b/backend/__pycache__/news_service.cpython-313.pyc differ diff --git a/backend/cli.py b/backend/cli.py index 688e1f5..94e447f 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -14,6 +14,7 @@ from backend import config from backend.database import SessionLocal, init_db from backend.models import NewsItem from backend.news_service import ( + GENERIC_AI_FALLBACK_URL, download_and_optimize_image, extract_image_keywords, fetch_royalty_free_image, @@ -87,56 +88,105 @@ def build_contextual_query(headline: str, summary: str | None) -> str: return cleaned +def resolve_article_id_from_permalink(value: str | None) -> int | None: + if not value: + return None + if value.isdigit(): + return int(value) + match = re.search(r"(?:\?|&)article=(\d+)", value) + if match: + return int(match.group(1)) + return None + + +def is_unrelated_image_candidate(image_url: str | None, image_credit: str | None) -> bool: + text = f"{image_url or ''} {image_credit or ''}".lower() + blocked = ( + "cat", + "dog", + "pet", + "animal", + "wildlife", + "lion", + "tiger", + "bird", + "horse", + ) + return any(term in text for term in blocked) + + +async def refetch_image_for_item( + item: NewsItem, + max_attempts: int, +) -> tuple[str | None, str | None, str]: + query = build_contextual_query(item.headline, item.summary) + current_summary_image = item.summary_image_url + query_variants = [ + f"{query} alternative angle", + f"{query} concept illustration", + query, + ] + + for query_variant in query_variants: + for attempt in range(max_attempts): + try: + image_url, image_credit = await fetch_royalty_free_image(query_variant) + if not image_url: + raise RuntimeError("no-image-url") + if is_unrelated_image_candidate(image_url, image_credit): + logger.info("Rejected unrelated image candidate: %s", image_url) + continue + local_image = await download_and_optimize_image(image_url) + if not local_image: + raise RuntimeError("image-download-or-optimize-failed") + if current_summary_image and local_image == current_summary_image: + logger.info("Rejected duplicate image candidate for article=%s", item.id) + continue + return local_image, image_credit, "provider" + except Exception: + if attempt < max_attempts - 1: + delay = 2**attempt + await asyncio.sleep(delay) + + fallback_local = await download_and_optimize_image(GENERIC_AI_FALLBACK_URL) + if fallback_local and fallback_local != current_summary_image: + return fallback_local, "AI-themed fallback", "fallback" + return None, None, "none" + + async def refetch_images_for_latest( limit: int, max_attempts: int, dry_run: bool, + target_article_id: int | None = None, ) -> tuple[int, int]: db = SessionLocal() processed = 0 refreshed = 0 try: - items = ( - db.query(NewsItem) - .filter(NewsItem.archived.is_(False)) - .order_by(desc(NewsItem.published_at)) - .limit(limit) - .all() - ) + if target_article_id is not None: + items = ( + db.query(NewsItem) + .filter(NewsItem.archived.is_(False), NewsItem.id == target_article_id) + .all() + ) + else: + items = ( + db.query(NewsItem) + .filter(NewsItem.archived.is_(False)) + .order_by(desc(NewsItem.published_at)) + .limit(limit) + .all() + ) total = len(items) for idx, item in enumerate(items, start=1): processed += 1 - query = build_contextual_query(item.headline, item.summary) - - image_url: str | None = None - image_credit: str | None = None - local_image: str | None = None - - for attempt in range(max_attempts): - try: - image_url, image_credit = await fetch_royalty_free_image(query) - if not image_url: - raise RuntimeError("no-image-url") - local_image = await download_and_optimize_image(image_url) - if not local_image: - raise RuntimeError("image-download-or-optimize-failed") - break - except Exception: - if attempt == max_attempts - 1: - logger.exception("Image refetch failed for item=%s after retries", item.id) - image_url = None - local_image = None - break - delay = 2**attempt - logger.warning( - "Refetch retry item=%s attempt=%d delay=%ds", - item.id, - attempt + 1, - delay, - ) - await asyncio.sleep(delay) + local_image, image_credit, decision = await refetch_image_for_item( + item=item, + max_attempts=max_attempts, + ) if local_image: refreshed += 1 @@ -152,6 +202,7 @@ async def refetch_images_for_latest( total=total, refreshed=refreshed, article_id=item.id, + decision=decision, ) return processed, refreshed @@ -186,6 +237,12 @@ def build_parser() -> argparse.ArgumentParser: help="Refetch and optimize latest article images", ) refetch_parser.add_argument("--limit", type=positive_int, default=30) + refetch_parser.add_argument( + "--permalink", + type=str, + default="", + help="Target one article by permalink (for example '/?article=123' or '123')", + ) refetch_parser.add_argument("--max-attempts", type=positive_int, default=4) refetch_parser.add_argument("--dry-run", action="store_true") refetch_parser.set_defaults(handler=handle_admin_refetch_images) @@ -280,11 +337,22 @@ def handle_admin_refetch_images(args: argparse.Namespace) -> int: start = time.monotonic() try: init_db() + target_article_id = resolve_article_id_from_permalink(args.permalink) + if args.permalink and target_article_id is None: + print_result( + "refetch-images", + "blocked", + reason="invalid-permalink", + hint="use '/?article=' or raw numeric id", + ) + return 2 + processed, refreshed = asyncio.run( refetch_images_for_latest( limit=min(args.limit, 30), max_attempts=args.max_attempts, dry_run=args.dry_run, + target_article_id=target_article_id, ) ) elapsed = time.monotonic() - start @@ -293,6 +361,7 @@ def handle_admin_refetch_images(args: argparse.Namespace) -> int: "ok", processed=processed, refreshed=refreshed, + target_article_id=target_article_id, dry_run=args.dry_run, elapsed=f"{elapsed:.1f}s", ) diff --git a/backend/main.py b/backend/main.py index 06e3cbd..f87992a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,18 +37,18 @@ app = FastAPI(title="ClawFort News API", version="0.1.0") _ERROR_MESSAGES = { 404: [ - "Oh no! This page wandered off to train a tiny model.", - "Oh no! We looked everywhere, even in the latent space.", - "Oh no! The link took a creative detour.", - "Oh no! This route is currently off doing research.", - "Oh no! The page you asked for is not in this timeline.", + "This page wandered off to train a tiny model.", + "We looked everywhere, even in the latent space.", + "The link took a creative detour.", + "This route is currently off doing research.", + "The page you asked for is not in this timeline.", ], 500: [ - "Oh no! The server hit a logic knot and needs a quick reset.", - "Oh no! Our robots dropped a semicolon somewhere important.", - "Oh no! A background process got stage fright.", - "Oh no! The AI took an unexpected coffee break.", - "Oh no! Something internal blinked at the wrong moment.", + "The server hit a logic knot and needs a quick reset.", + "Our robots dropped a semicolon somewhere important.", + "A background process got stage fright.", + "The AI took an unexpected coffee break.", + "Something internal blinked at the wrong moment.", ], } diff --git a/backend/news_service.py b/backend/news_service.py index 64bdd96..0391e30 100644 --- a/backend/news_service.py +++ b/backend/news_service.py @@ -25,6 +25,49 @@ logger = logging.getLogger(__name__) PLACEHOLDER_IMAGE_PATH = "/static/images/placeholder.png" GENERIC_AI_FALLBACK_URL = "https://placehold.co/1200x630/0f172a/e2e8f0/png?text=AI+News" +GENERIC_FINANCE_FALLBACK_URL = "https://placehold.co/1200x630/0f172a/e2e8f0/png?text=Market+News" + +_FINANCE_TOPIC_TERMS = frozenset( + { + "finance", + "financial", + "market", + "markets", + "stock", + "stocks", + "share", + "shares", + "earnings", + "investor", + "investors", + "nasdaq", + "nyse", + "dow", + "s&p", + "bank", + "banking", + "revenue", + "profit", + "trading", + "ipo", + "valuation", + } +) + +_FINANCE_IMAGE_BLOCKLIST = ( + "cat", + "dog", + "pet", + "lion", + "tiger", + "bird", + "horse", + "portrait", + "selfie", + "wedding", + "food", + "nature-only", +) async def call_perplexity_api(query: str) -> dict | None: @@ -174,6 +217,43 @@ def parse_translation_response(response: dict) -> dict | None: return None +def validate_translation_quality( + headline: str, summary: str, language_code: str +) -> tuple[bool, str | None]: + text = f"{headline} {summary}".strip() + if not headline or not summary: + return False, "empty-content" + if len(text) < 20: + return False, "too-short" + + repeated_runs = re.search(r"(.)\1{6,}", text) + if repeated_runs: + return False, "repeated-sequence" + + lines = [segment.strip() for segment in re.split(r"[.!?]\s+", text) if segment.strip()] + if lines: + unique_ratio = len(set(lines)) / len(lines) + if unique_ratio < 0.4: + return False, "low-unique-content" + + if language_code == "ta": + script_hits = sum(1 for char in text if "\u0b80" <= char <= "\u0bff") + elif language_code == "ml": + script_hits = sum(1 for char in text if "\u0d00" <= char <= "\u0d7f") + else: + return True, None + + alpha_hits = sum(1 for char in text if char.isalpha()) + if alpha_hits == 0: + return False, "no-alpha-content" + + script_ratio = script_hits / alpha_hits + if script_ratio < 0.35: + return False, "script-mismatch" + + return True, None + + async def generate_translations( headline: str, summary: str, @@ -200,7 +280,20 @@ async def generate_translations( if response: parsed = parse_translation_response(response) if parsed: - translations[language_code] = parsed + is_valid, reason = validate_translation_quality( + parsed["headline"], + parsed["summary"], + language_code, + ) + if is_valid: + logger.info("Translation accepted for %s", language_code) + translations[language_code] = parsed + else: + logger.warning( + "Translation rejected for %s: %s", + language_code, + reason, + ) except Exception: logger.exception("Translation generation failed for %s", language_code) @@ -467,7 +560,7 @@ async def fetch_pixabay_image(query: str) -> tuple[str | None, str | None]: except Exception: logger.exception("Pixabay image retrieval failed") - return GENERIC_AI_FALLBACK_URL, "Generic AI fallback" + return None, None async def fetch_unsplash_image(query: str) -> tuple[str | None, str | None]: @@ -591,6 +684,15 @@ def get_enabled_providers() -> list[ async def fetch_royalty_free_image(query: str) -> tuple[str | None, str | None]: """Fetch royalty-free image using provider chain with fallback.""" + + def is_finance_story(text: str) -> bool: + lowered = (text or "").lower() + return any(term in lowered for term in _FINANCE_TOPIC_TERMS) + + def is_finance_safe_image(image_url: str, credit: str | None) -> bool: + haystack = f"{image_url or ''} {credit or ''}".lower() + return not any(term in haystack for term in _FINANCE_IMAGE_BLOCKLIST) + # MCP endpoint takes highest priority if configured if config.ROYALTY_IMAGE_MCP_ENDPOINT: try: @@ -610,15 +712,35 @@ async def fetch_royalty_free_image(query: str) -> tuple[str | None, str | None]: # Extract keywords for better image search refined_query = extract_image_keywords(query) + finance_story = is_finance_story(query) + query_variants = [refined_query] + if finance_story: + query_variants = [ + f"{refined_query} stock market trading chart finance business", + refined_query, + ] # Try each enabled provider in order - for provider_name, fetch_fn in get_enabled_providers(): - try: - image_url, credit = await fetch_fn(refined_query) - if image_url: + for query_variant in query_variants: + for provider_name, fetch_fn in get_enabled_providers(): + try: + image_url, credit = await fetch_fn(query_variant) + if not image_url: + continue + if finance_story and not is_finance_safe_image(image_url, credit): + logger.info( + "Rejected non-finance-safe image from %s for query '%s': %s", + provider_name, + query_variant, + image_url, + ) + continue return image_url, credit - except Exception: - logger.exception("%s image retrieval failed", provider_name.capitalize()) + except Exception: + logger.exception("%s image retrieval failed", provider_name.capitalize()) + + if finance_story: + return GENERIC_FINANCE_FALLBACK_URL, "Finance-safe fallback" return None, None diff --git a/docs/monitoring-dashboard-config.md b/docs/monitoring-dashboard-config.md new file mode 100644 index 0000000..f1aa2d9 --- /dev/null +++ b/docs/monitoring-dashboard-config.md @@ -0,0 +1,26 @@ +# Monitoring Dashboard Configuration + +## Objective + +Define baseline dashboards and alert thresholds for reliability and freshness checks. + +## Dashboard Panels + +1. API p95 latency for `/api/news` and `/api/news/latest` +2. API error rate (`5xx`) by route +3. Scheduler success/failure count per hour +4. Feed freshness lag (minutes since latest published item) + +## Alert Thresholds + +- API latency alert: p95 > 750 ms for 10 minutes +- API error-rate alert: `5xx` > 3% for 5 minutes +- Scheduler alert: 2 consecutive failed fetch cycles +- Freshness alert: latest item older than 120 minutes + +## Test Trigger Plan + +- Latency trigger: run stress test against `/api/news` with 50 concurrent requests in staging. +- Error-rate trigger: simulate upstream timeout and confirm 5xx alert path. +- Scheduler trigger: disable upstream API key in staging and verify consecutive failure alert. +- Freshness trigger: pause scheduler for >120 minutes in staging and confirm lag alert. diff --git a/docs/p15-code-review-findings.md b/docs/p15-code-review-findings.md new file mode 100644 index 0000000..022bf8d --- /dev/null +++ b/docs/p15-code-review-findings.md @@ -0,0 +1,23 @@ +# P15 Code Review Findings + +Date: 2026-02-13 + +## High + +- owner=backend area=translations finding=Machine translation output is accepted without strict language validation in runtime flow, allowing occasional script mismatch/gibberish. + +## Medium + +- owner=frontend area=policy-disclosures finding=Terms and Attribution links previously required route navigation, reducing continuity and causing context loss. +- owner=backend area=admin-cli finding=Image refetch previously lacked permalink-targeted repair mode, forcing broad batch operations. + +## Low + +- owner=frontend area=sharing finding=Text-based icon actions in compact surfaces reduced visual consistency on small screens. + +## Remediation Status + +- translations-quality-gate: fixed-in-progress +- policy-modal-surface: fixed-in-progress +- permalink-targeted-refetch: fixed-in-progress +- icon-consistency: fixed-in-progress diff --git a/docs/quality-and-monitoring.md b/docs/quality-and-monitoring.md new file mode 100644 index 0000000..1e44e33 --- /dev/null +++ b/docs/quality-and-monitoring.md @@ -0,0 +1,64 @@ +# Quality and Monitoring Baseline + +## CI Quality Gates + +Pipeline file: `.github/workflows/quality-gates.yml` + +Stages: +- `lint-and-test`: Ruff + pytest (coverage threshold enforced). +- `security-scan`: `pip-audit` dependency vulnerability scan. + +Failure policy: +- Any failed stage blocks merge. +- Coverage floor below threshold blocks merge. + +## Coverage and Test Scope + +Current baseline suites: +- API contracts: `tests/test_api_contracts.py` +- DB lifecycle workflows: `tests/test_db_workflows.py` +- Accessibility contracts: `tests/test_accessibility_contract.py` +- Security/performance smoke checks: `tests/test_security_and_performance.py` + +## UX Validation Checklist + +Run manually on desktop + mobile viewport: +1. Hero loads with image and CTA visible. +2. Feed cards render with source and TL;DR CTA. +3. Modal opens/closes with Escape and backdrop click. +4. Share controls are visible in light and dark themes. +5. Floating back-to-top appears after scrolling and returns to top. + +## Production Metrics and Alert Thresholds + +| Metric | Target | Alert Threshold | +|---|---|---| +| API p95 latency (`/api/news`) | < 350 ms | > 750 ms for 10 min | +| API error rate (`5xx`) | < 1% | > 3% for 5 min | +| Scheduler success rate | 100% hourly runs | 2 consecutive failures | +| Feed freshness lag | < 75 min | > 120 min | + +## Alert Runbook + +### Incident: Elevated API latency +1. Confirm DB file I/O and host CPU saturation. +2. Inspect recent release diff for expensive queries. +3. Roll back latest deploy if regression is confirmed. + +### Incident: Scheduler failures +1. Check API key and upstream provider status. +2. Run `python -m backend.cli force-fetch` for repro. +3. Review logs for provider fallback exhaustion. + +### Incident: Error-rate spike +1. Check `/api/health` response and DB availability. +2. Identify top failing routes and common status codes. +3. Mitigate with rollback or feature flag disablement. + +## Review/Remediation Log Template + +Use this structure for each cycle: + +```text +severity= owner= area= finding= status= +``` diff --git a/frontend/index.html b/frontend/index.html index a1b6400..80f93ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -103,8 +103,13 @@ --cf-select-text: #ffffff; --cf-select-border: rgba(255, 255, 255, 0.55); } - .cf-body { background: var(--cf-bg); color: var(--cf-text); } + .cf-body { background: var(--cf-bg); color: var(--cf-text); padding-bottom: 78px; } .cf-header { background: var(--cf-header-bg); } + .cf-header-top { box-shadow: none; border-color: rgba(148, 163, 184, 0.12); } + .cf-header-scrolled { + box-shadow: 0 10px 28px rgba(2, 6, 23, 0.24); + border-color: rgba(148, 163, 184, 0.28); + } .cf-card { background: var(--cf-card-bg) !important; } .cf-modal { background: var(--cf-modal-bg); } .cf-select { @@ -129,7 +134,7 @@ } .theme-menu-item:hover { background: rgba(92, 124, 250, 0.15); } .hero-overlay { - background: linear-gradient(to top, rgba(2, 6, 23, 0.94), rgba(15, 23, 42, 0.62), rgba(15, 23, 42, 0.22), transparent); + background: linear-gradient(to top, rgba(2, 6, 23, 0.97), rgba(15, 23, 42, 0.75), rgba(15, 23, 42, 0.35), transparent); } .hero-title { color: #e2e8f0; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55); } .hero-summary { color: #cbd5e1; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); } @@ -221,6 +226,14 @@ line-height: 1.78; letter-spacing: 0.01em; } + html[data-lang='ta'] .hero-title, + html[data-lang='ml'] .hero-title { + text-shadow: 0 3px 12px rgba(0, 0, 0, 0.75); + } + html[data-lang='ta'] .hero-summary, + html[data-lang='ml'] .hero-summary { + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7); + } .share-icon-btn { width: 34px; height: 34px; @@ -234,8 +247,42 @@ transition: background 180ms ease; } .share-icon-btn:hover { background: rgba(92, 124, 250, 0.25); } + html[data-theme='light'] .share-icon-btn { + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.45); + background: rgba(59, 130, 246, 0.12); + } + html[data-theme='light'] .share-icon-btn:hover { + background: rgba(59, 130, 246, 0.2); + } .footer-link { text-decoration: underline; text-underline-offset: 2px; } .footer-link:hover { color: #dbeafe; } + .site-footer { + background: color-mix(in srgb, var(--cf-bg) 92%, transparent); + backdrop-filter: blur(10px); + } + .back-to-top-island { + position: fixed; + right: 14px; + bottom: 88px; + z-index: 55; + border-radius: 9999px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.88); + color: #dbeafe; + min-width: 44px; + min-height: 44px; + padding: 0 13px; + font-size: 12px; + font-weight: 700; + line-height: 1; + box-shadow: 0 12px 24px rgba(2, 6, 23, 0.35); + } + html[data-theme='light'] .back-to-top-island { + background: rgba(248, 250, 252, 0.96); + color: #1e3a8a; + border-color: rgba(37, 99, 235, 0.35); + } .contact-hint { position: fixed; z-index: 60; @@ -256,14 +303,31 @@ } @media (max-width: 640px) { .theme-btn { width: 26px; height: 26px; } + .cf-body { padding-bottom: 84px; } + html[data-lang='ta'] .hero-title, + html[data-lang='ml'] .hero-title { + font-size: 1.9rem; + line-height: 1.26; + letter-spacing: 0.01em; + } + html[data-lang='ta'] .hero-summary, + html[data-lang='ml'] .hero-summary { + font-size: 1.08rem; + line-height: 1.86; + letter-spacing: 0.015em; + } + .back-to-top-island { + right: 10px; + bottom: 82px; + } } Skip to content -
- @@ -409,10 +470,7 @@ @click.stop="trackEvent('source-link-click')" x-show="item.source_url" x-text="extractDomain(item.source_url)"> -
- Link - -
+ +

Permalink copied.

@@ -508,16 +572,54 @@
-