p08-seo-tweaks

This commit is contained in:
2026-02-13 00:49:22 -05:00
parent a1da041f14
commit 88a5540b7d
63 changed files with 2228 additions and 37 deletions

4
.env
View File

@@ -4,3 +4,7 @@ OPENROUTER_API_KEY=sk-or-v1-ef54151dcbc69e380a890d35ad25b533b12abb753802016d613f
UMAMI_SCRIPT_URL=https://wa.santhoshj.com/script.js
UMAMI_WEBSITE_ID=b4315ab2-3075-44b7-b91a-d08497771c14
RETENTION_DAYS=30
ROYALTY_IMAGE_PROVIDER=picsum
ROYALTY_IMAGE_MCP_ENDPOINT=
ROYALTY_IMAGE_API_KEY=
SUMMARY_LENGTH_SCALE=3

View File

@@ -4,3 +4,7 @@ OPENROUTER_API_KEY=
UMAMI_SCRIPT_URL=
UMAMI_WEBSITE_ID=
RETENTION_DAYS=30
ROYALTY_IMAGE_PROVIDER=picsum
ROYALTY_IMAGE_MCP_ENDPOINT=
ROYALTY_IMAGE_API_KEY=
SUMMARY_LENGTH_SCALE=3

130
README.md
View File

@@ -71,6 +71,66 @@ Frontend language selector behavior:
- Selected language is persisted in `localStorage` and mirrored in a client cookie.
- Returning users see content in their previously selected language.
## Summary Modal
Each fetched article now includes a concise summary artifact and can be opened in a modal from the feed.
Modal structure:
```text
[Relevant Image]
## TL;DR
[bullet points]
## Summary
[Summarized article]
## Source and Citation
[source of the news]
Powered by Perplexity
```
Backend behavior:
- Summary artifacts are generated during ingestion using Perplexity and persisted with article records.
- If summary generation fails, ingestion still succeeds and a fallback summary artifact is stored.
- Summary image enrichment prefers MCP image retrieval when configured, with deterministic fallback behavior.
Umami events for summary modal:
- `summary-modal-open` with `article_id`
- `summary-modal-close` with `article_id`
- `summary-modal-link-out` with `article_id` and `source_url`
## Policy Pages
The footer now includes:
- `Terms of Use` (`/terms`)
- `Attribution` (`/attribution`)
Both pages are served as static frontend documents through FastAPI routes.
## Theme Switcher
The header includes an icon-based theme switcher with four modes:
- `system` (default when unset)
- `light`
- `dark`
- `contrast` (high contrast)
Theme persistence behavior:
- Primary: `localStorage` (`clawfort_theme`)
- Fallback: cookie (`clawfort_theme`)
Returning users get their previously selected theme.
## Cookie Consent and Analytics Gate
Analytics loading is consent-gated:
- Consent banner appears when no consent is stored.
- Clicking `Accept` stores consent in localStorage and cookie (`clawfort_cookie_consent=accepted`).
- Umami analytics script loads only after consent.
## Environment Variables
| Variable | Required | Default | Description |
@@ -81,6 +141,10 @@ Frontend language selector behavior:
| `RETENTION_DAYS` | No | `30` | Days to keep news before archiving |
| `UMAMI_SCRIPT_URL` | No | — | Umami analytics script URL |
| `UMAMI_WEBSITE_ID` | No | — | Umami website tracking ID |
| `ROYALTY_IMAGE_PROVIDER` | No | `picsum` | Royalty-free image source (`picsum`, `wikimedia`, or MCP) |
| `ROYALTY_IMAGE_MCP_ENDPOINT` | No | — | MCP endpoint for image retrieval (preferred when set) |
| `ROYALTY_IMAGE_API_KEY` | No | — | Optional API key for image provider integrations |
| `SUMMARY_LENGTH_SCALE` | No | `3` | Summary detail level from `1` (short) to `5` (long) |
## Architecture
@@ -99,6 +163,72 @@ Frontend language selector behavior:
| `GET` | `/api/health` | Health check with news count |
| `GET` | `/config` | Frontend config (analytics) |
## SEO and Structured Data Contract
Homepage (`/`) contract:
- Core metadata: `title`, `description`, `robots`, canonical link.
- Social metadata: Open Graph (`og:type`, `og:site_name`, `og:title`, `og:description`, `og:url`, `og:image`) and Twitter (`twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`).
- JSON-LD graph includes:
- `Newspaper` entity for site-level identity.
- `NewsArticle` entities for hero and feed articles.
Policy page contract (`/terms`, `/attribution`):
- Page-specific `title` and `description`.
- `robots` metadata.
- Canonical link for the route.
- Open Graph and Twitter preview metadata.
Structured data field baseline for each `NewsArticle`:
- `headline`, `description`, `image`, `datePublished`, `dateModified`, `url`, `mainEntityOfPage`, `inLanguage`, `publisher`, `author`.
## Delivery Performance Header Contract
FastAPI middleware applies route-specific cache and compression behavior:
| Route Class | Cache-Control | Notes |
|-------------|---------------|-------|
| `/static/*` | `public, max-age=604800, immutable` | Long-lived static assets |
| `/api/*` | `public, max-age=60, stale-while-revalidate=120` | Short-lived feed data |
| `/`, `/terms`, `/attribution` | `public, max-age=300, stale-while-revalidate=600` | HTML routes |
Additional headers:
- `Vary: Accept-Encoding` for API responses.
- `X-Content-Type-Options: nosniff` for all responses.
- Gzip compression enabled via `GZipMiddleware` for eligible payloads.
## SEO and Performance Verification Checklist
Run after local startup (`python -m uvicorn backend.main:app --reload --port 8000`):
```bash
# HTML route cache checks
curl -I http://localhost:8000/
curl -I http://localhost:8000/terms
curl -I http://localhost:8000/attribution
# API cache + vary checks
curl -I "http://localhost:8000/api/news/latest?language=en"
curl -I "http://localhost:8000/api/news?limit=5&language=en"
# Compression check (expect Content-Encoding: gzip for eligible payloads)
curl -s -H "Accept-Encoding: gzip" -D - "http://localhost:8000/api/news?limit=10&language=en" -o /dev/null
```
Manual acceptance checks:
1. Homepage source contains one canonical link and Open Graph/Twitter metadata fields.
2. Homepage JSON-LD contains one `Newspaper` entity and deduplicated `NewsArticle` entries.
3. Hero/feed/modal images show shimmer placeholders until load/fallback completion.
4. Feed and modal images use `loading="lazy"`, explicit `width`/`height`, and `decoding="async"`.
5. Smooth scrolling behavior is enabled for in-page navigation interactions.
Structured-data validation:
- Validate JSON-LD output using schema-aware validators (e.g., Schema.org validator or equivalent tooling) and confirm `Newspaper` + `NewsArticle` entities pass required field checks.
Regression checks:
- Verify homepage rendering (hero, feed, modal).
- Verify policy-page metadata output.
- Verify cache/compression headers remain unchanged after SEO-related edits.
### GET /api/news
Query parameters:

View File

@@ -10,6 +10,12 @@ IMAGE_QUALITY = int(os.getenv("IMAGE_QUALITY", "85"))
RETENTION_DAYS = int(os.getenv("RETENTION_DAYS", "30"))
UMAMI_SCRIPT_URL = os.getenv("UMAMI_SCRIPT_URL", "")
UMAMI_WEBSITE_ID = os.getenv("UMAMI_WEBSITE_ID", "")
ROYALTY_IMAGE_MCP_ENDPOINT = os.getenv("ROYALTY_IMAGE_MCP_ENDPOINT", "")
ROYALTY_IMAGE_API_KEY = os.getenv("ROYALTY_IMAGE_API_KEY", "")
ROYALTY_IMAGE_PROVIDER = os.getenv("ROYALTY_IMAGE_PROVIDER", "picsum")
_summary_length_raw = int(os.getenv("SUMMARY_LENGTH_SCALE", "3"))
SUMMARY_LENGTH_SCALE = max(1, min(5, _summary_length_raw))
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
PERPLEXITY_MODEL = "sonar"

View File

@@ -1,6 +1,6 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
DATABASE_URL = "sqlite:///./data/clawfort.db"
@@ -25,3 +25,27 @@ def init_db() -> None:
from backend.models import NewsItem, NewsTranslation # noqa: F401
Base.metadata.create_all(bind=engine)
migration_sql = {
"news_items": {
"tldr_points": "ALTER TABLE news_items ADD COLUMN tldr_points TEXT",
"summary_body": "ALTER TABLE news_items ADD COLUMN summary_body TEXT",
"source_citation": "ALTER TABLE news_items ADD COLUMN source_citation TEXT",
"summary_image_url": "ALTER TABLE news_items ADD COLUMN summary_image_url VARCHAR(2000)",
"summary_image_credit": "ALTER TABLE news_items ADD COLUMN summary_image_credit VARCHAR(500)",
},
"news_translations": {
"tldr_points": "ALTER TABLE news_translations ADD COLUMN tldr_points TEXT",
"summary_body": "ALTER TABLE news_translations ADD COLUMN summary_body TEXT",
"source_citation": "ALTER TABLE news_translations ADD COLUMN source_citation TEXT",
},
}
with engine.begin() as conn:
for table, columns in migration_sql.items():
existing_cols = {
row[1] for row in conn.execute(text(f"PRAGMA table_info({table})")).fetchall()
}
for col_name, ddl in columns.items():
if col_name not in existing_cols:
conn.execute(text(ddl))

View File

@@ -2,8 +2,9 @@ import logging
import os
from apscheduler.schedulers.background import BackgroundScheduler
from fastapi import Depends, FastAPI, Query
from fastapi import Depends, FastAPI, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -20,6 +21,7 @@ from backend.repository import (
get_translation,
normalize_language,
resolve_news_content,
resolve_summary_fields,
)
from backend.schemas import HealthResponse, NewsItemResponse, PaginatedNewsResponse
@@ -39,6 +41,30 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=500)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith("/static/"):
response.headers.setdefault("Cache-Control", "public, max-age=604800, immutable")
elif path.startswith("/api/"):
response.headers.setdefault(
"Cache-Control", "public, max-age=60, stale-while-revalidate=120"
)
response.headers.setdefault("Vary", "Accept-Encoding")
elif path in {"/", "/terms", "/attribution"}:
response.headers.setdefault(
"Cache-Control", "public, max-age=300, stale-while-revalidate=600"
)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
return response
static_dir = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@@ -101,6 +127,7 @@ def api_get_news(
if lang != "en":
translation = get_translation(db, item.id, lang)
headline, summary = resolve_news_content(item, translation)
tldr_points, summary_body, source_citation = resolve_summary_fields(item, translation)
response_items.append(
NewsItemResponse(
id=item.id,
@@ -109,6 +136,11 @@ def api_get_news(
source_url=item.source_url,
image_url=item.image_url,
image_credit=item.image_credit,
tldr_points=tldr_points,
summary_body=summary_body,
source_citation=source_citation,
summary_image_url=item.summary_image_url,
summary_image_credit=item.summary_image_credit,
published_at=item.published_at,
created_at=item.created_at,
language=lang if translation is not None else "en",
@@ -136,6 +168,7 @@ def api_get_latest_news(
if lang != "en":
translation = get_translation(db, item.id, lang)
headline, summary = resolve_news_content(item, translation)
tldr_points, summary_body, source_citation = resolve_summary_fields(item, translation)
return NewsItemResponse(
id=item.id,
headline=headline,
@@ -143,6 +176,11 @@ def api_get_latest_news(
source_url=item.source_url,
image_url=item.image_url,
image_credit=item.image_credit,
tldr_points=tldr_points,
summary_body=summary_body,
source_citation=source_citation,
summary_image_url=item.summary_image_url,
summary_image_credit=item.summary_image_credit,
published_at=item.published_at,
created_at=item.created_at,
language=lang if translation is not None else "en",
@@ -163,6 +201,16 @@ async def serve_frontend() -> FileResponse:
return FileResponse(os.path.join(frontend_dir, "index.html"))
@app.get("/terms")
async def serve_terms() -> FileResponse:
return FileResponse(os.path.join(frontend_dir, "terms.html"))
@app.get("/attribution")
async def serve_attribution() -> FileResponse:
return FileResponse(os.path.join(frontend_dir, "attribution.html"))
@app.get("/config")
async def serve_config() -> dict:
return {

View File

@@ -12,6 +12,11 @@ class NewsItem(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
headline: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
tldr_points: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_body: Mapped[str | None] = mapped_column(Text, nullable=True)
source_citation: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_image_url: Mapped[str | None] = mapped_column(String(2000), nullable=True)
summary_image_credit: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_url: Mapped[str | None] = mapped_column(String(2000), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(2000), nullable=True)
image_credit: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -38,6 +43,9 @@ class NewsTranslation(Base):
language: Mapped[str] = mapped_column(String(5), nullable=False, index=True)
headline: Mapped[str] = mapped_column(String(500), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
tldr_points: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_body: Mapped[str | None] = mapped_column(Text, nullable=True)
source_citation: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, nullable=False, default=datetime.datetime.utcnow
)

View File

@@ -5,6 +5,7 @@ import logging
import os
import time
from io import BytesIO
from urllib.parse import quote_plus
import httpx
from PIL import Image
@@ -95,7 +96,12 @@ async def call_openrouter_api(query: str) -> dict | None:
async def call_perplexity_translation_api(
headline: str, summary: str, language: str
headline: str,
summary: str,
language: str,
tldr_points: list[str] | None = None,
summary_body: str | None = None,
source_citation: str | None = None,
) -> dict | None:
headers = {
"Authorization": f"Bearer {config.PERPLEXITY_API_KEY}",
@@ -119,6 +125,9 @@ async def call_perplexity_translation_api(
"target_language": language,
"headline": headline,
"summary": summary,
"tldr_points": tldr_points or [],
"summary_body": summary_body or "",
"source_citation": source_citation or "",
}
),
},
@@ -144,13 +153,31 @@ def parse_translation_response(response: dict) -> dict | None:
headline = str(parsed.get("headline", "")).strip()
summary = str(parsed.get("summary", "")).strip()
if headline and summary:
return {"headline": headline, "summary": summary}
tldr_points = parsed.get("tldr_points", [])
if not isinstance(tldr_points, list):
tldr_points = []
cleaned_points = [str(p).strip() for p in tldr_points if str(p).strip()]
summary_body = str(parsed.get("summary_body", "")).strip() or None
source_citation = str(parsed.get("source_citation", "")).strip() or None
return {
"headline": headline,
"summary": summary,
"tldr_points": cleaned_points,
"summary_body": summary_body,
"source_citation": source_citation,
}
except json.JSONDecodeError:
logger.error("Failed to parse translation response: %s", content[:200])
return None
async def generate_translations(headline: str, summary: str) -> dict[str, dict]:
async def generate_translations(
headline: str,
summary: str,
tldr_points: list[str] | None = None,
summary_body: str | None = None,
source_citation: str | None = None,
) -> dict[str, dict]:
translations: dict[str, dict] = {}
language_names = {"ta": "Tamil", "ml": "Malayalam"}
@@ -159,7 +186,14 @@ async def generate_translations(headline: str, summary: str) -> dict[str, dict]:
for language_code, language_name in language_names.items():
try:
response = await call_perplexity_translation_api(headline, summary, language_name)
response = await call_perplexity_translation_api(
headline=headline,
summary=summary,
language=language_name,
tldr_points=tldr_points,
summary_body=summary_body,
source_citation=source_citation,
)
if response:
parsed = parse_translation_response(response)
if parsed:
@@ -170,6 +204,146 @@ async def generate_translations(headline: str, summary: str) -> dict[str, dict]:
return translations
async def call_perplexity_summary_api(
headline: str, summary: str, source_url: str | None
) -> dict | None:
headers = {
"Authorization": f"Bearer {config.PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": config.PERPLEXITY_MODEL,
"messages": [
{
"role": "system",
"content": (
"Generate concise structured JSON for UI modal. Return only JSON object with keys: "
"tldr_points (array of 3 short bullets), summary_body (detailed summary), "
"source_citation (concise source/citation text). "
"Always summarize from provided text only. No markdown."
),
},
{
"role": "user",
"content": json.dumps(
{
"headline": headline,
"summary": summary,
"source_citation": source_url or "Original source",
"summary_length_scale": config.SUMMARY_LENGTH_SCALE,
"summary_length_rule": (
"1=very short, 2=short, 3=medium, 4=long, 5=very long. "
"Use more detail as scale increases."
),
}
),
},
],
"temperature": 0.2,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(config.PERPLEXITY_API_URL, headers=headers, json=payload)
response.raise_for_status()
return response.json()
def parse_summary_response(response: dict) -> dict | None:
content = response.get("choices", [{}])[0].get("message", {}).get("content", "")
content = content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[-1].rsplit("```", 1)[0]
try:
parsed = json.loads(content)
except json.JSONDecodeError:
logger.error("Failed to parse summary response: %s", content[:200])
return None
if not isinstance(parsed, dict):
return None
tldr_points = parsed.get("tldr_points", [])
if not isinstance(tldr_points, list):
tldr_points = []
cleaned_points = [str(point).strip() for point in tldr_points if str(point).strip()]
summary_body = str(parsed.get("summary_body", "")).strip()
source_citation = str(parsed.get("source_citation", "")).strip()
if not cleaned_points and not summary_body:
return None
return {
"tldr_points": cleaned_points[:5],
"summary_body": summary_body or None,
"source_citation": source_citation or None,
}
def build_fallback_summary(summary: str, source_url: str | None) -> dict:
segments = [
s.strip() for s in summary.replace("!", ".").replace("?", ".").split(".") if s.strip()
]
points = segments[:3]
if not points and summary.strip():
points = [summary.strip()[:180]]
return {
"tldr_points": points,
"summary_body": summary,
"source_citation": source_url or "Original source",
}
async def fetch_royalty_free_image(query: str) -> tuple[str | None, str | None]:
if config.ROYALTY_IMAGE_MCP_ENDPOINT:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
config.ROYALTY_IMAGE_MCP_ENDPOINT,
json={"query": query},
)
response.raise_for_status()
payload = response.json()
image_url = payload.get("image_url") or payload.get("url")
image_credit = payload.get("image_credit") or payload.get("credit")
if image_url:
return str(image_url), str(image_credit or "Royalty-free")
except Exception:
logger.exception("MCP image retrieval failed")
if config.ROYALTY_IMAGE_PROVIDER.lower() == "wikimedia":
try:
encoded_query = quote_plus(query[:120])
search_url = (
"https://commons.wikimedia.org/w/api.php"
"?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=1"
f"&gsrsearch={encoded_query}&prop=imageinfo&iiprop=url"
)
async with httpx.AsyncClient(
timeout=15.0,
headers={"User-Agent": "ClawFortBot/1.0 (news image enrichment)"},
) as client:
response = await client.get(search_url)
response.raise_for_status()
data = response.json()
pages = data.get("query", {}).get("pages", {})
if pages:
first_page = next(iter(pages.values()))
infos = first_page.get("imageinfo", [])
if infos:
url = infos[0].get("url")
if url:
return str(url), "Wikimedia Commons"
except Exception:
logger.exception("Wikimedia image retrieval failed")
if config.ROYALTY_IMAGE_PROVIDER.lower() == "picsum":
seed = hashlib.md5(query.encode("utf-8")).hexdigest()[:12]
return f"https://picsum.photos/seed/{seed}/1200/630", "Picsum Photos"
return None, None
def parse_news_response(response: dict) -> list[dict]:
content = response.get("choices", [{}])[0].get("message", {}).get("content", "")
content = content.strip()
@@ -261,6 +435,37 @@ async def process_and_store_news() -> int:
local_image = await download_and_optimize_image(item.get("image_url", ""))
image_url = local_image or PLACEHOLDER_IMAGE_PATH
summary_artifact: dict | None = None
if config.PERPLEXITY_API_KEY:
try:
summary_response = await call_perplexity_summary_api(
headline=headline,
summary=summary,
source_url=item.get("source_url"),
)
if summary_response:
summary_artifact = parse_summary_response(summary_response)
except Exception:
logger.exception("Summary generation failed for article: %s", headline[:80])
if summary_artifact is None:
summary_artifact = build_fallback_summary(summary, item.get("source_url"))
summary_image_url, summary_image_credit = await fetch_royalty_free_image(headline)
summary_local_image = None
if summary_image_url:
summary_local_image = await download_and_optimize_image(summary_image_url)
if summary_local_image:
summary_image_url = summary_local_image
if not summary_image_url:
summary_image_url = image_url
if not summary_image_credit:
summary_image_credit = item.get("image_credit")
tldr_points = summary_artifact.get("tldr_points") if summary_artifact else None
summary_body = summary_artifact.get("summary_body") if summary_artifact else None
source_citation = summary_artifact.get("source_citation") if summary_artifact else None
created_news_item = create_news(
db=db,
headline=headline,
@@ -268,9 +473,20 @@ async def process_and_store_news() -> int:
source_url=item.get("source_url"),
image_url=image_url,
image_credit=item.get("image_credit"),
tldr_points=tldr_points,
summary_body=summary_body,
source_citation=source_citation,
summary_image_url=summary_image_url,
summary_image_credit=summary_image_credit,
)
translations = await generate_translations(headline, summary)
translations = await generate_translations(
headline=headline,
summary=summary,
tldr_points=tldr_points,
summary_body=summary_body,
source_citation=source_citation,
)
for language_code, payload in translations.items():
if translation_exists(db, created_news_item.id, language_code):
continue
@@ -280,6 +496,9 @@ async def process_and_store_news() -> int:
language=language_code,
headline=payload["headline"],
summary=payload["summary"],
tldr_points=payload.get("tldr_points"),
summary_body=payload.get("summary_body"),
source_citation=payload.get("source_citation"),
)
stored += 1

View File

@@ -1,4 +1,5 @@
import datetime
import json
from sqlalchemy import and_, desc
from sqlalchemy.orm import Session
@@ -15,6 +16,11 @@ def create_news(
source_url: str | None = None,
image_url: str | None = None,
image_credit: str | None = None,
tldr_points: list[str] | None = None,
summary_body: str | None = None,
source_citation: str | None = None,
summary_image_url: str | None = None,
summary_image_credit: str | None = None,
published_at: datetime.datetime | None = None,
) -> NewsItem:
item = NewsItem(
@@ -23,6 +29,11 @@ def create_news(
source_url=source_url,
image_url=image_url,
image_credit=image_credit,
tldr_points=json.dumps(tldr_points) if tldr_points else None,
summary_body=summary_body,
source_citation=source_citation,
summary_image_url=summary_image_url,
summary_image_credit=summary_image_credit,
published_at=published_at or datetime.datetime.utcnow(),
)
db.add(item)
@@ -56,12 +67,18 @@ def create_translation(
language: str,
headline: str,
summary: str,
tldr_points: list[str] | None = None,
summary_body: str | None = None,
source_citation: str | None = None,
) -> NewsTranslation:
translation = NewsTranslation(
news_item_id=news_item_id,
language=language,
headline=headline,
summary=summary,
tldr_points=json.dumps(tldr_points) if tldr_points else None,
summary_body=summary_body,
source_citation=source_citation,
)
db.add(translation)
db.commit()
@@ -101,6 +118,28 @@ def resolve_news_content(item: NewsItem, translation: NewsTranslation | None) ->
return translation.headline, translation.summary
def resolve_tldr_points(item: NewsItem, translation: NewsTranslation | None) -> list[str] | None:
raw = translation.tldr_points if translation is not None else item.tldr_points
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
if isinstance(parsed, list):
return [str(x) for x in parsed if str(x).strip()]
return None
def resolve_summary_fields(
item: NewsItem, translation: NewsTranslation | None
) -> tuple[list[str] | None, str | None, str | None]:
tldr_points = resolve_tldr_points(item, translation)
if translation is None:
return tldr_points, item.summary_body, item.source_citation
return tldr_points, translation.summary_body, translation.source_citation
def normalize_language(language: str | None) -> str:
if not language:
return "en"

View File

@@ -10,6 +10,11 @@ class NewsItemResponse(BaseModel):
source_url: str | None = None
image_url: str | None = None
image_credit: str | None = None
tldr_points: list[str] | None = None
summary_body: str | None = None
source_citation: str | None = None
summary_image_url: str | None = None
summary_image_credit: str | None = None
published_at: datetime.datetime
created_at: datetime.datetime
language: str
@@ -29,6 +34,9 @@ class NewsTranslationResponse(BaseModel):
language: str
headline: str
summary: str
tldr_points: list[str] | None = None
summary_body: str | None = None
source_citation: str | None = None
created_at: datetime.datetime
model_config = {"from_attributes": True}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

47
frontend/attribution.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Attribution and ownership disclaimer for ClawFort AI News content.">
<meta name="robots" content="index,follow">
<meta property="og:type" content="article">
<meta property="og:title" content="Attribution and Ownership Disclaimer - ClawFort">
<meta property="og:description" content="Understand content attribution and ownership boundaries for ClawFort AI-generated summaries.">
<meta property="og:url" content="/attribution">
<meta property="og:image" content="/static/images/placeholder.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Attribution and Ownership Disclaimer - ClawFort">
<meta name="twitter:description" content="Understand content attribution and ownership boundaries for ClawFort AI-generated summaries.">
<meta name="twitter:image" content="/static/images/placeholder.png">
<link rel="canonical" href="/attribution">
<title>Attribution and Ownership Disclaimer - ClawFort</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 font-[Inter] min-h-screen">
<main class="max-w-3xl mx-auto px-4 py-12">
<a href="/" class="text-blue-400 hover:text-blue-300">&larr; Back to ClawFort</a>
<h1 class="text-3xl sm:text-4xl font-extrabold mt-6 mb-4">Attribution and Ownership Disclaimer</h1>
<div class="space-y-4 text-slate-300 leading-relaxed">
<p>
None of the content presented on this site is created by the owner as an individual person.
</p>
<p>
The content is AI-generated and automatically assembled from external sources and generated summaries.
</p>
<p>
The owner does not claim personal authorship, editorial ownership, or direct responsibility for
generated statements beyond operating the site interface.
</p>
</div>
</main>
<footer class="border-t border-white/10 py-6 text-center text-sm text-slate-400">
<a href="/" class="hover:text-white">Home</a>
<span class="mx-2">|</span>
<a href="/terms" class="hover:text-white">Terms of Use</a>
</footer>
</body>
</html>

View File

@@ -3,6 +3,23 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="ClawFort brings you the latest AI news with hourly updates, concise summaries, and source links.">
<meta name="robots" content="index,follow,max-image-preview:large">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="theme-color" content="#0f172a">
<meta property="og:type" content="website">
<meta property="og:site_name" content="ClawFort">
<meta property="og:title" content="ClawFort - AI News">
<meta property="og:description" content="The latest AI news, refreshed hourly with concise summaries and source attribution.">
<meta property="og:url" content="/" id="meta-og-url">
<meta property="og:image" content="/static/images/placeholder.png" id="meta-og-image">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ClawFort - AI News">
<meta name="twitter:description" content="The latest AI news, refreshed hourly with concise summaries and source attribution.">
<meta name="twitter:image" content="/static/images/placeholder.png" id="meta-twitter-image">
<link rel="canonical" href="/" id="canonical-link">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//fonts.gstatic.com">
<title>ClawFort — AI News</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -25,7 +42,9 @@
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script id="structured-data-graph" type="application/ld+json">{"@context":"https://schema.org","@graph":[]}</script>
<style>
html { scroll-behavior: smooth; }
[x-cloak] { display: none !important; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.fade-up { animation: fadeUp 0.6s ease-out forwards; }
@@ -33,11 +52,148 @@
.skeleton { background: linear-gradient(90deg, #1e293b 25%, #334155 50%, #1e293b 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
:root {
--cf-bg: #0f172a;
--cf-text: #f1f5f9;
--cf-text-strong: #e2e8f0;
--cf-text-muted: #94a3b8;
--cf-header-bg: rgba(15, 23, 42, 0.85);
--cf-card-bg: #1e293b;
--cf-modal-bg: #0f172a;
--cf-select-bg: #1e293b;
--cf-select-text: #e2e8f0;
--cf-select-border: rgba(148, 163, 184, 0.3);
}
html[data-theme='light'] {
--cf-bg: #f8fafc;
--cf-text: #0f172a;
--cf-text-strong: #0f172a;
--cf-text-muted: #475569;
--cf-header-bg: rgba(248, 250, 252, 0.92);
--cf-card-bg: #ffffff;
--cf-modal-bg: #ffffff;
--cf-select-bg: #ffffff;
--cf-select-text: #0f172a;
--cf-select-border: rgba(15, 23, 42, 0.2);
}
html[data-theme='dark'] {
--cf-bg: #0f172a;
--cf-text: #f1f5f9;
--cf-text-strong: #e2e8f0;
--cf-text-muted: #94a3b8;
--cf-header-bg: rgba(15, 23, 42, 0.85);
--cf-card-bg: #1e293b;
--cf-modal-bg: #0f172a;
--cf-select-bg: #1e293b;
--cf-select-text: #e2e8f0;
--cf-select-border: rgba(148, 163, 184, 0.3);
}
html[data-theme='contrast'] {
--cf-bg: #000000;
--cf-text: #ffffff;
--cf-text-strong: #ffffff;
--cf-text-muted: #f8fafc;
--cf-header-bg: rgba(0, 0, 0, 0.96);
--cf-card-bg: #000000;
--cf-modal-bg: #000000;
--cf-select-bg: #000000;
--cf-select-text: #ffffff;
--cf-select-border: rgba(255, 255, 255, 0.55);
}
.cf-body { background: var(--cf-bg); color: var(--cf-text); }
.cf-header { background: var(--cf-header-bg); }
.cf-card { background: var(--cf-card-bg) !important; }
.cf-modal { background: var(--cf-modal-bg); }
.cf-select {
background: var(--cf-select-bg);
color: var(--cf-select-text);
border: 1px solid var(--cf-select-border);
}
.theme-menu-panel {
background: var(--cf-card-bg);
border-color: var(--cf-select-border);
}
.theme-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
padding: 6px 8px;
color: var(--cf-text-strong);
text-align: left;
font-size: 13px;
}
.theme-menu-item:hover { background: rgba(92, 124, 250, 0.15); }
.hero-overlay {
background: linear-gradient(to top, rgba(15, 23, 42, 0.92), rgba(15, 23, 42, 0.45), 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); }
.hero-meta { color: #cbd5e1; }
.news-card-title { color: var(--cf-text-strong); }
.news-card-summary { color: var(--cf-text-muted); }
.news-card-meta { color: var(--cf-text-muted); }
.news-card-btn { color: #dbe4ff; }
.modal-article-title { color: var(--cf-text-strong); }
.modal-section-title { color: #5c7cfa; }
.modal-body-text { color: var(--cf-text-strong); }
.modal-citation { color: var(--cf-text-muted); }
.modal-powered { color: var(--cf-text-muted); }
.modal-close-btn { color: var(--cf-text-muted); }
.modal-close-btn:hover { color: var(--cf-text-strong); }
.modal-cta {
background: rgba(92, 124, 250, 0.18);
color: #dbe4ff;
}
.modal-cta:hover { background: rgba(92, 124, 250, 0.28); }
html[data-theme='light'] .news-card {
border-color: rgba(15, 23, 42, 0.14) !important;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
html[data-theme='light'] .hero-title,
html[data-theme='light'] .hero-summary,
html[data-theme='light'] .hero-meta {
color: #f8fafc;
}
html[data-theme='light'] .hero-overlay {
background: linear-gradient(to top, rgba(15, 23, 42, 0.9), rgba(15, 23, 42, 0.35), transparent);
}
html[data-theme='light'] .news-card-btn { color: #1e3a8a; }
html[data-theme='light'] .modal-cta {
color: #1e3a8a;
background: rgba(59, 91, 219, 0.14);
}
html[data-theme='light'] .modal-cta:hover { background: rgba(59, 91, 219, 0.22); }
html[data-theme='light'] .text-gray-500,
html[data-theme='light'] .text-gray-400 { color: #475569 !important; }
.theme-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--cf-select-border);
background: var(--cf-select-bg);
color: var(--cf-select-text);
font-size: 14px;
line-height: 1;
}
.theme-btn[data-active='true'] {
border-color: #5c7cfa;
box-shadow: 0 0 0 1px #5c7cfa inset;
}
*:focus-visible {
outline: 2px solid #5c7cfa;
outline-offset: 2px;
}
@media (max-width: 640px) {
.theme-btn { width: 26px; height: 26px; }
}
</style>
</head>
<body class="bg-cf-950 text-gray-100 font-sans min-h-screen">
<body class="font-sans min-h-screen overflow-x-hidden cf-body">
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 bg-cf-500 text-white px-3 py-2 rounded">Skip to content</a>
<header class="sticky top-0 z-50 bg-cf-950/80 backdrop-blur-lg border-b border-white/5">
<header class="sticky top-0 z-50 backdrop-blur-lg border-b border-white/5 cf-header">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<a href="/" class="flex items-center gap-2.5 group">
<svg class="w-8 h-8 text-cf-500 group-hover:text-cf-400 transition-colors" viewBox="0 0 32 32" fill="none">
@@ -49,18 +205,27 @@
<div class="flex items-center gap-3">
<label for="language-select" class="text-xs text-gray-400 hidden sm:block">Language</label>
<select id="language-select"
class="bg-[#1e293b] border border-white/10 text-gray-200 text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-cf-500"
class="cf-select text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-cf-500"
onchange="setPreferredLanguage(this.value)">
<option value="en">English</option>
<option value="ta">Tamil</option>
<option value="ml">Malayalam</option>
</select>
<div class="relative" id="theme-menu-root">
<button id="theme-menu-button" type="button" class="theme-btn" aria-haspopup="menu" aria-expanded="false" aria-label="Open theme menu" title="Theme menu" onclick="toggleThemeMenu()">&#9788;</button>
<div id="theme-menu" class="hidden absolute right-0 mt-2 w-44 rounded-lg border theme-menu-panel p-2 z-50" role="menu" aria-label="Theme options">
<button type="button" data-theme-option="system" onclick="setThemePreference('system'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use system theme">&#128421; <span>System</span></button>
<button type="button" data-theme-option="light" onclick="setThemePreference('light'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use light theme">&#9728; <span>Light</span></button>
<button type="button" data-theme-option="dark" onclick="setThemePreference('dark'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use dark theme">&#9790; <span>Dark</span></button>
<button type="button" data-theme-option="contrast" onclick="setThemePreference('contrast'); closeThemeMenu();" class="theme-menu-item" role="menuitem" aria-label="Use high contrast theme">&#9681; <span>High Contrast</span></button>
</div>
</div>
<span class="text-xs text-gray-500 hidden lg:block">AI News — Updated Hourly</span>
</div>
</div>
</header>
<main>
<main id="main-content">
<section x-data="heroBlock()" x-init="init()" class="relative">
<template x-if="loading">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
@@ -73,21 +238,35 @@
</div>
</template>
<template x-if="!loading && item">
<div class="fade-up">
<article class="fade-up" itemscope itemtype="https://schema.org/NewsArticle" :id="'news-' + item.id">
<meta itemprop="headline" :content="item.headline">
<meta itemprop="description" :content="item.summary">
<meta itemprop="datePublished" :content="item.published_at">
<meta itemprop="dateModified" :content="item.created_at || item.published_at">
<meta itemprop="image" :content="toAbsoluteUrl(preferredImage(item))">
<meta itemprop="url" :content="articlePermalink(item)">
<meta itemprop="inLanguage" :content="item.language || 'en'">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div class="relative rounded-2xl overflow-hidden group">
<img :src="item.image_url" :alt="item.headline"
<div x-show="heroImageLoading" class="absolute inset-0 skeleton"></div>
<img :src="preferredImage(item)" :alt="item.headline"
width="1200" height="480" decoding="async" fetchpriority="high"
class="w-full h-[300px] sm:h-[400px] lg:h-[480px] object-cover transition-transform duration-700 group-hover:scale-105"
@error="$el.src='/static/images/placeholder.png'">
<div class="absolute inset-0 bg-gradient-to-t from-cf-950 via-cf-950/40 to-transparent"></div>
@load="heroImageLoading = false"
@error="$el.src='/static/images/placeholder.png'; heroImageLoading = false">
<div class="absolute inset-0 hero-overlay"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 sm:p-10">
<div class="flex items-center gap-3 mb-3">
<span class="px-2.5 py-1 bg-cf-500/20 text-cf-400 text-xs font-semibold rounded-full border border-cf-500/30">LATEST</span>
<span class="text-gray-400 text-sm" x-text="timeAgo(item.published_at)"></span>
</div>
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-extrabold leading-tight mb-3 max-w-4xl" x-text="item.headline"></h1>
<p class="text-gray-300 text-base sm:text-lg max-w-3xl line-clamp-3 mb-4" x-text="item.summary"></p>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-extrabold leading-tight mb-3 max-w-4xl hero-title" x-text="item.headline"></h1>
<p class="text-base sm:text-lg max-w-3xl line-clamp-3 mb-4 hero-summary" x-text="item.summary"></p>
<div class="flex flex-wrap items-center gap-4 text-sm hero-meta">
<button class="px-3 py-1.5 rounded-md bg-cf-500/20 text-cf-300 hover:bg-cf-500/30 transition-colors"
@click="trackEvent('hero-cta-click', { article_id: item.id }); window.open(item.source_url || '#', '_blank')">
Read Full Article
</button>
<a :href="item.source_url" target="_blank" rel="noopener"
class="hover:text-cf-400 transition-colors"
@click="trackEvent('hero-source-click')"
@@ -99,7 +278,7 @@
</div>
</div>
</div>
</div>
</article>
</template>
<template x-if="!loading && !item">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
@@ -132,18 +311,27 @@
<div x-show="!initialLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="item in items" :key="item.id">
<article class="bg-[#1e293b] rounded-xl overflow-hidden border border-white/5 hover:border-cf-500/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-cf-500/5 group cursor-pointer"
@click="window.open(item.source_url || '#', '_blank'); trackEvent('card-click')">
<article class="rounded-xl overflow-hidden border border-white/5 hover:border-cf-500/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-cf-500/5 group cf-card news-card" itemscope itemtype="https://schema.org/NewsArticle" :id="'news-' + item.id">
<meta itemprop="headline" :content="item.headline">
<meta itemprop="description" :content="item.summary">
<meta itemprop="datePublished" :content="item.published_at">
<meta itemprop="dateModified" :content="item.created_at || item.published_at">
<meta itemprop="image" :content="toAbsoluteUrl(preferredImage(item))">
<meta itemprop="url" :content="articlePermalink(item)">
<meta itemprop="inLanguage" :content="item.language || 'en'">
<div class="relative h-48 overflow-hidden">
<img :src="item.image_url" :alt="item.headline" loading="lazy"
<div x-show="isImageLoading(item.id)" class="absolute inset-0 skeleton"></div>
<img :src="preferredImage(item)" :alt="item.headline" loading="lazy"
width="640" height="384" decoding="async" fetchpriority="low"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="$el.src='/static/images/placeholder.png'">
@load="markImageLoaded(item.id)"
@error="handleImageError(item.id, $el)">
<div class="absolute inset-0 bg-gradient-to-t from-[#1e293b] to-transparent opacity-60"></div>
</div>
<div class="p-5">
<h3 class="font-bold text-base mb-2 line-clamp-2 group-hover:text-cf-400 transition-colors" x-text="item.headline"></h3>
<p class="text-gray-400 text-sm line-clamp-2 mb-3" x-text="item.summary"></p>
<div class="flex items-center justify-between text-xs text-gray-500">
<h3 class="font-bold text-base mb-2 line-clamp-2 group-hover:text-cf-400 transition-colors news-card-title" x-text="item.headline"></h3>
<p class="text-sm line-clamp-2 mb-3 news-card-summary" x-text="item.summary"></p>
<div class="flex items-center justify-between text-xs mb-3 news-card-meta">
<a :href="item.source_url" target="_blank" rel="noopener"
class="hover:text-cf-400 transition-colors truncate max-w-[60%]"
@click.stop="trackEvent('source-link-click')"
@@ -151,11 +339,64 @@
x-text="extractDomain(item.source_url)"></a>
<span x-text="timeAgo(item.published_at)"></span>
</div>
<button @click="openSummary(item)"
class="w-full text-center text-xs font-semibold rounded-md px-3 py-2 bg-cf-500/15 hover:bg-cf-500/25 transition-colors news-card-btn">
Read TL;DR
</button>
</div>
</article>
</template>
</div>
<div x-show="modalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center" @keydown.escape.window="closeSummary()">
<div class="absolute inset-0 bg-black/70" @click="closeSummary()"></div>
<div role="dialog" aria-modal="true" aria-label="Article summary" class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-auto rounded-xl border border-white/10 bg-[#0f172a]">
<div class="p-6 space-y-5 cf-modal" x-show="modalItem">
<div class="flex justify-end">
<button @click="closeSummary()" aria-label="Close summary modal" class="transition-colors modal-close-btn">Close</button>
</div>
<div class="relative">
<div x-show="modalImageLoading" class="absolute inset-0 skeleton rounded-lg"></div>
<img :src="modalItem?.summary_image_url || modalItem?.image_url || '/static/images/placeholder.png'"
:alt="modalItem?.headline || 'Summary image'"
loading="lazy" decoding="async" width="1024" height="576"
class="w-full h-56 sm:h-72 object-cover rounded-lg"
@load="modalImageLoading = false"
@error="$el.src='/static/images/placeholder.png'; modalImageLoading = false">
</div>
<h2 class="text-xl sm:text-2xl font-bold leading-tight modal-article-title" x-text="modalItem?.headline || ''"></h2>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">TL;DR</h3>
<ul class="list-disc pl-5 space-y-1 text-sm modal-body-text" x-show="modalItem?.tldr_points && modalItem.tldr_points.length > 0">
<template x-for="(point, idx) in (modalItem?.tldr_points || [])" :key="idx">
<li x-text="point"></li>
</template>
</ul>
</div>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">Summary</h3>
<p class="text-sm leading-relaxed modal-body-text" x-text="modalItem?.summary_body || modalItem?.summary || ''"></p>
</div>
<div>
<h3 class="text-sm uppercase tracking-wide font-semibold mb-2 modal-section-title">Source and Citation</h3>
<p class="text-xs mb-3 modal-citation" x-text="modalItem?.source_citation || 'Original source' "></p>
<a :href="modalItem?.source_url || '#'" target="_blank" rel="noopener"
class="inline-block px-3 py-2 rounded-md text-sm font-semibold modal-cta"
@click="trackSummarySource(modalItem)">
Read Full Article
</a>
</div>
<p class="text-xs modal-powered">Powered by Perplexity</p>
</div>
</div>
</div>
<div x-show="loadingMore" class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-cf-500/30 border-t-cf-500 rounded-full animate-spin"></div>
</div>
@@ -176,10 +417,25 @@
<footer class="border-t border-white/5 py-8 text-center text-sm text-gray-500">
<div class="max-w-7xl mx-auto px-4 space-y-2">
<p>Powered by <a href="https://www.perplexity.ai" target="_blank" rel="noopener" class="text-cf-400 hover:text-cf-300 transition-colors">Perplexity</a></p>
<p class="space-x-3">
<a href="/terms" class="underline hover:text-gray-300">Terms of Use</a>
<a href="/attribution" class="underline hover:text-gray-300">Attribution</a>
</p>
<p>&copy; <span x-data x-text="new Date().getFullYear()"></span> ClawFort. All rights reserved.</p>
</div>
</footer>
<div id="cookie-consent-banner" class="hidden fixed bottom-4 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50 rounded-lg border border-white/15 bg-slate-900/95 backdrop-blur p-4 transition-opacity duration-700 opacity-100">
<p class="text-sm text-slate-200 mb-3">
We use analytics cookies to improve the product experience. By choosing "Accept", you consent to analytics tracking.
</p>
<div class="flex justify-end">
<button id="cookie-consent-accept" type="button" class="px-4 py-2 rounded bg-cf-500 hover:bg-cf-600 text-white font-semibold" aria-label="Accept analytics cookies">
Accept
</button>
</div>
</div>
<script>
function extractDomain(url) {
if (!url) return '';
@@ -200,6 +456,143 @@ function timeAgo(dateStr) {
return 'just now';
}
function preferredImage(item) {
const primary = item?.image_url;
const summary = item?.summary_image_url;
if (primary && primary !== '/static/images/placeholder.png') return primary;
if (summary) return summary;
return '/static/images/placeholder.png';
}
function toAbsoluteUrl(url) {
if (!url) return `${window.location.origin}/`;
try {
return new URL(url, window.location.origin).toString();
} catch {
return `${window.location.origin}/`;
}
}
function articlePermalink(item) {
if (!item?.id) return toAbsoluteUrl('/');
return toAbsoluteUrl(`/#news-${item.id}`);
}
function setMetaContent(selector, content, attribute = 'content') {
if (!content) return;
const node = document.querySelector(selector);
if (node) node.setAttribute(attribute, content);
}
function syncSeoMeta(item) {
const canonical = toAbsoluteUrl(window.location.pathname || '/');
setMetaContent('#canonical-link', canonical, 'href');
setMetaContent('#meta-og-url', canonical);
if (!item) return;
const title = `${item.headline} | ClawFort`;
const description = item.summary || 'Latest AI news updates from ClawFort.';
const image = toAbsoluteUrl(preferredImage(item));
document.title = title;
setMetaContent('meta[name="description"]', description);
setMetaContent('meta[property="og:title"]', title);
setMetaContent('meta[property="og:description"]', description);
setMetaContent('#meta-og-image', image);
setMetaContent('meta[name="twitter:title"]', title);
setMetaContent('meta[name="twitter:description"]', description);
setMetaContent('#meta-twitter-image', image);
}
function buildNewsArticleSchema(item) {
if (!item?.headline) return null;
return {
'@type': 'NewsArticle',
headline: item.headline,
description: item.summary || '',
datePublished: item.published_at,
dateModified: item.created_at || item.published_at,
inLanguage: item.language || window._selectedLanguage || 'en',
url: articlePermalink(item),
mainEntityOfPage: articlePermalink(item),
image: [toAbsoluteUrl(preferredImage(item))],
author: { '@type': 'Organization', name: 'ClawFort AI Desk' },
publisher: {
'@type': 'Organization',
name: 'ClawFort',
logo: {
'@type': 'ImageObject',
url: toAbsoluteUrl('/static/images/placeholder.png')
}
},
articleSection: 'AI News',
isBasedOn: item.source_url ? toAbsoluteUrl(item.source_url) : undefined,
};
}
function syncStructuredData() {
const schemaNode = document.getElementById('structured-data-graph');
if (!schemaNode) return;
const heroItem = window.__heroNewsItem || null;
const feedItems = Array.isArray(window.__feedNewsItems) ? window.__feedNewsItems : [];
const articleSchemas = [];
if (heroItem) {
const heroSchema = buildNewsArticleSchema(heroItem);
if (heroSchema) articleSchemas.push(heroSchema);
}
for (const item of feedItems) {
const schema = buildNewsArticleSchema(item);
if (schema) articleSchemas.push(schema);
}
const deduped = [];
const seen = new Set();
for (const schema of articleSchemas) {
const key = schema.url || schema.headline;
if (!seen.has(key)) {
seen.add(key);
deduped.push(schema);
}
}
const payload = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Newspaper',
name: 'ClawFort',
url: toAbsoluteUrl('/'),
inLanguage: window._selectedLanguage || 'en',
description: 'ClawFort AI News - hourly AI news updates with source attribution and concise summaries.',
publisher: {
'@type': 'Organization',
name: 'ClawFort',
logo: {
'@type': 'ImageObject',
url: toAbsoluteUrl('/static/images/placeholder.png')
}
}
},
...deduped,
],
};
schemaNode.textContent = JSON.stringify(payload);
}
function toBulletPoints(text) {
if (!text) return [];
const clean = String(text).replace(/\s+/g, ' ').trim();
if (!clean) return [];
const parts = clean.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
if (parts.length > 1) return parts;
const chunks = clean.split(/[,;:]\s+/).map(s => s.trim()).filter(Boolean);
return chunks.length > 1 ? chunks : [clean];
}
function trackEvent(name, data) {
if (window.umami) window.umami.track(name, data);
}
@@ -213,6 +606,93 @@ function readCookie(name) {
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, maxAgeSeconds = 31536000) {
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax`;
}
function safeSetStorage(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
function safeGetStorage(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function getPreferredTheme() {
const stored = safeGetStorage('clawfort_theme');
if (stored) return stored;
return readCookie('clawfort_theme') || 'system';
}
function getResolvedTheme(themeChoice) {
if (themeChoice === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (themeChoice === 'contrast') return 'contrast';
return themeChoice;
}
function updateThemeButtons(themeChoice) {
document.querySelectorAll('[data-theme-option]').forEach(btn => {
btn.setAttribute('data-active', String(btn.getAttribute('data-theme-option') === themeChoice));
});
}
function applyTheme(themeChoice) {
const resolved = getResolvedTheme(themeChoice);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-choice', themeChoice);
updateThemeButtons(themeChoice);
}
function setThemePreference(themeChoice) {
safeSetStorage('clawfort_theme', themeChoice);
setCookie('clawfort_theme', themeChoice);
applyTheme(themeChoice);
}
function toggleThemeMenu() {
const menu = document.getElementById('theme-menu');
const button = document.getElementById('theme-menu-button');
if (!menu || !button) return;
const isHidden = menu.classList.contains('hidden');
if (isHidden) {
menu.classList.remove('hidden');
button.setAttribute('aria-expanded', 'true');
} else {
menu.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
}
}
function closeThemeMenu() {
const menu = document.getElementById('theme-menu');
const button = document.getElementById('theme-menu-button');
if (!menu || !button) return;
menu.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
}
function hasCookieConsent() {
const local = safeGetStorage('clawfort_cookie_consent');
if (local) return local === 'accepted';
return readCookie('clawfort_cookie_consent') === 'accepted';
}
function setCookieConsentAccepted() {
safeSetStorage('clawfort_cookie_consent', 'accepted');
setCookie('clawfort_cookie_consent', 'accepted');
}
function normalizeLanguage(language) {
const supported = ['en', 'ta', 'ml'];
if (supported.includes(language)) return language;
@@ -220,7 +700,7 @@ function normalizeLanguage(language) {
}
function getPreferredLanguage() {
const stored = localStorage.getItem('clawfort_language');
const stored = safeGetStorage('clawfort_language');
if (stored) return normalizeLanguage(stored);
const cookieValue = readCookie('clawfort_language');
if (cookieValue) return normalizeLanguage(cookieValue);
@@ -230,22 +710,51 @@ function getPreferredLanguage() {
function setPreferredLanguage(language) {
const normalized = normalizeLanguage(language);
window._selectedLanguage = normalized;
localStorage.setItem('clawfort_language', normalized);
document.cookie = `clawfort_language=${encodeURIComponent(normalized)}; path=/; max-age=31536000; SameSite=Lax`;
safeSetStorage('clawfort_language', normalized);
setCookie('clawfort_language', normalized);
const select = document.getElementById('language-select');
if (select && select.value !== normalized) {
select.value = normalized;
}
trackEvent('language-change', { language: normalized });
syncStructuredData();
window.dispatchEvent(new CustomEvent('language-changed', { detail: { language: normalized } }));
}
window._selectedLanguage = getPreferredLanguage();
window._themeChoice = getPreferredTheme();
window.__heroNewsItem = null;
window.__feedNewsItems = [];
applyTheme(window._themeChoice);
syncSeoMeta(null);
syncStructuredData();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const choice = getPreferredTheme();
if (choice === 'system') {
applyTheme('system');
}
});
document.addEventListener('click', (event) => {
const root = document.getElementById('theme-menu-root');
if (!root) return;
if (!root.contains(event.target)) {
closeThemeMenu();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeThemeMenu();
}
});
function heroBlock() {
return {
item: null,
loading: true,
heroImageLoading: true,
async init() {
const select = document.getElementById('language-select');
@@ -258,16 +767,21 @@ function heroBlock() {
async loadHero() {
this.loading = true;
this.heroImageLoading = true;
this.item = null;
try {
const params = new URLSearchParams({ language: window._selectedLanguage || 'en' });
const resp = await fetch(`/api/news/latest?${params}`);
if (resp.ok) {
const data = await resp.json();
if (data) this.item = data;
this.item = data || null;
}
} catch (e) { console.error('Hero fetch failed:', e); }
this.loading = false;
window._heroId = this.item?.id;
window.__heroNewsItem = this.item;
syncSeoMeta(this.item);
syncStructuredData();
}
};
}
@@ -280,6 +794,10 @@ function newsFeed() {
initialLoading: true,
loadingMore: false,
observer: null,
modalOpen: false,
modalItem: null,
modalImageLoading: true,
imageLoaded: {},
async init() {
await this.waitForHero();
@@ -291,6 +809,8 @@ function newsFeed() {
this.nextCursor = null;
this.hasMore = true;
this.initialLoading = true;
this.imageLoaded = {};
window.__feedNewsItems = [];
await this.waitForHero();
await this.loadMore();
this.initialLoading = false;
@@ -324,8 +844,15 @@ function newsFeed() {
const data = await resp.json();
this.items = [...this.items, ...data.items];
for (const item of data.items) {
if (this.imageLoaded[item.id] === undefined) {
this.imageLoaded[item.id] = false;
}
}
this.nextCursor = data.next_cursor;
this.hasMore = data.has_more;
window.__feedNewsItems = this.items;
syncStructuredData();
} catch (e) {
console.error('Feed fetch failed:', e);
this.hasMore = false;
@@ -333,6 +860,19 @@ function newsFeed() {
this.loadingMore = false;
},
isImageLoading(id) {
return !this.imageLoaded[id];
},
markImageLoaded(id) {
this.imageLoaded[id] = true;
},
handleImageError(id, element) {
element.src = '/static/images/placeholder.png';
this.imageLoaded[id] = true;
},
setupObserver() {
this.observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && this.hasMore && !this.loadingMore) {
@@ -340,21 +880,76 @@ function newsFeed() {
}
}, { rootMargin: '200px' });
this.observer.observe(this.$refs.sentinel);
},
openSummary(item) {
this.modalItem = item;
this.modalOpen = true;
this.modalImageLoading = true;
trackEvent('summary-modal-open', { article_id: item.id });
},
closeSummary() {
const id = this.modalItem ? this.modalItem.id : null;
this.modalOpen = false;
this.modalItem = null;
trackEvent('summary-modal-close', { article_id: id });
},
trackSummarySource(item) {
if (!item) return;
trackEvent('summary-modal-link-out', {
article_id: item.id,
source_url: item.source_url,
});
}
};
}
(async function initAnalytics() {
try {
async function loadUmami() {
if (window.__umamiLoaded) return;
const resp = await fetch('/config');
if (!resp.ok) return;
const cfg = await resp.json();
if (cfg.umami_script_url && cfg.umami_website_id) {
const s = document.createElement('script');
s.defer = true;
s.src = cfg.umami_script_url;
s.setAttribute('data-website-id', cfg.umami_website_id);
document.head.appendChild(s);
if (!cfg.umami_script_url || !cfg.umami_website_id) return;
const s = document.createElement('script');
s.defer = true;
s.src = cfg.umami_script_url;
s.setAttribute('data-website-id', cfg.umami_website_id);
document.head.appendChild(s);
window.__umamiLoaded = true;
}
try {
const banner = document.getElementById('cookie-consent-banner');
const accept = document.getElementById('cookie-consent-accept');
function hideBannerWithFade() {
if (!banner || banner.classList.contains('hidden')) return;
banner.classList.add('opacity-0');
setTimeout(() => {
banner.classList.add('hidden');
banner.classList.remove('opacity-0');
}, 700);
}
if (hasCookieConsent()) {
await loadUmami();
} else if (banner) {
banner.classList.remove('hidden');
setTimeout(() => {
hideBannerWithFade();
}, 10000);
}
if (accept) {
accept.addEventListener('click', async () => {
setCookieConsentAccepted();
hideBannerWithFade();
await loadUmami();
trackEvent('cookie-consent-accepted');
});
}
} catch {}
})();

49
frontend/terms.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Terms of Use for ClawFort AI News, including content limitations and user responsibility.">
<meta name="robots" content="index,follow">
<meta property="og:type" content="article">
<meta property="og:title" content="Terms of Use - ClawFort">
<meta property="og:description" content="Read ClawFort terms governing informational use of AI-generated and aggregated content.">
<meta property="og:url" content="/terms">
<meta property="og:image" content="/static/images/placeholder.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Terms of Use - ClawFort">
<meta name="twitter:description" content="Read ClawFort terms governing informational use of AI-generated and aggregated content.">
<meta name="twitter:image" content="/static/images/placeholder.png">
<link rel="canonical" href="/terms">
<title>Terms of Use - ClawFort</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 font-[Inter] min-h-screen">
<main class="max-w-3xl mx-auto px-4 py-12">
<a href="/" class="text-blue-400 hover:text-blue-300">&larr; Back to ClawFort</a>
<h1 class="text-3xl sm:text-4xl font-extrabold mt-6 mb-4">Terms of Use</h1>
<div class="space-y-4 text-slate-300 leading-relaxed">
<p>
The information shown on this site is provided for general informational use only.
It is not independently verified by the site owner.
</p>
<p>
Any use of this content is entirely at your own risk. You are responsible for validating
facts, citations, and suitability before relying on or redistributing the information.
</p>
<p>
Content may originate from external and AI-generated sources and may include errors,
omissions, or outdated material.
</p>
</div>
</main>
<footer class="border-t border-white/10 py-6 text-center text-sm text-slate-400">
<a href="/" class="hover:text-white">Home</a>
<span class="mx-2">|</span>
<a href="/attribution" class="hover:text-white">Attribution</a>
</footer>
</body>
</html>

View File

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

View File

@@ -0,0 +1,95 @@
## Context
ClawFort currently stores and displays full headline/summary text from the ingestion pipeline and renders feed content directly in cards/hero. There is no dedicated concise summary format, modal reading experience, or summary-specific analytics lifecycle.
This change introduces a structured summary artifact per fetched article, with template-driven rendering and event instrumentation.
Constraints:
- Reuse existing Perplexity integration for generation.
- Keep source attribution visible and preserved.
- Prefer royalty-free image retrieval via MCP integration when available, with deterministic fallback path.
- Ensure modal interactions are fully tagged in Umami.
## Goals / Non-Goals
**Goals:**
- Generate and persist concise summary content at ingestion time.
- Persist and return template-compatible summary fields and image metadata.
- Present summary in a modal dialog with required visual structure.
- Track modal open/close/link-out analytics events consistently.
**Non-Goals:**
- Replacing the existing core feed API model end-to-end.
- Building a full long-form article reader.
- Introducing user-authored summary editing workflows.
- Supporting arbitrary analytics providers beyond current Umami hooks.
## Decisions
### Decision: Persist structured summary fields alongside article records
**Decision:** Store summary artifacts as explicit fields (TL;DR bullets, summary body, citation/source, summary image URL/credit) linked to each article.
**Rationale:**
- Enables deterministic API response shape for modal rendering.
- Keeps summary retrieval simple at read time.
- Avoids dynamic prompt regeneration during page interactions.
**Alternatives considered:**
- Generate summary on-demand at modal open: rejected due to latency and cost spikes.
- Store a single blob markdown string only: rejected due to weaker field-level control and analytics granularity.
### Decision: Use Perplexity for summary generation with strict output schema
**Decision:** Prompt Perplexity to return machine-parseable JSON fields that map directly to the template sections.
**Rationale:**
- Existing Perplexity integration and operational familiarity.
- Structured output reduces frontend parsing fragility.
**Alternatives considered:**
- Free-form text generation then regex parsing: rejected as brittle.
### Decision: Prefer MCP royalty-free image sourcing, fallback to deterministic non-MCP source path
**Decision:** When MCP image retrieval integration is configured, use it first; otherwise use a configured royalty-free provider path and fallback placeholder.
**Rationale:**
- Satisfies preference for MCP leverage while preserving reliability.
- Maintains legal/licensing constraints and avoids blocked ingestion.
**Alternatives considered:**
- Hard dependency on MCP only: rejected due to availability/runtime coupling risk.
### Decision: Add modal-specific analytics event contract
**Decision:** Define and emit explicit Umami events for summary modal open, close, and source link-out clicks.
**Rationale:**
- Makes summary engagement measurable independently of feed interactions.
- Prevents implicit/ambiguous event interpretation.
**Alternatives considered:**
- Reusing existing generic card click events only: rejected due to insufficient modal-level observability.
## Risks / Trade-offs
- **[Risk] Summary generation adds ingest latency** -> Mitigation: bounded retries and skip/fallback behavior.
- **[Risk] Provider output schema drift breaks parser** -> Mitigation: strict validation + fallback summary text behavior.
- **[Risk] Royalty-free image selection may be semantically weak** -> Mitigation: relevance prompt constraints and placeholder fallback.
- **[Trade-off] Additional stored fields increase row size** -> Mitigation: concise field limits and optional archival policy alignment.
- **[Risk] Event overcount from repeated modal toggles** -> Mitigation: standardize open/close trigger boundaries and dedupe rules in frontend logic.
## Migration Plan
1. Add summary/image metadata fields or related model for persisted summary artifacts.
2. Extend ingestion flow to generate structured summary + citation via Perplexity.
3. Integrate royalty-free image retrieval with MCP-preferred flow and fallback.
4. Extend API payloads to return summary-modal-ready data.
5. Implement frontend modal rendering with exact template and analytics tags.
6. Validate event tagging correctness and rendering fallback behavior.
Rollback:
- Disable modal entrypoint and return existing feed behavior while retaining stored summary data.
## Open Questions
- Should TL;DR bullet count be fixed (for example 3) or provider-adaptive within a bounded range?
- Should summary modal open be card-click only or have an explicit "Read Summary" CTA in each card?
- Which royalty-free provider is preferred default when MCP is unavailable?

View File

@@ -0,0 +1,37 @@
## Why
Users need a quick, concise view of fetched news without reading long article text. Adding a TL;DR summary flow improves scan speed, reading experience, and engagement while keeping source transparency.
## What Changes
- **New Capabilities:**
- Generate concise article summaries via Perplexity when news is fetched.
- Store structured summary content in the database using the required display template.
- Fetch and attach an appropriate royalty-free image for each summarized article.
- Render summary content in a modal dialog from the landing page.
- Add Umami event tagging for modal opens, closes, and source link-outs.
- **Backend:**
- Extend ingestion to call Perplexity summary generation and persist summary output.
- Integrate royalty-free image sourcing (prefer MCP path when available).
- **Frontend:**
- Add summary modal UI and interaction flow.
- Add event tracking for all required user actions in the modal.
## Capabilities
### New Capabilities
- `article-tldr-summary`: Generate and persist concise TL;DR + summary content per fetched article using Perplexity.
- `summary-modal-experience`: Display summary content in a modal dialog using the standardized template format.
- `royalty-free-image-enrichment`: Attach appropriate royalty-free images for summarized articles, leveraging MCP integration when available.
- `summary-analytics-tagging`: Track summary modal opens, closes, and external source link-outs via Umami event tags.
### Modified Capabilities
- None.
## Impact
- **Code:** Ingestion pipeline, storage model/schema, API response payloads, and frontend modal interactions will be updated.
- **APIs:** Existing news payloads will include summary/template-ready fields and image metadata.
- **Dependencies:** Reuses Perplexity; may add/enable royalty-free image provider integration (including MCP route if available).
- **Infrastructure:** No major topology change expected.
- **Data:** Database will store summary artifacts and associated image/source metadata.

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: System generates structured TL;DR summary for each fetched article
The system SHALL generate a concise summary artifact for each newly fetched article using Perplexity during ingestion.
#### Scenario: Successful summary generation
- **WHEN** a new article is accepted in ingestion
- **THEN** the system generates TL;DR bullet points and a concise summary body
- **AND** output is persisted in a structured, template-compatible format
#### Scenario: Summary generation fallback
- **WHEN** summary generation fails for an article
- **THEN** ingestion continues without failing the entire cycle
- **AND** the article remains available with existing non-summary content
### Requirement: Summary storage includes citation and source context
The system SHALL persist source/citation information needed to render summary provenance.
#### Scenario: Persist source and citation metadata
- **WHEN** summary content is stored
- **THEN** associated source/citation fields are stored with the article summary artifact
- **AND** response payloads can render a "Source and Citation" section

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: System enriches summaries with appropriate royalty-free images
The system SHALL attach an appropriate royalty-free image to each summarized article.
#### Scenario: Successful royalty-free image retrieval
- **WHEN** summary generation succeeds for an article
- **THEN** the system retrieves an appropriate royalty-free image for that article context
- **AND** stores image URL and attribution metadata for rendering
#### Scenario: MCP-preferred retrieval path
- **WHEN** MCP image integration is available in runtime
- **THEN** the system uses MCP-based retrieval as the preferred image sourcing path
- **AND** falls back to configured non-MCP royalty-free source path when MCP retrieval fails or is unavailable
### Requirement: Image retrieval failures do not block article availability
The system SHALL remain resilient when image sourcing fails.
#### Scenario: Image fallback behavior
- **WHEN** no suitable royalty-free image can be retrieved
- **THEN** the article summary remains available for modal display
- **AND** UI uses configured placeholder/fallback image behavior

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Modal interactions are tagged for analytics
The system SHALL emit Umami analytics events for summary modal open and close actions.
#### Scenario: Modal open event tagging
- **WHEN** a user opens the summary modal
- **THEN** the system emits a modal-open Umami event
- **AND** event payload includes article context identifier
#### Scenario: Modal close event tagging
- **WHEN** a user closes the summary modal
- **THEN** the system emits a modal-close Umami event
- **AND** event payload includes article context identifier when available
### Requirement: Source link-out interactions are tagged for analytics
The system SHALL emit Umami analytics events for source/citation link-outs from summary modal.
#### Scenario: Source link-out event tagging
- **WHEN** a user clicks source/citation link in summary modal
- **THEN** the system emits a link-out Umami event before or at navigation trigger
- **AND** event includes source URL or source identifier metadata

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Summary is rendered in a modal dialog using standard template
The system SHALL render article summary content in a modal dialog using the required structure.
#### Scenario: Open summary modal
- **WHEN** a user triggers summary view for an article
- **THEN** a modal dialog opens and displays content in this order: relevant image, TL;DR bullets, summary body, source and citation, and "Powered by Perplexity"
- **AND** modal content corresponds to the selected article
#### Scenario: Close summary modal
- **WHEN** a user closes the modal via close control or backdrop interaction
- **THEN** the modal is dismissed cleanly
- **AND** user returns to previous feed context without page navigation
### Requirement: Modal content preserves source link-out behavior
The system SHALL provide source link-outs from the summary modal.
#### Scenario: Source link-out from modal
- **WHEN** a user clicks source/citation link in the modal
- **THEN** the original source opens in a new tab/window
- **AND** modal behavior remains stable for continued browsing

View File

@@ -0,0 +1,46 @@
## 1. Summary Data Model and Persistence
- [x] 1.1 Add persisted summary fields for TL;DR bullets, summary body, source/citation, and summary image metadata
- [x] 1.2 Update database initialization/migration path for summary-related storage changes
- [x] 1.3 Add repository read/write helpers for structured summary artifact fields
## 2. Ingestion-Time Summary Generation
- [x] 2.1 Extend ingestion flow to request structured TL;DR + summary output from Perplexity for each fetched article
- [x] 2.2 Implement strict parser/validator for summary output schema used by the frontend template
- [x] 2.3 Persist generated summary artifacts with article records during the same ingestion cycle
- [x] 2.4 Add graceful fallback behavior when summary generation fails without blocking article availability
## 3. Royalty-Free Image Enrichment
- [x] 3.1 Implement royalty-free image retrieval for summarized articles with relevance constraints
- [x] 3.2 Prefer MCP-based image retrieval path when available
- [x] 3.3 Implement deterministic non-MCP fallback image path and placeholder behavior
- [x] 3.4 Persist image attribution/licensing metadata required for compliant display
## 4. API Delivery for Summary Modal
- [x] 4.1 Extend API response payloads to include summary-modal-ready fields
- [x] 4.2 Ensure API payload contract maps directly to required template sections
- [x] 4.3 Preserve source and citation link-out data in API responses
## 5. Frontend Summary Modal Experience
- [x] 5.1 Implement summary modal dialog component in landing page flow
- [x] 5.2 Render modal using required order: image, TL;DR bullets, summary, source/citation, Powered by Perplexity
- [x] 5.3 Add article-level trigger to open summary modal from feed interactions
- [x] 5.4 Implement robust modal close behavior (close control + backdrop interaction)
## 6. Analytics Event Tagging
- [x] 6.1 Emit Umami event on summary modal open with article context
- [x] 6.2 Emit Umami event on summary modal close with article context when available
- [x] 6.3 Emit Umami event on source/citation link-out from modal with source metadata
- [x] 6.4 Validate event naming and payload consistency across repeated interactions
## 7. Validation and Documentation
- [x] 7.1 Validate end-to-end summary generation, persistence, and modal rendering for new articles
- [x] 7.2 Validate fallback behavior for summary/image retrieval failures
- [x] 7.3 Validate source/citation visibility and external link behavior in modal
- [x] 7.4 Update README with summary modal feature behavior and analytics event contract

View File

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

View File

@@ -0,0 +1,65 @@
## Context
ClawFort currently has a single-page news experience and no dedicated policy/disclaimer documents accessible from primary navigation. This creates ambiguity around authorship, verification, and acceptable use expectations.
This change introduces lightweight policy pages and footer navigation updates without changing core data flows or APIs.
## Goals / Non-Goals
**Goals:**
- Add visible footer links for Terms of Use and Attribution.
- Add dedicated pages with explicit non-ownership and AI-generation disclosures.
- Add clear risk language that content is unverified and users act at their own risk.
**Non-Goals:**
- Implementing full legal-policy versioning workflows.
- User-specific policy acceptance tracking.
- Backend auth/session changes.
## Decisions
### Decision: Serve policy pages as static frontend documents
**Decision:** Implement `terms.html` and `attribution.html` as static pages in the frontend directory.
**Rationale:**
- Lowest complexity for current architecture.
- Policy content is mostly static and does not require dynamic API data.
**Alternatives considered:**
- Backend-rendered templates: rejected due to unnecessary server complexity.
### Decision: Add persistent footer links on main page and policy pages
**Decision:** Footer includes links on landing page and reciprocal navigation from policy pages back to home.
**Rationale:**
- Improves discoverability and prevents navigation dead ends.
**Alternatives considered:**
- Header-only links: rejected due to crowded header and lower policy discoverability.
### Decision: Keep disclaimer wording explicit and prominent
**Decision:** Use direct language in page body and heading hierarchy emphasizing AI generation, non-ownership, and use-at-own-risk boundaries.
**Rationale:**
- Meets intent of legal disclosure and user expectation setting.
**Alternatives considered:**
- Compact single-line disclaimers: rejected as insufficiently clear.
## Risks / Trade-offs
- **[Risk] Disclaimer copy may still be interpreted differently by jurisdictions** -> Mitigation: keep language clear and easily editable in static pages.
- **[Trade-off] Static pages require redeploy for copy updates** -> Mitigation: isolate content in dedicated files for quick revision.
## Migration Plan
1. Add static policy pages under frontend.
2. Add footer links in the main page and cross-links in policy pages.
3. Validate page serving and navigation in local runtime.
Rollback:
- Remove policy pages and footer links; no data migration required.
## Open Questions
- Should policy pages include effective-date/version metadata in this phase?

View File

@@ -0,0 +1,26 @@
## Why
The site needs explicit legal/disclaimer pages so users understand content ownership boundaries and reliability limits. Adding these now reduces misuse risk and sets clear expectations for AI-generated, unverified information.
## What Changes
- Add two footer links: **Terms of Use** and **Attribution**.
- Create an **Attribution** page with clear disclosure that content is AI-generated and not authored/verified by the site owner.
- Create a **Terms of Use** page stating users must use the information at their own risk because it is not independently verified.
- Ensure footer links are visible and route correctly from the landing page.
## Capabilities
### New Capabilities
- `footer-policy-links`: Add footer navigation entries for Terms of Use and Attribution pages.
- `attribution-disclaimer-page`: Provide a dedicated attribution/disclaimer page with explicit AI-generation and non-ownership statements.
- `terms-of-use-risk-disclosure`: Provide a terms page that clearly states unverified information and user-at-own-risk usage.
### Modified Capabilities
- None.
## Impact
- **Frontend/UI:** Footer layout and navigation updated; two new legal/disclaimer pages added.
- **Routing/Serving:** Backend/static serving may need routes for new pages if not purely static-linked.
- **Content/Policy:** Adds formal disclaimer language for authorship, verification, and usage risk.

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Attribution page discloses AI generation and non-ownership
The system SHALL provide an Attribution page with explicit statements that content is AI-generated and not personally authored by the site owner.
#### Scenario: Attribution page title and disclosure content
- **WHEN** a user opens the Attribution page
- **THEN** the page title clearly indicates attribution/disclaimer purpose
- **AND** the body states that content is AI-generated and not generated by the owner as an individual
#### Scenario: Attribution page includes non-involvement statement
- **WHEN** a user reads the Attribution page
- **THEN** the page explicitly states owner non-involvement in generated content claims
- **AND** wording is presented in primary readable content area

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Footer exposes policy navigation links
The system SHALL display footer links for Terms of Use and Attribution on the landing page.
#### Scenario: Footer links visible on landing page
- **WHEN** a user loads the main page
- **THEN** the footer includes links labeled "Terms of Use" and "Attribution"
- **AND** links are visually distinguishable and keyboard focusable
#### Scenario: Footer links navigate correctly
- **WHEN** a user activates either policy link
- **THEN** the browser navigates to the corresponding policy page
- **AND** navigation succeeds without API dependency

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Terms page states unverified-content risk
The system SHALL provide a Terms of Use page that states information is unverified and use is at the users own risk.
#### Scenario: Terms page risk statement visible
- **WHEN** a user opens the Terms of Use page
- **THEN** the page includes clear at-own-risk usage language
- **AND** the page states information is not independently verified
#### Scenario: Terms page references source uncertainty
- **WHEN** a user reads terms details
- **THEN** the page explains content is surfaced from external/AI-generated sources
- **AND** users are informed responsibility remains with their own decisions

View File

@@ -0,0 +1,24 @@
## 1. Footer Policy Navigation
- [x] 1.1 Add Terms of Use and Attribution links to primary footer
- [x] 1.2 Ensure policy links are keyboard focusable and readable on all breakpoints
## 2. Attribution Page Content
- [x] 2.1 Create attribution page with explicit AI-generated and non-ownership disclosure title/content
- [x] 2.2 Add statement clarifying owner non-involvement in generated content claims
## 3. Terms of Use Risk Disclosure
- [x] 3.1 Create Terms of Use page with unverified-content and use-at-own-risk statements
- [x] 3.2 Add language that users remain responsible for downstream use decisions
## 4. Routing and Page Serving
- [x] 4.1 Wire policy page routes/serving behavior from current frontend/backend structure
- [x] 4.2 Add return navigation between home and policy pages
## 5. Validation and Documentation
- [x] 5.1 Validate footer link navigation and policy page rendering on desktop/mobile
- [x] 5.2 Update README or docs with policy page locations and purpose

View File

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

View File

@@ -0,0 +1,68 @@
## Context
The current UI defaults to dark presentation and lacks a global theme control. Users with different preference and accessibility needs cannot choose light/system/high-contrast alternatives.
This change introduces an icon-based theme switcher and persistent client-side preference restoration.
## Goals / Non-Goals
**Goals:**
- Add a header theme switcher with system, light, dark, and high-contrast options.
- Apply theme choice globally with minimal visual regression.
- Persist preference in localStorage with cookie fallback.
- Restore returning-user choice; default to system when unset.
**Non-Goals:**
- Server-side profile theme persistence.
- Theme-specific content changes.
- Full design-system rewrite.
## Decisions
### Decision: Centralize theme state on `document.documentElement`
**Decision:** Set a root theme attribute/class and drive color tokens from CSS variables.
**Rationale:**
- Single source of truth for whole page styling.
- Works with existing Tailwind utility classes via custom CSS variable bridge.
**Alternatives considered:**
- Component-level theming flags: rejected due to drift and maintenance overhead.
### Decision: Keep system mode dynamic via `prefers-color-scheme`
**Decision:** For `system`, listen to media query changes and update resolved theme automatically.
**Rationale:**
- Matches user OS preference behavior.
**Alternatives considered:**
- One-time system snapshot: rejected as surprising for users changing OS theme at runtime.
### Decision: Use icon-only options with accessible labels
**Decision:** Theme controls are icon buttons with ARIA labels and visible selected state.
**Rationale:**
- Meets UX requirement while preserving accessibility.
**Alternatives considered:**
- Text dropdown: rejected due to explicit icon requirement.
## Risks / Trade-offs
- **[Risk] Existing hardcoded color classes may not adapt perfectly** -> Mitigation: prioritize core surfaces/text and progressively map remaining variants.
- **[Risk] High-contrast mode may expose layout artifacts** -> Mitigation: audit focus outlines, borders, and semantic contrast first.
- **[Trade-off] Additional JS for persistence and media listeners** -> Mitigation: keep logic modular and lightweight.
## Migration Plan
1. Add theme tokens and root theme resolver.
2. Implement icon switcher in header and state persistence.
3. Wire system preference listener and fallback behavior.
4. Validate across refresh/returning sessions and responsive breakpoints.
Rollback:
- Remove theme switcher and resolver, revert to existing dark-default classes.
## Open Questions
- Should high-contrast mode align with OS `prefers-contrast` in a later phase?

View File

@@ -0,0 +1,29 @@
## Why
The current UI is locked to dark presentation, which does not match all user preferences or accessibility needs. Adding multi-theme support now improves usability and lets returning users keep a consistent visual experience.
## What Changes
- Add a theme switcher in the top-right header area.
- Support four theme modes: **system**, **light**, **dark**, and **high-contrast**.
- Render theme options as icons (not text-only controls).
- Persist selected theme in client storage with **localStorage as primary** and **cookie fallback**.
- Restore persisted theme for returning users.
- Use **system** as default when no prior selection exists.
## Capabilities
### New Capabilities
- `theme-switcher-control`: Provide an icon-based theme switcher in the header with system/light/dark/high-contrast options.
- `theme-preference-persistence`: Persist and restore user-selected theme using localStorage first, with cookie fallback.
- `theme-default-system`: Apply system theme automatically when no saved preference exists.
### Modified Capabilities
- None.
## Impact
- **Frontend/UI:** Header controls and global styling system updated for four theme modes.
- **State management:** Client-side preference state handling added for theme selection and restoration.
- **Accessibility:** High-contrast option improves readability for users needing stronger contrast.
- **APIs/Backend:** No required backend API changes expected.

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: System theme is default when no preference exists
The system SHALL default to system theme behavior if no persisted theme preference is found.
#### Scenario: No saved preference on first visit
- **WHEN** a user visits the site with no stored theme value
- **THEN** the UI resolves theme from system color-scheme preference
- **AND** switcher indicates system mode as active
#### Scenario: Persisted preference overrides system default
- **WHEN** a user has an existing stored theme preference
- **THEN** stored preference is applied instead of system mode
- **AND** user-selected theme remains stable across reloads

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Theme choice persists across sessions
The system SHALL persist user-selected theme with localStorage as primary storage and cookie fallback when localStorage is unavailable.
#### Scenario: Persist theme in localStorage
- **WHEN** localStorage is available and user selects a theme
- **THEN** selected theme is saved in localStorage
- **AND** stored value is used on next visit in same browser
#### Scenario: Cookie fallback persistence
- **WHEN** localStorage is unavailable or blocked
- **THEN** selected theme is saved in cookie storage
- **AND** cookie value is used to restore theme on return visit

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Header provides icon-based theme switcher
The system SHALL display a theme switcher in the top-right header area with icon controls for system, light, dark, and high-contrast modes.
#### Scenario: Theme options visible as icons
- **WHEN** a user views the header
- **THEN** all four theme options are represented by distinct icons
- **AND** each option is keyboard-accessible and screen-reader labeled
#### Scenario: Theme selection applies immediately
- **WHEN** a user selects a theme option
- **THEN** the page updates visual theme without full page reload
- **AND** selected option has a visible active state

View File

@@ -0,0 +1,26 @@
## 1. Theme Foundation
- [x] 1.1 Define root-level theme state model for system, light, dark, and high-contrast
- [x] 1.2 Add CSS token/variable mapping so all theme modes can be resolved consistently
## 2. Theme Switcher UI
- [x] 2.1 Add icon-based theme switcher control to top-right header area
- [x] 2.2 Provide accessible labels and active-state indication for each icon option
## 3. Theme Preference Persistence
- [x] 3.1 Persist selected theme in localStorage when available
- [x] 3.2 Implement cookie fallback persistence when localStorage is unavailable
- [x] 3.3 Restore persisted preference for returning users
## 4. System Default Behavior
- [x] 4.1 Apply system mode when no persisted preference exists
- [x] 4.2 Ensure saved user preference overrides system default on subsequent visits
## 5. Validation and Documentation
- [x] 5.1 Validate theme switching and persistence across refreshes and browser restarts
- [x] 5.2 Validate icon controls with keyboard navigation and screen reader labels
- [x] 5.3 Update README/docs with theme options and persistence behavior

View File

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

View File

@@ -0,0 +1,71 @@
## Context
The current one-page application has strong feature velocity but uneven robustness across viewport sizes and accessibility states. It also initializes analytics without an explicit user consent gate, which creates compliance and trust risks in stricter jurisdictions.
This change introduces responsive hardening, WCAG 2.2 AA baseline conformance, and explicit cookie-consent-controlled tracking behavior.
## Goals / Non-Goals
**Goals:**
- Ensure key UI flows work consistently across mobile, tablet, and desktop.
- Bring critical interactions and content presentation to WCAG 2.2 AA expectations.
- Add consent UI and persist consent state in cookies (with local state sync).
- Gate analytics script/event execution behind consent.
**Non-Goals:**
- Full legal-policy framework beyond consent capture basics.
- Rebuilding the full visual system from scratch.
- Country-specific geo-personalized consent variants in this phase.
## Decisions
### Decision: Define responsive guarantees around existing breakpoints and interaction surfaces
**Decision:** Formalize requirements for hero, feed cards, modal/dialog interactions, and header controls across common breakpoints and orientations.
**Rationale:**
- Targets user-visible breakage points first.
- Reduces regression risk while keeping implementation incremental.
**Alternatives considered:**
- Pixel-perfect per-device tailoring: rejected due to maintenance cost.
### Decision: Prioritize WCAG 2.2 AA for core paths and controls
**Decision:** Apply compliance to keyboard navigation, focus indicators, contrast, semantic labels, and non-text alternatives in primary user journeys.
**Rationale:**
- Maximizes accessibility impact where users spend time.
- Keeps scope realistic for immediate hardening.
**Alternatives considered:**
- Attempting broad AAA alignment: rejected as out-of-scope for this phase.
### Decision: Gate analytics on explicit consent and persist choice in cookie
**Decision:** Tracking initializes only after user consent; cookie stores consent state and optional local state mirrors for fast frontend read.
**Rationale:**
- Aligns with safer consent posture and user transparency.
- Supports returning-user behavior without backend session coupling.
**Alternatives considered:**
- Always-on tracking with notice-only banner: rejected for compliance risk.
## Risks / Trade-offs
- **[Risk] Responsive fixes can introduce visual drift across existing sections** -> Mitigation: validate at target breakpoints and keep changes token-based.
- **[Risk] Accessibility remediations may require widespread class/markup changes** -> Mitigation: focus first on critical interactions and reuse shared utility patterns.
- **[Trade-off] Consent gating can reduce analytics volume** -> Mitigation: explicit consent messaging and friction-minimized accept flow.
## Migration Plan
1. Add responsive and accessibility acceptance criteria for key components.
2. Implement consent banner, persistence logic, and analytics gating.
3. Refine UI semantics/focus/contrast and test keyboard-only navigation.
4. Validate across viewport matrix and accessibility checklist.
Rollback:
- Disable consent gate logic and revert to prior analytics init path while retaining non-breaking responsive/accessibility improvements.
## Open Questions
- Should consent expiration/renewal interval be introduced in this phase or follow-up?
- Should consent state include analytics-only granularity now, or remain a single accepted state?

View File

@@ -0,0 +1,27 @@
## Why
The product needs stronger technical quality and trust signals across devices and accessibility contexts. Improving responsiveness, WCAG 2.2 AA conformance, and compliant consent handling reduces usability risk and supports broader adoption.
## What Changes
- Make the site fully device-agnostic and responsive across mobile, tablet, and desktop breakpoints.
- Bring key user flows to WCAG 2.2 AA standards (contrast, focus visibility, keyboard navigation, semantics, and non-text content).
- Add a cookie consent banner with clear consent messaging and persistence.
- Record consent in browser cookies (with local state sync where applicable) and apply analytics only after consent is given.
## Capabilities
### New Capabilities
- `responsive-device-agnostic-layout`: Ensure core pages/components adapt reliably across viewport sizes and input modes.
- `wcag-2-2-aa-accessibility`: Enforce WCAG 2.2 AA requirements for interactive and content-rendering paths.
- `cookie-consent-tracking-gate`: Provide consent capture and persistence for analytics/tracking behavior.
### Modified Capabilities
- None.
## Impact
- **Frontend/UI:** Layout, spacing, typography, and interaction behavior updated for responsive and accessible presentation.
- **Accessibility:** ARIA semantics, keyboard focus flow, and contrast/focus treatments refined.
- **Analytics/Consent:** Consent banner and tracking gate logic added; cookie persistence introduced.
- **QA/Validation:** Accessibility and responsiveness verification scope expands (manual + automated checks where available).

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Consent banner captures and persists tracking consent
The system SHALL display a cookie consent banner and persist user consent decision in cookies before enabling analytics tracking.
#### Scenario: Consent capture and persistence
- **WHEN** a user interacts with the consent banner and accepts
- **THEN** consent state is stored in a cookie
- **AND** stored consent is honored on subsequent visits
#### Scenario: Tracking gated by consent
- **WHEN** consent has not been granted
- **THEN** analytics/tracking scripts and events do not execute
- **AND** tracking begins only after consent state indicates acceptance

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Core layout is device-agnostic and responsive
The system SHALL render key surfaces (header, hero, feed, modal, footer) responsively across mobile, tablet, and desktop viewports.
#### Scenario: Mobile layout behavior
- **WHEN** a user opens the site on a mobile viewport
- **THEN** content remains readable without horizontal overflow
- **AND** interactive controls remain reachable and usable
#### Scenario: Desktop and tablet adaptation
- **WHEN** a user opens the site on tablet or desktop viewports
- **THEN** layout reflows according to breakpoint design rules
- **AND** no key content or controls are clipped

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Core user flows comply with WCAG 2.2 AA baseline
The system SHALL meet WCAG 2.2 AA accessibility requirements for primary interactions and content presentation.
#### Scenario: Keyboard-only interaction flow
- **WHEN** a keyboard-only user navigates the page
- **THEN** all primary interactive elements are reachable and operable
- **AND** visible focus indication is present at each step
#### Scenario: Contrast and non-text alternatives
- **WHEN** users consume text and non-text UI content
- **THEN** color contrast meets AA thresholds for relevant text and controls
- **AND** meaningful images and controls include accessible labels/alternatives

View File

@@ -0,0 +1,28 @@
## 1. Responsive Hardening
- [x] 1.1 Audit and fix layout breakpoints for header, hero, feed cards, modal, and footer
- [x] 1.2 Ensure no horizontal overflow or clipped controls on supported viewport sizes
## 2. WCAG 2.2 AA Accessibility Baseline
- [x] 2.1 Implement/verify keyboard operability for primary controls and dialogs
- [x] 2.2 Add/verify visible focus indicators and semantic labels for interactive elements
- [x] 2.3 Improve contrast and non-text alternatives to meet AA expectations on core flows
## 3. Cookie Consent and Tracking Gate
- [x] 3.1 Implement consent banner UI with explicit analytics consent action
- [x] 3.2 Persist consent state in cookies and synchronize frontend state
- [x] 3.3 Gate analytics script/event initialization until consent is granted
## 4. Returning User Consent Behavior
- [x] 4.1 Restore prior consent state from cookie on returning visits
- [x] 4.2 Ensure tracking remains disabled without stored accepted consent
## 5. Verification and Documentation
- [x] 5.1 Validate responsive behavior on mobile/tablet/desktop matrices
- [x] 5.2 Run accessibility checks and manual keyboard-only walkthrough for critical journeys
- [x] 5.3 Validate consent gating and analytics behavior before/after acceptance
- [x] 5.4 Update README/docs with accessibility and consent behavior notes

View File

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

View File

@@ -0,0 +1,91 @@
## Context
ClawFort serves a static, client-rendered news experience backed by FastAPI endpoints and scheduled content refresh. The change introduces explicit technical requirements for crawlability, structured data quality, and delivery speed so SEO behavior is reliable across homepage, article cards, and static policy pages.
Current implementation already includes foundational metadata and partial performance behavior, but requirements are not yet codified in change specs. This design defines an implementation approach that keeps existing architecture (FastAPI + static frontend) while formalizing output guarantees required by search engines and validators.
## Goals / Non-Goals
**Goals:**
- Define a deterministic metadata contract for homepage and static pages (description, canonical, robots, Open Graph, Twitter card fields).
- Define structured-data output for homepage (`Newspaper`) and every rendered news item (`NewsArticle`) with stable required properties.
- Define response-delivery expectations for compression and cache policy plus front-end media loading behavior.
- Keep requirements implementable in the current stack without introducing heavyweight infrastructure.
**Non-Goals:**
- Full SSR migration or framework replacement.
- Introduction of external CDN, edge workers, or managed caching tiers.
- Reworking editorial/news-fetch business logic.
- Rich-result optimization for types outside this scope (e.g., FAQ, VideoObject, LiveBlogPosting).
## Decisions
### Decision: Keep JSON-LD generation in the existing page runtime contract
**Decision:** Define structured data as JSON-LD embedded in `index.html`, populated from the same article data model used by hero/feed rendering.
**Rationale:**
- Avoids duplication between UI content and structured data.
- Preserves current architecture and deployment flow.
- Supports homepage-wide `@graph` output containing `Newspaper` and multiple `NewsArticle` nodes.
**Alternatives considered:**
- Server-side rendered JSON-LD via template engine: rejected due to architectural drift and migration overhead.
- Microdata-only tagging: rejected because JSON-LD is simpler to maintain and validate for this use case.
### Decision: Use standards-aligned required field baseline for `NewsArticle`
**Decision:** Require each `NewsArticle` node to include stable core fields: headline, description, image, datePublished, dateModified, url/mainEntityOfPage, inLanguage, publisher, and author.
**Rationale:**
- Produces predictable, testable output.
- Reduces schema validation regressions from partial payloads.
- Aligns with common crawler expectations for article entities.
**Alternatives considered:**
- Minimal schema with only headline/url: rejected due to weak semantic value and poorer validation confidence.
### Decision: Enforce lightweight HTTP performance controls in-app
**Decision:** Treat transport optimization as explicit requirements using in-app compression middleware and response cache headers by route class (static assets, APIs, HTML pages).
**Rationale:**
- High impact with minimal infrastructure changes.
- Testable directly in integration checks.
- Works in current deployment topology.
**Alternatives considered:**
- Delegate entirely to reverse proxy/CDN: rejected because this repository currently controls delivery behavior directly.
### Decision: Standardize lazy media loading behavior with shimmer placeholders
**Decision:** Define lazy-loading requirements for non-critical images and require shimmer placeholder states until image load/error resolution.
**Rationale:**
- Improves perceived performance and consistency.
- Helps reduce layout instability when paired with explicit image dimensions.
- Fits existing UI loading pattern.
**Alternatives considered:**
- Skeleton-only page-level placeholders: rejected because item-level shimmer provides better visual continuity.
## Risks / Trade-offs
- **[Risk] Dynamic metadata timing for client-rendered content** -> Mitigation: require baseline static metadata defaults and deterministic runtime replacement after hero/article payload availability.
- **[Risk] Overly aggressive cache behavior could stale fresh news** -> Mitigation: short API max-age with stale-while-revalidate; separate longer static asset policy.
- **[Trade-off] Strict validation vs. framework directives in markup** -> Mitigation: define standards-compatible output goals and track exceptions where framework attributes are unavoidable.
- **[Trade-off] More metadata fields increase maintenance** -> Mitigation: centralize field mapping helpers and require parity with article model fields.
## Migration Plan
1. Implement and verify metadata/structured-data contracts on homepage and news-card rendering paths.
2. Add/verify response compression and route-level cache directives in backend delivery layer.
3. Align image loading UX requirements (lazy + shimmer + explicit dimensions) across hero/feed/modal contexts.
4. Validate output with schema and HTML validation tooling, then fix conformance gaps.
5. Document acceptance checks and rollback approach.
Rollback:
- Revert SEO/performance-specific frontend/backend changes to prior baseline while retaining unaffected feature behavior.
- Remove schema additions and route cache directives if they introduce regressions.
## Open Questions
- Should policy pages (`/terms`, `/attribution`) share a stricter noindex strategy or remain indexable by default?
- Should canonical URLs include hash anchors for in-page article cards or stay route-level canonical only?
- Do we require locale-specific `og:locale`/alternate tags in this phase or defer to a follow-up i18n SEO change?

View File

@@ -0,0 +1,28 @@
## Why
ClawFort currently lacks a formal SEO and structured-data specification, which limits discoverability and consistency for search crawlers. Defining this now ensures the news experience is indexable, standards-oriented, and performance-focused as the content footprint grows.
## What Changes
- Add search-focused metadata requirements for the main page and policy pages (description, canonical, robots, social preview tags).
- Define structured data requirements so the home page is represented as `Newspaper` and each news item is represented as `NewsArticle`.
- Establish performance requirements for transport and caching behavior (HTTP compression and cache directives) plus front-end loading behavior.
- Define UX and rendering requirements for image lazy loading with shimmer placeholders and smooth scrolling.
- Require markup and interaction patterns that are compatible with strict standards validation goals.
## Capabilities
### New Capabilities
- `seo-meta-and-social-tags`: Standardize meta, canonical, robots, and social preview tags for key public pages.
- `news-structured-data`: Provide machine-readable `Newspaper` and `NewsArticle` structured data for homepage and article entries.
- `delivery-and-rendering-performance`: Define response compression/caching and client-side loading behavior for faster page delivery.
### Modified Capabilities
- None.
## Impact
- **Frontend/UI:** `frontend/index.html` and static policy pages gain SEO metadata, structured-data hooks, and loading-state behavior requirements.
- **Backend/API Delivery:** `backend/main.py` response middleware/headers are affected by compression and cache policy expectations.
- **Quality/Validation:** Standards conformance and SEO validation become explicit acceptance criteria for this change.
- **Operations:** Performance posture depends on HTTP behavior and deploy/runtime configuration alignment.

View File

@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: HTTP delivery applies compression and cache policy
The system SHALL apply transport-level compression and explicit cache directives for static assets, API responses, and public HTML routes.
#### Scenario: Compressed responses are available for eligible payloads
- **WHEN** a client requests compressible content that exceeds the compression threshold
- **THEN** the response is served with gzip compression
- **AND** response headers advertise the selected content encoding
#### Scenario: Route classes receive deterministic cache-control directives
- **WHEN** clients request static assets, API responses, or HTML page routes
- **THEN** each route class returns a cache policy aligned to its freshness requirements
- **AND** cache directives are explicit and testable from response headers
### Requirement: Media rendering optimizes perceived loading performance
The system SHALL lazy-load non-critical images and render shimmer placeholders until image load completion or fallback resolution.
#### Scenario: Feed and modal images lazy-load with placeholders
- **WHEN** feed or modal images have not completed loading
- **THEN** a shimmer placeholder is visible for the pending image region
- **AND** the placeholder is removed after load or fallback error handling completes
#### Scenario: Image rendering reduces layout shift risk
- **WHEN** article images are rendered in hero, feed, or modal contexts
- **THEN** image elements include explicit dimensions and async decoding hints
- **AND** layout remains stable while content loads
### Requirement: Smooth scrolling behavior is consistently enabled
The system SHALL provide smooth scrolling behavior for in-page navigation and user-initiated scroll interactions.
#### Scenario: In-page navigation uses smooth scrolling
- **WHEN** users navigate to in-page anchors or equivalent interactions
- **THEN** scrolling transitions occur smoothly rather than jumping abruptly
- **AND** behavior is consistent across supported breakpoints

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Homepage publishes Newspaper structured data
The system SHALL expose a valid JSON-LD entity of type `Newspaper` on the homepage.
#### Scenario: Newspaper entity is emitted on homepage
- **WHEN** the homepage HTML is rendered
- **THEN** a JSON-LD script block includes an entity with `@type` set to `Newspaper`
- **AND** the entity includes stable publisher and site identity fields
#### Scenario: Newspaper entity remains language-aware
- **WHEN** homepage content is rendered in a selected language
- **THEN** the structured data includes language context for the active locale
- **AND** language output stays consistent with visible content language
### Requirement: Each rendered news item publishes NewsArticle structured data
The system SHALL expose a valid JSON-LD entity of type `NewsArticle` for each rendered news item in hero and feed contexts.
#### Scenario: NewsArticle entities include required semantic fields
- **WHEN** news items are present on the homepage
- **THEN** each `NewsArticle` entity includes headline, description, image, publication dates, and URL fields
- **AND** publisher and author context are present for each item
#### Scenario: Structured data avoids duplicate article entities
- **WHEN** article data appears across hero and feed sections
- **THEN** structured-data output deduplicates entities for the same article URL
- **AND** only one canonical semantic entry remains for each unique article

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Core SEO metadata is present on public pages
The system SHALL expose standards-compliant SEO metadata on the homepage and policy pages, including description, robots, canonical URL, and social preview metadata.
#### Scenario: Homepage metadata baseline exists
- **WHEN** a crawler or browser loads the homepage
- **THEN** the document includes `description`, `robots`, and canonical metadata
- **AND** Open Graph and Twitter card metadata fields are present with non-empty values
#### Scenario: Policy pages include indexable metadata
- **WHEN** a crawler loads `/terms` or `/attribution`
- **THEN** the page includes page-specific `title` and `description` metadata
- **AND** Open Graph and Twitter card metadata are present for link previews
### Requirement: Canonical and preview metadata remain deterministic
The system SHALL keep canonical and preview metadata deterministic for each route to avoid conflicting crawler signals.
#### Scenario: Canonical URL reflects active route
- **WHEN** metadata is rendered for a public route
- **THEN** exactly one canonical link is emitted for that route
- **AND** canonical metadata does not point to unrelated routes
#### Scenario: Social preview tags map to current page context
- **WHEN** the page metadata is generated or updated
- **THEN** `og:title`, `og:description`, and corresponding Twitter fields reflect the current page context
- **AND** preview image fields resolve to a valid absolute URL

View File

@@ -0,0 +1,34 @@
## 1. SEO Metadata and Social Tags
- [x] 1.1 Ensure homepage and policy pages expose required `title`, `description`, `robots`, and canonical metadata.
- [x] 1.2 Ensure Open Graph and Twitter metadata fields are present and mapped to current page context.
- [x] 1.3 Add verification checks for deterministic canonical URLs and valid absolute social image URLs.
## 2. Structured Data (Newspaper and NewsArticle)
- [x] 2.1 Implement and verify homepage `Newspaper` JSON-LD output with publisher/site identity fields.
- [x] 2.2 Implement and verify `NewsArticle` JSON-LD output for hero and feed items using required semantic fields.
- [x] 2.3 Add deduplication logic so repeated hero/feed references emit one semantic entity per article URL.
## 3. Delivery and Caching Performance
- [x] 3.1 Apply and validate gzip compression for eligible responses.
- [x] 3.2 Apply and validate explicit cache-control policies for static assets, APIs, and HTML routes.
- [x] 3.3 Verify route-level header behavior with repeatable checks and document expected header values.
## 4. Rendering Performance and UX
- [x] 4.1 Ensure non-critical images use lazy loading with explicit dimensions and async decoding hints.
- [x] 4.2 Ensure shimmer placeholders are visible until image load or fallback completion in feed and modal contexts.
- [x] 4.3 Ensure smooth scrolling behavior remains consistent for in-page navigation interactions.
## 5. Validation and Acceptance
- [x] 5.1 Validate structured data output for `Newspaper` and `NewsArticle` entities against schema expectations.
- [x] 5.2 Validate HTML/metadata output against project validation goals and resolve conformance gaps.
- [x] 5.3 Execute regression checks for homepage rendering, article card behavior, and policy page metadata.
## 6. Documentation
- [x] 6.1 Document SEO/structured-data contracts and performance header expectations in project docs.
- [x] 6.2 Document verification steps so future changes can re-run SEO and performance acceptance checks.

View File

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