Spaces:
Running
feat(frontend): infinite paginated feed and a hand-written design system
Browse filesThe feed used to end. REC_LIMIT (10) papers plus 2 exploration picks, then a
dead end β and dismissing shrank it further, so actively triaging drained the
page toward empty. That is an inbox, not a feed.
Feed
- GET /api/recommendations?page=N&query_id=β¦ The first request ranks a 60-deep
pool and caches the ORDER under its query_id; later pages replay it, so they
cost a metadata fetch rather than a recluster + re-retrieve + rescore.
Measured cold, one process: page 1 3506ms, page 2 1.6ms, no overlap.
- Exploration is drawn PER PAGE from a pre-shuffled pool, so doc 06 Β§3.5's "2
serendipitous papers per feed" holds for every page. Injecting once over the
pool would dilute the ratio and strand every pick on the last page, since
inject_exploration appends.
- Scroll-to-load is driven by an IntersectionObserver in app.js that clicks a
real <button>. Both htmx built-ins were tried against the deployment first:
`revealed` cannot fire when combined with a second trigger (htmx's poller
matches the attribute string exactly), and `intersect once` wired its handler
but never fired on real scrolling. Keeping a button means keyboard and
screen-reader users can still advance the feed, which NN/g flags as the usual
casualty of infinite scroll.
Dismissal
- Deferred 5s so Undo cancels the POST rather than compensating for it. A
dismissal is folded into the negative EWMA, and an EWMA is a lossy running
average with no exact inverse β an undone dismissal must never be sent at all.
Verified by watching the network: no request is issued when Undo is clicked.
CSS
- Tailwind Play CDN, DaisyUI, Google Fonts and the unpkg htmx are gone; htmx is
vendored. Zero external origins. Tailwind's docs call the Play CDN a
development tool, and its documented worst case β "elements added to the page
after first load" β is precisely an htmx-swapped card on an infinite feed.
- One hand-written stylesheet: warm near-black ground, a single green accent
reserved for Save, content in a serif and chrome in sans/mono, category chips
on Radix step semantics. Three theme states (system / explicit light /
explicit dark) with every colour defined in the bare :root, so nothing
depends on a media query being present.
Card
- The abstract's lead sentence is emphasised and the rest dimmed, approximating
Scholar Inbox's "highlight the sentence most related to the core idea"; NN/g's
F-pattern work finds attention decays sharply after the first lines.
- "Why this?" ships for real, derived from candidate_source, replacing the
Coming Soon badge that sat on every card.
- Metadata collapsed to one dim line; five facts no longer get five treatments.
Also: hero removed so the first paper is visible on load; onboarding states that
5 saves is the Tier-1 threshold (the gap PHASE8 flagged); groq_svc returns None
instead of raising when the package is absent, so a missing optional dependency
no longer turns /api/search/summary into a 500 and an error toast.
Tests
- tests/test_template_selectors.py β static, no server, no browser, 0.02s.
htmx passes hx-target straight to querySelectorAll unescaped (issues #1537,
#2601); an arXiv id's dot starts a class token, so "#actions-1706.03762" is
read as id="actions-1706" AND class="03762" and RAISES. Maintainer position is
that escaping is the application's job, and CSS.escape was rejected for 1.x
over IE11 β so pinned to 1.9.12 the rule is enforced here. Verified
adversarially: reintroducing either shipped bug fails the suite.
- tests/test_e2e_browser.py β Playwright. Asserts what only a browser sees.
11/11 against the deployment; locally it skips the feed cases when no metadata
sidecar is present rather than hanging.
- playwright stays out of requirements.txt (the Dockerfile installs that file
and would pull ~95 MB of Chromium into the image); requirements-dev.txt added.
Search deliberately keeps bounded results: NN/g endorses infinite scroll for
"homogeneous items with no particular task or goal in mind" and against it for
finding something specific.
Note on scope: this repository stays decoupled from researchit-space. The map
JSON API, its config and the 3D links live only in the deployment and are
deliberately absent here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- app/config.py +6 -0
- app/groq_svc.py +12 -1
- app/routers/events.py +17 -1
- app/routers/recommendations.py +277 -125
- app/static/animations.css +0 -149
- app/static/app.js +211 -255
- app/static/htmx.min.js +1 -0
- app/static/styles.css +657 -903
- app/templates/base.html +87 -103
- app/templates/index.html +22 -87
- app/templates/onboarding.html +75 -140
- app/templates/partials/action_buttons.html +82 -55
- app/templates/partials/ai_summary.html +4 -14
- app/templates/partials/empty_recs.html +11 -20
- app/templates/partials/paper_card.html +56 -80
- app/templates/partials/rec_page.html +55 -0
- app/templates/partials/recommendations.html +17 -64
- app/templates/partials/search_results.html +43 -80
- app/templates/partials/seed_results.html +23 -41
- app/templates/partials/seed_search.html +72 -72
- app/templates/partials/skeleton.html +19 -31
- app/templates/saved.html +19 -61
- app/templates/search.html +27 -59
- app/templates_env.py +171 -0
- requirements-dev.txt +16 -0
- tests/test_e2e_browser.py +382 -0
- tests/test_template_selectors.py +169 -0
|
@@ -5,6 +5,11 @@ All credentials live in .env locally; override with env vars in production.
|
|
| 5 |
import os
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
load_dotenv() # reads .env file if present (won't override existing env vars)
|
| 9 |
|
| 10 |
# ββ Qdrant (BGE-M3 dense, 1 024-dim) βββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -387,3 +392,4 @@ def expand_category_groups(group_keys: list[str]) -> set[str]:
|
|
| 387 |
if grp:
|
| 388 |
cats.update(grp["arxiv"])
|
| 389 |
return cats
|
|
|
|
|
|
| 5 |
import os
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
+
# .env.local first, then .env. load_dotenv does not overwrite a name that is
|
| 9 |
+
# already set, so the first file to define a key wins β which makes .env.local
|
| 10 |
+
# the local override and .env the shared default. Real environment variables
|
| 11 |
+
# still beat both, so nothing changes in the container.
|
| 12 |
+
load_dotenv(".env.local")
|
| 13 |
load_dotenv() # reads .env file if present (won't override existing env vars)
|
| 14 |
|
| 15 |
# ββ Qdrant (BGE-M3 dense, 1 024-dim) βββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 392 |
if grp:
|
| 393 |
cats.update(grp["arxiv"])
|
| 394 |
return cats
|
| 395 |
+
|
|
@@ -34,7 +34,18 @@ def _get_client():
|
|
| 34 |
if _client is not None:
|
| 35 |
return _client
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
_client = Groq(api_key=config.GROQ_API_KEY)
|
| 40 |
print("[groq_svc] Groq client initialized")
|
|
|
|
| 34 |
if _client is not None:
|
| 35 |
return _client
|
| 36 |
|
| 37 |
+
# Returning None rather than raising when the package is absent. Every
|
| 38 |
+
# other optional dependency here degrades quietly β BGE-M3, Zilliz,
|
| 39 |
+
# LightGBM and the metadata sidecar all fall back β but this one raised
|
| 40 |
+
# ModuleNotFoundError straight out of the request, so /api/search/summary
|
| 41 |
+
# answered 500 and the page showed "Something went wrong" instead of
|
| 42 |
+
# simply omitting the overview. The rewriter has the same call path, so
|
| 43 |
+
# a missing groq also broke search itself, not just the summary.
|
| 44 |
+
try:
|
| 45 |
+
from groq import Groq
|
| 46 |
+
except ImportError:
|
| 47 |
+
print("[groq_svc] groq not installed -- summaries and rewrite disabled")
|
| 48 |
+
return None
|
| 49 |
|
| 50 |
_client = Groq(api_key=config.GROQ_API_KEY)
|
| 51 |
print("[groq_svc] Groq client initialized")
|
|
@@ -54,10 +54,26 @@ async def save_paper(
|
|
| 54 |
# warms the in-process vector cache, which is the one that still matters.
|
| 55 |
asyncio.create_task(_update_profile_on_save(user_id, paper_id))
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
resp = templates.TemplateResponse(
|
| 58 |
request,
|
| 59 |
"partials/action_buttons.html",
|
| 60 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 63 |
return resp
|
|
|
|
| 54 |
# warms the in-process vector cache, which is the one that still matters.
|
| 55 |
asyncio.create_task(_update_profile_on_save(user_id, paper_id))
|
| 56 |
|
| 57 |
+
# The response replaces the whole actions row, so it has to carry enough
|
| 58 |
+
# context to re-render every control in it β including "Why this?", which
|
| 59 |
+
# is derived from candidate_source. Without these the button silently
|
| 60 |
+
# disappeared the moment a user saved the paper.
|
| 61 |
resp = templates.TemplateResponse(
|
| 62 |
request,
|
| 63 |
"partials/action_buttons.html",
|
| 64 |
+
{
|
| 65 |
+
"paper_id": paper_id,
|
| 66 |
+
"saved": True,
|
| 67 |
+
"dismissed": False,
|
| 68 |
+
"source": source,
|
| 69 |
+
"position": position,
|
| 70 |
+
"query_id": query_id,
|
| 71 |
+
"ranker_version": ranker_version,
|
| 72 |
+
"candidate_source": candidate_source,
|
| 73 |
+
"cluster_id": cluster_id,
|
| 74 |
+
"propensity": propensity,
|
| 75 |
+
"policy_id": policy_id,
|
| 76 |
+
},
|
| 77 |
)
|
| 78 |
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 79 |
return resp
|
|
@@ -16,8 +16,11 @@ Phase 4 changes vs Phase 2b:
|
|
| 16 |
- Category-level suppression filters strongly disliked topics (4.3)
|
| 17 |
"""
|
| 18 |
import asyncio
|
|
|
|
| 19 |
import time
|
| 20 |
import uuid
|
|
|
|
|
|
|
| 21 |
import numpy as np
|
| 22 |
from fastapi import APIRouter, Request, Cookie
|
| 23 |
from fastapi.responses import HTMLResponse
|
|
@@ -34,13 +37,17 @@ from app.recommend.clustering import (
|
|
| 34 |
)
|
| 35 |
from app.recommend.fusion import allocate_quotas, merge_quota_results
|
| 36 |
from app.recommend.reranker import rerank_candidates
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
router = APIRouter(prefix="/api")
|
| 40 |
|
| 41 |
# Phase 4.5: Pipeline version tag for instrumentation. Bump this on any
|
| 42 |
# change to the ranking logic so A/B attribution is possible.
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
# Minimum EWMA interactions before switching from ID-based to vector-based recs
|
| 46 |
_MIN_EWMA_INTERACTIONS = 3
|
|
@@ -51,165 +58,317 @@ _OVERSAMPLE = 3
|
|
| 51 |
# Short-term session context: fixed supplementary pool size
|
| 52 |
_ST_SUPPLEMENT = 20
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
@router.get("/recommendations", response_class=HTMLResponse)
|
| 56 |
async def get_recommendations(
|
| 57 |
request: Request,
|
|
|
|
|
|
|
| 58 |
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 59 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
user_id = user_id or str(uuid.uuid4())
|
| 61 |
state = await us.ensure_loaded(user_id)
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
| 65 |
|
| 66 |
def _empty_resp():
|
| 67 |
-
|
| 68 |
-
request,
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
if not state.has_enough_for_recs():
|
| 76 |
-
# ββ Tier 0: Category-filtered trending (Phase 5 cold-start) ββββββ
|
| 77 |
-
# If user has onboarded with category selections but hasn't saved
|
| 78 |
-
# enough papers yet, serve trending papers in their areas.
|
| 79 |
category_filter = await db.get_user_category_filter(user_id)
|
| 80 |
if category_filter:
|
| 81 |
trending = await turso_svc.fetch_trending_by_categories(
|
| 82 |
-
category_filter, limit=
|
| 83 |
)
|
| 84 |
if trending:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
return r
|
| 105 |
-
|
| 106 |
-
return _empty_resp()
|
| 107 |
|
| 108 |
seen = us.all_seen(user_id)
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
rec_arxiv_ids: list[str] = []
|
| 114 |
-
rerank_time_ms = 0
|
| 115 |
-
timing_breakdown: dict = {}
|
| 116 |
-
|
| 117 |
-
# ββ Tier 1: Multi-interest clustering + quota fusion (β₯5 saves) ββββββ
|
| 118 |
-
rec_arxiv_ids, paper_tags, rerank_time_ms, timing_breakdown = await _multi_interest_recommend(
|
| 119 |
-
user_id, state, seen, REC_LIMIT, query_id=query_id,
|
| 120 |
)
|
| 121 |
|
| 122 |
-
# ββ Tier 2: EWMA single-vector search (β₯3 saves) βββββββββββββββββββββ
|
| 123 |
-
if not
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
"ranker_version": _RANKER_VERSION,
|
| 128 |
"candidate_source": "ewma_longterm",
|
| 129 |
"cluster_id": "",
|
| 130 |
"query_id": query_id,
|
| 131 |
"propensity": 1.0,
|
| 132 |
"policy_id": _RANKER_VERSION,
|
| 133 |
-
}
|
|
|
|
| 134 |
|
| 135 |
-
# ββ Tier 3: Qdrant Recommend API (β₯1 save
|
| 136 |
-
if not
|
| 137 |
-
|
| 138 |
positive_arxiv_ids=state.positive_list,
|
| 139 |
negative_arxiv_ids=state.negative_list,
|
| 140 |
seen_arxiv_ids=seen,
|
| 141 |
-
limit=
|
| 142 |
)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
"ranker_version": _RANKER_VERSION,
|
| 146 |
"candidate_source": "qdrant_recommend",
|
| 147 |
"cluster_id": "",
|
| 148 |
"query_id": query_id,
|
| 149 |
"propensity": 1.0,
|
| 150 |
"policy_id": _RANKER_VERSION,
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
# Phase 3.5: Turso primary, arXiv API fallback
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
missing = [aid for aid in rec_arxiv_ids if aid not in meta]
|
| 160 |
if missing:
|
| 161 |
try:
|
| 162 |
-
|
| 163 |
-
meta.update(arxiv_meta)
|
| 164 |
except Exception as e:
|
| 165 |
print(f"[recommendations] arXiv fallback for {len(missing)} IDs failed: {e}")
|
| 166 |
-
t1_meta = time.time()
|
| 167 |
-
meta_time_ms = int((t1_meta - t0_meta) * 1000)
|
| 168 |
|
| 169 |
-
# Cache to SQLite so category
|
| 170 |
await db.cache_turso_metadata_batch(list(meta.values()))
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if aid not in meta:
|
| 175 |
continue
|
| 176 |
-
tags =
|
|
|
|
| 177 |
papers.append({
|
| 178 |
**meta[aid],
|
| 179 |
"saved": False,
|
| 180 |
"dismissed": False,
|
| 181 |
-
# Phase 4.5 instrumentation β embedded in card, flows back via HTMX
|
| 182 |
"ranker_version": tags.get("ranker_version", _RANKER_VERSION),
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
"
|
| 188 |
-
|
| 189 |
-
"
|
|
|
|
|
|
|
| 190 |
"policy_id": tags.get("policy_id", _RANKER_VERSION),
|
| 191 |
})
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
"
|
| 196 |
-
{
|
| 197 |
-
"papers": papers,
|
| 198 |
-
"rerank_time_ms": rerank_time_ms,
|
| 199 |
-
"meta_time_ms": meta_time_ms,
|
| 200 |
-
"timing": timing_breakdown,
|
| 201 |
-
},
|
| 202 |
)
|
| 203 |
-
|
| 204 |
-
return resp
|
| 205 |
-
|
| 206 |
-
|
| 207 |
# ββ Tier 1: Multi-interest clustering + quota fusion βββββββββββββββββββββββββ
|
| 208 |
|
| 209 |
async def _multi_interest_recommend(
|
| 210 |
user_id: str, state, seen: set[str], limit: int,
|
| 211 |
*, query_id: str = "",
|
| 212 |
-
) -> tuple[list[str], dict[str, dict]]:
|
| 213 |
"""
|
| 214 |
Full recommendation pipeline (Phase 2b + Phase 4 corrections):
|
| 215 |
1. Ward clustering β identify distinct interests
|
|
@@ -226,13 +385,13 @@ async def _multi_interest_recommend(
|
|
| 226 |
"""
|
| 227 |
positives = state.positive_list
|
| 228 |
if len(positives) < MIN_PAPERS_FOR_CLUSTERING:
|
| 229 |
-
return [], {}, 0, {}
|
| 230 |
|
| 231 |
try:
|
| 232 |
# Fetch embeddings for all saved papers
|
| 233 |
vectors = await qdrant_svc.get_paper_vectors(positives)
|
| 234 |
if len(vectors) < MIN_PAPERS_FOR_CLUSTERING:
|
| 235 |
-
return [], {}, 0, {}
|
| 236 |
|
| 237 |
timing = {} # Collect per-stage timing breakdown
|
| 238 |
|
|
@@ -355,7 +514,7 @@ async def _multi_interest_recommend(
|
|
| 355 |
qdrant_score_map[aid] = float(hit["score"])
|
| 356 |
|
| 357 |
if not candidate_ids:
|
| 358 |
-
return [], {}, 0, {}
|
| 359 |
timing["ann_retrieval_ms"] = int((time.time() - t0_ann) * 1000)
|
| 360 |
|
| 361 |
# ββ Step 5: Fetch candidate vectors + metadata ββββββββββββββββββββ
|
|
@@ -379,7 +538,7 @@ async def _multi_interest_recommend(
|
|
| 379 |
# Only process candidates with both vectors and metadata
|
| 380 |
valid_ids = [cid for cid in candidate_ids if cid in cand_vectors and cid in cand_meta]
|
| 381 |
if not valid_ids:
|
| 382 |
-
return candidate_ids[:limit], {}, 0, {}
|
| 383 |
timing["candidate_meta_ms"] = int((time.time() - t0_cand_meta) * 1000)
|
| 384 |
|
| 385 |
valid_embs = np.array([cand_vectors[cid] for cid in valid_ids], dtype=np.float32)
|
|
@@ -508,31 +667,24 @@ async def _multi_interest_recommend(
|
|
| 508 |
)
|
| 509 |
timing["mmr_ms"] = int((time.time() - t0_mmr) * 1000)
|
| 510 |
|
| 511 |
-
# ββ Step 8:
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
# Phase 4.5 + 6.5: Build per-paper instrumentation tags
|
| 520 |
-
exploration_set = set(final) - set(mmr_selected)
|
| 521 |
-
|
| 522 |
-
# Phase 6.5 B2: Compute propensity for counterfactual evaluation
|
| 523 |
-
# MMR-selected papers are deterministic β propensity = 1.0
|
| 524 |
-
# Exploration papers are randomly sampled β propensity = n_explore / pool_size
|
| 525 |
mmr_set = set(mmr_selected)
|
| 526 |
-
|
| 527 |
-
n_actual_explore = len(exploration_set)
|
| 528 |
-
explore_propensity = n_actual_explore / explore_pool_size if explore_pool_size > 0 else 0.0
|
| 529 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
paper_tags: dict[str, dict] = {}
|
| 531 |
-
for aid in
|
| 532 |
cluster_idx = paper_cluster_map.get(aid)
|
| 533 |
-
if
|
| 534 |
-
source = "exploration"
|
| 535 |
-
elif cluster_idx == -1:
|
| 536 |
source = "short_term_supplement"
|
| 537 |
elif cluster_idx is not None:
|
| 538 |
source = f"cluster_{cluster_idx}"
|
|
@@ -543,15 +695,15 @@ async def _multi_interest_recommend(
|
|
| 543 |
"candidate_source": source,
|
| 544 |
"cluster_id": str(cluster_idx) if cluster_idx is not None and cluster_idx >= 0 else "",
|
| 545 |
"query_id": query_id,
|
| 546 |
-
"propensity":
|
| 547 |
"policy_id": _RANKER_VERSION,
|
| 548 |
}
|
| 549 |
|
| 550 |
-
return
|
| 551 |
|
| 552 |
except Exception as e:
|
| 553 |
print(f"[recommendations] multi-interest preprocessing failed: {e}")
|
| 554 |
-
return [], {}, 0, {}
|
| 555 |
|
| 556 |
|
| 557 |
# ββ Tier 2: EWMA single-vector search ββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 16 |
- Category-level suppression filters strongly disliked topics (4.3)
|
| 17 |
"""
|
| 18 |
import asyncio
|
| 19 |
+
import random
|
| 20 |
import time
|
| 21 |
import uuid
|
| 22 |
+
from collections import OrderedDict
|
| 23 |
+
|
| 24 |
import numpy as np
|
| 25 |
from fastapi import APIRouter, Request, Cookie
|
| 26 |
from fastapi.responses import HTMLResponse
|
|
|
|
| 37 |
)
|
| 38 |
from app.recommend.fusion import allocate_quotas, merge_quota_results
|
| 39 |
from app.recommend.reranker import rerank_candidates
|
| 40 |
+
# inject_exploration is no longer imported here: exploration is now drawn per
|
| 41 |
+
# page in _build_page() from a pre-shuffled pool, so that doc 06 Β§3.5's "two
|
| 42 |
+
# serendipitous papers per feed" holds for every page rather than once per pool.
|
| 43 |
+
from app.recommend.diversity import mmr_rerank
|
| 44 |
|
| 45 |
router = APIRouter(prefix="/api")
|
| 46 |
|
| 47 |
# Phase 4.5: Pipeline version tag for instrumentation. Bump this on any
|
| 48 |
# change to the ranking logic so A/B attribution is possible.
|
| 49 |
+
# v7 = paginated serving; ranking itself is unchanged.
|
| 50 |
+
_RANKER_VERSION = "v7.0_paginated_feed"
|
| 51 |
|
| 52 |
# Minimum EWMA interactions before switching from ID-based to vector-based recs
|
| 53 |
_MIN_EWMA_INTERACTIONS = 3
|
|
|
|
| 58 |
# Short-term session context: fixed supplementary pool size
|
| 59 |
_ST_SUPPLEMENT = 20
|
| 60 |
|
| 61 |
+
# ββ Paginated feed βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
#
|
| 63 |
+
# The feed used to terminate: REC_LIMIT (10) papers plus 2 exploration picks,
|
| 64 |
+
# then a dead end with a "show different recommendations" button. Dismissing
|
| 65 |
+
# shrank it further, so triaging drained the page toward empty.
|
| 66 |
+
#
|
| 67 |
+
# Rather than re-running the pipeline per page β it reclusters, re-retrieves
|
| 68 |
+
# and rescores, and its exploration step is random, so page 2 would both cost
|
| 69 |
+
# seconds and risk repeating page 1 β the first request ranks a deep pool once
|
| 70 |
+
# and caches the ORDER under its query_id. Later pages are then a metadata
|
| 71 |
+
# fetch and nothing else.
|
| 72 |
+
#
|
| 73 |
+
# Keying on query_id rather than user_id is deliberate: query_id already exists
|
| 74 |
+
# to group one feed's impressions for per-feed CTR (Phase 6.5 B1), so a feed
|
| 75 |
+
# and its cache entry share a lifetime, and two tabs get two independent feeds.
|
| 76 |
+
|
| 77 |
+
_PAGE_SIZE = REC_LIMIT # ranked papers per page
|
| 78 |
+
_FEED_POOL = 60 # how deep MMR ranks on the first request
|
| 79 |
+
_N_EXPLORE = 2 # exploration picks PER PAGE (doc 06 Β§3.5)
|
| 80 |
+
|
| 81 |
+
# Tier 0 gets a shallower pool than the behavioural tiers. Trending is a
|
| 82 |
+
# LIKE '%code%' scan plus a temp B-tree sort over 1.6M rows β the single most
|
| 83 |
+
# expensive query in the system, and the one that lands on brand-new users.
|
| 84 |
+
# Asking it for 60 rows instead of 10 made it time out entirely against Turso
|
| 85 |
+
# without the local sidecar. Three pages is plenty before behavioural signal
|
| 86 |
+
# takes over, and the sidecar makes this an index range read in production.
|
| 87 |
+
_TRENDING_POOL = 30
|
| 88 |
+
|
| 89 |
+
_FEED_CACHE: "OrderedDict[str, dict]" = OrderedDict()
|
| 90 |
+
_FEED_CACHE_MAX = 200
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _cache_put(query_id: str, entry: dict) -> None:
|
| 94 |
+
_FEED_CACHE[query_id] = entry
|
| 95 |
+
_FEED_CACHE.move_to_end(query_id)
|
| 96 |
+
while len(_FEED_CACHE) > _FEED_CACHE_MAX:
|
| 97 |
+
_FEED_CACHE.popitem(last=False)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _cache_get(query_id: str) -> dict | None:
|
| 101 |
+
entry = _FEED_CACHE.get(query_id)
|
| 102 |
+
if entry is not None:
|
| 103 |
+
_FEED_CACHE.move_to_end(query_id)
|
| 104 |
+
return entry
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def feed_cache_stats() -> dict:
|
| 108 |
+
"""For diagnostics parity with the other in-process caches."""
|
| 109 |
+
return {"size": len(_FEED_CACHE), "max": _FEED_CACHE_MAX}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _take(pool: list[str], entry: dict, seen: set[str], n: int) -> list[str]:
|
| 113 |
+
"""Pull the next n unserved ids from `pool`.
|
| 114 |
+
|
| 115 |
+
Tracks what has already been emitted on the entry rather than slicing by
|
| 116 |
+
page index, because `seen` grows while the user reads: a paper saved on
|
| 117 |
+
page 1 disappears from the pool, and index arithmetic would then silently
|
| 118 |
+
skip its neighbour.
|
| 119 |
+
"""
|
| 120 |
+
out: list[str] = []
|
| 121 |
+
emitted = entry["emitted"]
|
| 122 |
+
for aid in pool:
|
| 123 |
+
if len(out) >= n:
|
| 124 |
+
break
|
| 125 |
+
if aid in emitted or aid in seen:
|
| 126 |
+
continue
|
| 127 |
+
out.append(aid)
|
| 128 |
+
emitted.add(aid)
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
|
| 132 |
@router.get("/recommendations", response_class=HTMLResponse)
|
| 133 |
async def get_recommendations(
|
| 134 |
request: Request,
|
| 135 |
+
page: int = 1,
|
| 136 |
+
query_id: str | None = None,
|
| 137 |
user_id: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
| 138 |
):
|
| 139 |
+
"""
|
| 140 |
+
Serve one page of the feed.
|
| 141 |
+
|
| 142 |
+
page=1 (or an unknown query_id) runs the full pipeline, ranks a deep pool
|
| 143 |
+
and caches the ordering. Later pages replay that ordering, so they cost a
|
| 144 |
+
metadata fetch instead of a recluster + re-retrieve + rescore.
|
| 145 |
+
"""
|
| 146 |
user_id = user_id or str(uuid.uuid4())
|
| 147 |
state = await us.ensure_loaded(user_id)
|
| 148 |
+
page = max(1, page)
|
| 149 |
|
| 150 |
+
def _with_cookie(resp):
|
| 151 |
+
resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
|
| 152 |
+
return resp
|
| 153 |
|
| 154 |
def _empty_resp():
|
| 155 |
+
return _with_cookie(templates.TemplateResponse(
|
| 156 |
+
request, "partials/empty_recs.html", {"min_saves": REC_MIN_POSITIVES},
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
# Continue an existing feed only when the loader asks for a later page AND
|
| 160 |
+
# we still hold its ordering. A cache miss (restart, eviction) falls through
|
| 161 |
+
# to a fresh feed rather than erroring β the user sees new papers, which is
|
| 162 |
+
# the correct failure mode for a feed.
|
| 163 |
+
entry = _cache_get(query_id) if (query_id and page > 1) else None
|
| 164 |
+
|
| 165 |
+
if entry is None:
|
| 166 |
+
query_id = str(uuid.uuid4())
|
| 167 |
+
page = 1
|
| 168 |
+
entry = await _build_feed(user_id, state, query_id)
|
| 169 |
+
if entry is None:
|
| 170 |
+
return _empty_resp()
|
| 171 |
+
_cache_put(query_id, entry)
|
| 172 |
+
|
| 173 |
+
seen = us.all_seen(user_id)
|
| 174 |
+
papers, has_more = await _build_page(entry, seen)
|
| 175 |
+
|
| 176 |
+
if not papers:
|
| 177 |
+
return _empty_resp() if page == 1 else _with_cookie(
|
| 178 |
+
templates.TemplateResponse(
|
| 179 |
+
request, "partials/rec_page.html",
|
| 180 |
+
{"papers": [], "has_more": False,
|
| 181 |
+
"next_page": page + 1, "query_id": query_id},
|
| 182 |
+
))
|
| 183 |
+
|
| 184 |
+
# Page 1 brings the .feed wrapper; later pages are bare fragments that the
|
| 185 |
+
# loader button swaps itself out for.
|
| 186 |
+
template = "partials/recommendations.html" if page == 1 else "partials/rec_page.html"
|
| 187 |
+
|
| 188 |
+
return _with_cookie(templates.TemplateResponse(
|
| 189 |
+
request, template,
|
| 190 |
+
{
|
| 191 |
+
"papers": papers,
|
| 192 |
+
"has_more": has_more,
|
| 193 |
+
"next_page": page + 1,
|
| 194 |
+
"query_id": entry["query_id"],
|
| 195 |
+
"trending": entry.get("trending", False),
|
| 196 |
+
},
|
| 197 |
+
))
|
| 198 |
+
|
| 199 |
|
| 200 |
+
# ββ Feed construction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 201 |
+
|
| 202 |
+
async def _build_feed(user_id: str, state, query_id: str) -> dict | None:
|
| 203 |
+
"""
|
| 204 |
+
Run the tier cascade once and return a cacheable feed entry, or None when
|
| 205 |
+
there is nothing to show.
|
| 206 |
+
|
| 207 |
+
entry = {
|
| 208 |
+
ranked ordered ids β the MMR-diversified feed
|
| 209 |
+
explore shuffled leftover candidates, drawn from for serendipity
|
| 210 |
+
tags {arxiv_id: instrumentation dict}
|
| 211 |
+
emitted ids already served on some page of this feed
|
| 212 |
+
position running global rank, so `position` is continuous across pages
|
| 213 |
+
}
|
| 214 |
+
"""
|
| 215 |
+
base = {
|
| 216 |
+
"query_id": query_id,
|
| 217 |
+
"emitted": set(),
|
| 218 |
+
"position": 0,
|
| 219 |
+
"trending": False,
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
# ββ Tier 0: category trending (cold start, Phase 5) ββββββββββββββββββ
|
| 223 |
if not state.has_enough_for_recs():
|
|
|
|
|
|
|
|
|
|
| 224 |
category_filter = await db.get_user_category_filter(user_id)
|
| 225 |
if category_filter:
|
| 226 |
trending = await turso_svc.fetch_trending_by_categories(
|
| 227 |
+
category_filter, limit=_TRENDING_POOL,
|
| 228 |
)
|
| 229 |
if trending:
|
| 230 |
+
ids = [p["arxiv_id"] for p in trending if p.get("arxiv_id")]
|
| 231 |
+
if ids:
|
| 232 |
+
return {
|
| 233 |
+
**base,
|
| 234 |
+
"trending": True,
|
| 235 |
+
"ranked": ids,
|
| 236 |
+
"explore": [],
|
| 237 |
+
"tags": {
|
| 238 |
+
aid: {
|
| 239 |
+
"ranker_version": _RANKER_VERSION,
|
| 240 |
+
"candidate_source": "trending_category_fallback",
|
| 241 |
+
"cluster_id": "",
|
| 242 |
+
"query_id": query_id,
|
| 243 |
+
"propensity": 1.0,
|
| 244 |
+
"policy_id": _RANKER_VERSION,
|
| 245 |
+
} for aid in ids
|
| 246 |
+
},
|
| 247 |
+
}
|
| 248 |
+
return None
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
seen = us.all_seen(user_id)
|
| 251 |
|
| 252 |
+
# ββ Tier 1: multi-interest clustering + quota fusion (β₯5 saves) ββββββ
|
| 253 |
+
ranked, explore, tags, _rerank_ms, _timing = await _multi_interest_recommend(
|
| 254 |
+
user_id, state, seen, _FEED_POOL, query_id=query_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
)
|
| 256 |
|
| 257 |
+
# ββ Tier 2: EWMA single-vector search (β₯3 saves) βββββββββββββββββββββ
|
| 258 |
+
if not ranked:
|
| 259 |
+
ranked = await _ewma_recommend(user_id, seen, _FEED_POOL)
|
| 260 |
+
explore, tags = [], {
|
| 261 |
+
aid: {
|
| 262 |
"ranker_version": _RANKER_VERSION,
|
| 263 |
"candidate_source": "ewma_longterm",
|
| 264 |
"cluster_id": "",
|
| 265 |
"query_id": query_id,
|
| 266 |
"propensity": 1.0,
|
| 267 |
"policy_id": _RANKER_VERSION,
|
| 268 |
+
} for aid in ranked
|
| 269 |
+
}
|
| 270 |
|
| 271 |
+
# ββ Tier 3: Qdrant Recommend API (β₯1 save) βββββββββββββββββββββββββββ
|
| 272 |
+
if not ranked:
|
| 273 |
+
ranked = await qdrant_svc.recommend(
|
| 274 |
positive_arxiv_ids=state.positive_list,
|
| 275 |
negative_arxiv_ids=state.negative_list,
|
| 276 |
seen_arxiv_ids=seen,
|
| 277 |
+
limit=_FEED_POOL,
|
| 278 |
)
|
| 279 |
+
explore, tags = [], {
|
| 280 |
+
aid: {
|
| 281 |
"ranker_version": _RANKER_VERSION,
|
| 282 |
"candidate_source": "qdrant_recommend",
|
| 283 |
"cluster_id": "",
|
| 284 |
"query_id": query_id,
|
| 285 |
"propensity": 1.0,
|
| 286 |
"policy_id": _RANKER_VERSION,
|
| 287 |
+
} for aid in ranked
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
if not ranked:
|
| 291 |
+
return None
|
| 292 |
+
|
| 293 |
+
# Shuffled once, then drawn from in order. Doc 06 Β§3.5 calls for
|
| 294 |
+
# SERENDIPITOUS picks β taking the pool's head instead would just serve
|
| 295 |
+
# "next best by score", which is not the same thing. Shuffling here rather
|
| 296 |
+
# than sampling per page keeps picks non-repeating across pages for free.
|
| 297 |
+
explore = list(explore)
|
| 298 |
+
random.shuffle(explore)
|
| 299 |
+
|
| 300 |
+
return {**base, "ranked": ranked, "explore": explore, "tags": tags}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
async def _build_page(entry: dict, seen: set[str]) -> tuple[list[dict], bool]:
|
| 304 |
+
"""Materialise the next page: _PAGE_SIZE ranked papers + _N_EXPLORE picks."""
|
| 305 |
+
core = _take(entry["ranked"], entry, seen, _PAGE_SIZE)
|
| 306 |
+
# Exploration rides along with ranked papers; it never carries a page on its
|
| 307 |
+
# own. Without this guard the feed could report "that's everything" (has_more
|
| 308 |
+
# is computed over `ranked`) and then still hand back an exploration-only
|
| 309 |
+
# page to anyone who asked for the next one.
|
| 310 |
+
explore = (
|
| 311 |
+
_take(entry["explore"], entry, seen, _N_EXPLORE)
|
| 312 |
+
if core and entry["explore"] else []
|
| 313 |
+
)
|
| 314 |
+
ids = core + explore
|
| 315 |
+
if not ids:
|
| 316 |
+
return [], False
|
| 317 |
|
| 318 |
+
# Phase 3.5: Turso primary (sidecar-backed), arXiv API fallback.
|
| 319 |
+
meta = await turso_svc.fetch_metadata_batch(ids)
|
| 320 |
+
missing = [aid for aid in ids if aid not in meta]
|
|
|
|
| 321 |
if missing:
|
| 322 |
try:
|
| 323 |
+
meta.update(await arxiv_svc.fetch_metadata_batch(missing))
|
|
|
|
| 324 |
except Exception as e:
|
| 325 |
print(f"[recommendations] arXiv fallback for {len(missing)} IDs failed: {e}")
|
|
|
|
|
|
|
| 326 |
|
| 327 |
+
# Cache to SQLite so category-suppression JOINs work (Phase 4.3)
|
| 328 |
await db.cache_turso_metadata_batch(list(meta.values()))
|
| 329 |
|
| 330 |
+
explore_set = set(explore)
|
| 331 |
+
# Phase 6.5 B2: probability this policy chose to show an exploration paper
|
| 332 |
+
# on this page β the fraction of the pool drawn. Deterministic slots are 1.0.
|
| 333 |
+
explore_propensity = (
|
| 334 |
+
len(explore) / len(entry["explore"]) if entry["explore"] else 0.0
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
papers: list[dict] = []
|
| 338 |
+
for aid in ids:
|
| 339 |
if aid not in meta:
|
| 340 |
continue
|
| 341 |
+
tags = entry["tags"].get(aid, {})
|
| 342 |
+
is_explore = aid in explore_set
|
| 343 |
papers.append({
|
| 344 |
**meta[aid],
|
| 345 |
"saved": False,
|
| 346 |
"dismissed": False,
|
|
|
|
| 347 |
"ranker_version": tags.get("ranker_version", _RANKER_VERSION),
|
| 348 |
+
# Serving as an exploration pick overrides the retrieval origin β
|
| 349 |
+
# the same paper is only "exploration" by virtue of how it was shown.
|
| 350 |
+
"candidate_source": "exploration" if is_explore
|
| 351 |
+
else tags.get("candidate_source", ""),
|
| 352 |
+
"cluster_id": "" if is_explore else tags.get("cluster_id", ""),
|
| 353 |
+
"query_id": entry["query_id"],
|
| 354 |
+
"position": entry["position"],
|
| 355 |
+
"propensity": explore_propensity if is_explore
|
| 356 |
+
else tags.get("propensity", 1.0),
|
| 357 |
"policy_id": tags.get("policy_id", _RANKER_VERSION),
|
| 358 |
})
|
| 359 |
+
entry["position"] += 1
|
| 360 |
|
| 361 |
+
has_more = any(
|
| 362 |
+
aid not in entry["emitted"] and aid not in seen
|
| 363 |
+
for aid in entry["ranked"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
)
|
| 365 |
+
return papers, has_more
|
|
|
|
|
|
|
|
|
|
| 366 |
# ββ Tier 1: Multi-interest clustering + quota fusion βββββββββββββββββββββββββ
|
| 367 |
|
| 368 |
async def _multi_interest_recommend(
|
| 369 |
user_id: str, state, seen: set[str], limit: int,
|
| 370 |
*, query_id: str = "",
|
| 371 |
+
) -> tuple[list[str], list[str], dict[str, dict], int, dict]:
|
| 372 |
"""
|
| 373 |
Full recommendation pipeline (Phase 2b + Phase 4 corrections):
|
| 374 |
1. Ward clustering β identify distinct interests
|
|
|
|
| 385 |
"""
|
| 386 |
positives = state.positive_list
|
| 387 |
if len(positives) < MIN_PAPERS_FOR_CLUSTERING:
|
| 388 |
+
return [], [], {}, 0, {}
|
| 389 |
|
| 390 |
try:
|
| 391 |
# Fetch embeddings for all saved papers
|
| 392 |
vectors = await qdrant_svc.get_paper_vectors(positives)
|
| 393 |
if len(vectors) < MIN_PAPERS_FOR_CLUSTERING:
|
| 394 |
+
return [], [], {}, 0, {}
|
| 395 |
|
| 396 |
timing = {} # Collect per-stage timing breakdown
|
| 397 |
|
|
|
|
| 514 |
qdrant_score_map[aid] = float(hit["score"])
|
| 515 |
|
| 516 |
if not candidate_ids:
|
| 517 |
+
return [], [], {}, 0, {}
|
| 518 |
timing["ann_retrieval_ms"] = int((time.time() - t0_ann) * 1000)
|
| 519 |
|
| 520 |
# ββ Step 5: Fetch candidate vectors + metadata ββββββββββββββββββββ
|
|
|
|
| 538 |
# Only process candidates with both vectors and metadata
|
| 539 |
valid_ids = [cid for cid in candidate_ids if cid in cand_vectors and cid in cand_meta]
|
| 540 |
if not valid_ids:
|
| 541 |
+
return candidate_ids[:limit], [], {}, 0, {}
|
| 542 |
timing["candidate_meta_ms"] = int((time.time() - t0_cand_meta) * 1000)
|
| 543 |
|
| 544 |
valid_embs = np.array([cand_vectors[cid] for cid in valid_ids], dtype=np.float32)
|
|
|
|
| 667 |
)
|
| 668 |
timing["mmr_ms"] = int((time.time() - t0_mmr) * 1000)
|
| 669 |
|
| 670 |
+
# ββ Step 8: Split into the ranked feed and the exploration pool βββ
|
| 671 |
+
# Exploration is NOT injected here any more. Doc 06 Β§3.5 specifies two
|
| 672 |
+
# serendipitous papers per FEED, and with pagination a "feed" is one
|
| 673 |
+
# page β so the injection happens per page in _build_page(), against
|
| 674 |
+
# this pool. Injecting once over a 60-deep pool would have both diluted
|
| 675 |
+
# the ratio and stranded every exploration pick on the last page,
|
| 676 |
+
# because inject_exploration appends.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
mmr_set = set(mmr_selected)
|
| 678 |
+
explore_pool = [aid for aid in reranked_ids if aid not in mmr_set]
|
|
|
|
|
|
|
| 679 |
|
| 680 |
+
# Phase 4.5 + 6.5: per-paper instrumentation, for the whole pool.
|
| 681 |
+
# candidate_source here is the RETRIEVAL origin; papers served as an
|
| 682 |
+
# exploration pick get that overridden at page-build time, since the
|
| 683 |
+
# same paper is an exploration pick only by virtue of how it was served.
|
| 684 |
paper_tags: dict[str, dict] = {}
|
| 685 |
+
for aid in mmr_selected + explore_pool:
|
| 686 |
cluster_idx = paper_cluster_map.get(aid)
|
| 687 |
+
if cluster_idx == -1:
|
|
|
|
|
|
|
| 688 |
source = "short_term_supplement"
|
| 689 |
elif cluster_idx is not None:
|
| 690 |
source = f"cluster_{cluster_idx}"
|
|
|
|
| 695 |
"candidate_source": source,
|
| 696 |
"cluster_id": str(cluster_idx) if cluster_idx is not None and cluster_idx >= 0 else "",
|
| 697 |
"query_id": query_id,
|
| 698 |
+
"propensity": 1.0, # deterministic unless served as exploration
|
| 699 |
"policy_id": _RANKER_VERSION,
|
| 700 |
}
|
| 701 |
|
| 702 |
+
return mmr_selected, explore_pool, paper_tags, rerank_time_ms, timing
|
| 703 |
|
| 704 |
except Exception as e:
|
| 705 |
print(f"[recommendations] multi-interest preprocessing failed: {e}")
|
| 706 |
+
return [], [], {}, 0, {}
|
| 707 |
|
| 708 |
|
| 709 |
# ββ Tier 2: EWMA single-vector search ββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
-
ResearchIT β Animations
|
| 3 |
-
All @keyframes and animation utility classes.
|
| 4 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 5 |
-
|
| 6 |
-
/* ββ Skeleton shimmer βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7 |
-
@keyframes shimmer {
|
| 8 |
-
0% { background-position: -200% 0; }
|
| 9 |
-
100% { background-position: 200% 0; }
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
/* ββ Fade in with upward slide ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 13 |
-
@keyframes fadeInUp {
|
| 14 |
-
from {
|
| 15 |
-
opacity: 0;
|
| 16 |
-
transform: translateY(10px);
|
| 17 |
-
}
|
| 18 |
-
to {
|
| 19 |
-
opacity: 1;
|
| 20 |
-
transform: translateY(0);
|
| 21 |
-
}
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
/* ββ Fade in only βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 25 |
-
@keyframes fadeIn {
|
| 26 |
-
from { opacity: 0; }
|
| 27 |
-
to { opacity: 1; }
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
/* ββ Toast slide in/out βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 31 |
-
@keyframes toastIn {
|
| 32 |
-
from {
|
| 33 |
-
opacity: 0;
|
| 34 |
-
transform: translateX(40px) scale(0.95);
|
| 35 |
-
}
|
| 36 |
-
to {
|
| 37 |
-
opacity: 1;
|
| 38 |
-
transform: translateX(0) scale(1);
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
@keyframes toastOut {
|
| 43 |
-
from {
|
| 44 |
-
opacity: 1;
|
| 45 |
-
transform: translateX(0) scale(1);
|
| 46 |
-
}
|
| 47 |
-
to {
|
| 48 |
-
opacity: 0;
|
| 49 |
-
transform: translateX(40px) scale(0.95);
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
/* ββ Save button pulse ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 54 |
-
@keyframes savePulse {
|
| 55 |
-
0% { transform: scale(1); }
|
| 56 |
-
40% { transform: scale(1.12); }
|
| 57 |
-
100% { transform: scale(1); }
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
/* ββ Checkmark pop (onboarding tile) ββββββββββββββββββββββββββββββββββββββββ */
|
| 61 |
-
@keyframes checkPop {
|
| 62 |
-
0% { transform: scale(0); }
|
| 63 |
-
60% { transform: scale(1.2); }
|
| 64 |
-
100% { transform: scale(1); }
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
/* ββ Loading dot pulse ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 68 |
-
@keyframes dotPulse {
|
| 69 |
-
0%, 80%, 100% {
|
| 70 |
-
opacity: 0.3;
|
| 71 |
-
transform: scale(0.8);
|
| 72 |
-
}
|
| 73 |
-
40% {
|
| 74 |
-
opacity: 1;
|
| 75 |
-
transform: scale(1.1);
|
| 76 |
-
}
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
/* ββ Card stagger entrance ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 80 |
-
@keyframes cardIn {
|
| 81 |
-
from {
|
| 82 |
-
opacity: 0;
|
| 83 |
-
transform: translateY(16px);
|
| 84 |
-
}
|
| 85 |
-
to {
|
| 86 |
-
opacity: 1;
|
| 87 |
-
transform: translateY(0);
|
| 88 |
-
}
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
/* ββ Subtle glow pulse (hero, active elements) ββββββββββββββββββββββββββββββ */
|
| 92 |
-
@keyframes glowPulse {
|
| 93 |
-
0%, 100% { opacity: 0.4; }
|
| 94 |
-
50% { opacity: 0.7; }
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
/* ββ Spin (loading indicators) ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 98 |
-
@keyframes spin {
|
| 99 |
-
from { transform: rotate(0deg); }
|
| 100 |
-
to { transform: rotate(360deg); }
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
/* ββ Gradient shift (brand/hero background) βββββββββββββββββββββββββββββββββ */
|
| 104 |
-
@keyframes gradientShift {
|
| 105 |
-
0% { background-position: 0% 50%; }
|
| 106 |
-
50% { background-position: 100% 50%; }
|
| 107 |
-
100% { background-position: 0% 50%; }
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 111 |
-
/* Utility animation classes */
|
| 112 |
-
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 113 |
-
|
| 114 |
-
.animate-fade-in {
|
| 115 |
-
animation: fadeIn 0.3s ease-out;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
.animate-fade-in-up {
|
| 119 |
-
animation: fadeInUp 0.4s ease-out;
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
.animate-card-in {
|
| 123 |
-
animation: cardIn 0.35s ease-out both;
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
/* Staggered card entrance β assign via inline style or nth-child */
|
| 127 |
-
.animate-card-in:nth-child(1) { animation-delay: 0s; }
|
| 128 |
-
.animate-card-in:nth-child(2) { animation-delay: 0.05s; }
|
| 129 |
-
.animate-card-in:nth-child(3) { animation-delay: 0.1s; }
|
| 130 |
-
.animate-card-in:nth-child(4) { animation-delay: 0.15s; }
|
| 131 |
-
.animate-card-in:nth-child(5) { animation-delay: 0.2s; }
|
| 132 |
-
.animate-card-in:nth-child(6) { animation-delay: 0.25s; }
|
| 133 |
-
.animate-card-in:nth-child(7) { animation-delay: 0.3s; }
|
| 134 |
-
.animate-card-in:nth-child(8) { animation-delay: 0.35s; }
|
| 135 |
-
.animate-card-in:nth-child(9) { animation-delay: 0.4s; }
|
| 136 |
-
.animate-card-in:nth-child(10) { animation-delay: 0.45s; }
|
| 137 |
-
|
| 138 |
-
.animate-glow {
|
| 139 |
-
animation: glowPulse 3s ease-in-out infinite;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
.animate-spin {
|
| 143 |
-
animation: spin 1s linear infinite;
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
.animate-gradient {
|
| 147 |
-
background-size: 200% 200%;
|
| 148 |
-
animation: gradientShift 4s ease infinite;
|
| 149 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,282 +1,238 @@
|
|
| 1 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
-
ResearchIT β
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
function toggleTheme() {
|
| 10 |
-
const html = document.documentElement;
|
| 11 |
-
const current = html.getAttribute('data-theme');
|
| 12 |
-
const next = current === 'dark' ? 'light' : 'dark';
|
| 13 |
-
html.setAttribute('data-theme', next);
|
| 14 |
-
localStorage.setItem('researchit-theme', next);
|
| 15 |
-
updateThemeIcons();
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
function updateThemeIcons() {
|
| 19 |
-
const theme = document.documentElement.getAttribute('data-theme');
|
| 20 |
-
// Desktop icon
|
| 21 |
-
document.querySelectorAll('.theme-icon-sun').forEach(el => {
|
| 22 |
-
el.classList.toggle('hidden', theme !== 'dark');
|
| 23 |
-
});
|
| 24 |
-
document.querySelectorAll('.theme-icon-moon').forEach(el => {
|
| 25 |
-
el.classList.toggle('hidden', theme !== 'light');
|
| 26 |
-
});
|
| 27 |
-
// Mobile text
|
| 28 |
-
document.querySelectorAll('.theme-text-dark').forEach(el => {
|
| 29 |
-
el.classList.toggle('hidden', theme !== 'dark');
|
| 30 |
-
});
|
| 31 |
-
document.querySelectorAll('.theme-text-light').forEach(el => {
|
| 32 |
-
el.classList.toggle('hidden', theme !== 'light');
|
| 33 |
-
});
|
| 34 |
-
}
|
| 35 |
|
| 36 |
-
/
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
};
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
'Ranking the best matches for youβ¦',
|
| 72 |
-
'Almost there β curating resultsβ¦',
|
| 73 |
-
];
|
| 74 |
-
this.index = 0;
|
| 75 |
-
this.timer = null;
|
| 76 |
}
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
this._show();
|
| 85 |
-
}, intervalMs);
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
`;
|
| 102 |
}
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
// Global progress message instances
|
| 106 |
-
let searchProgress = null;
|
| 107 |
-
let recProgress = null;
|
| 108 |
-
|
| 109 |
-
// ββ Abstract Expand / Collapse βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
-
|
| 111 |
-
function toggleAbstract(btn) {
|
| 112 |
-
const card = btn.closest('.paper-card');
|
| 113 |
-
if (!card) return;
|
| 114 |
-
const abstract = card.querySelector('.abstract-text');
|
| 115 |
-
if (!abstract) return;
|
| 116 |
-
|
| 117 |
-
const isExpanded = abstract.classList.contains('expanded');
|
| 118 |
-
abstract.classList.toggle('expanded');
|
| 119 |
-
btn.textContent = isExpanded ? 'β Show more' : 'β Show less';
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
// ββ Coming Soon Click Handler ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
-
|
| 124 |
-
function handleComingSoon(e) {
|
| 125 |
-
e.preventDefault();
|
| 126 |
-
e.stopPropagation();
|
| 127 |
-
showToast('This feature is coming soon!', 'info', 2500);
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
// ββ Search Loading State βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
-
|
| 132 |
-
function initSearchHandlers() {
|
| 133 |
-
const searchForm = document.getElementById('search-form');
|
| 134 |
-
if (!searchForm) return;
|
| 135 |
-
|
| 136 |
-
searchProgress = new ProgressMessages('search-progress-msgs', [
|
| 137 |
-
'Rewriting query with Groq AIβ¦',
|
| 138 |
-
'Encoding with BGE-M3 embeddingsβ¦',
|
| 139 |
-
'Searching Qdrant + Zilliz vectorsβ¦',
|
| 140 |
-
'Fusing results with RRFβ¦',
|
| 141 |
-
'Reranking the best matchesβ¦',
|
| 142 |
-
'Almost thereβ¦',
|
| 143 |
-
]);
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
// ββ Recommendation Loading State βββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
-
|
| 148 |
-
function initRecHandlers() {
|
| 149 |
-
recProgress = new ProgressMessages('rec-progress-msgs', [
|
| 150 |
-
'Analyzing your interest clustersβ¦',
|
| 151 |
-
'Searching across 1.6M papersβ¦',
|
| 152 |
-
'Running LightGBM rerankerβ¦',
|
| 153 |
-
'Diversifying with MMRβ¦',
|
| 154 |
-
'Curating your personal feedβ¦',
|
| 155 |
-
]);
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
// ββ HTMX Global Event Handlers βββββββββββββββββββββββββββββββββββββββοΏ½οΏ½βββββββ
|
| 159 |
-
|
| 160 |
-
document.addEventListener('DOMContentLoaded', function () {
|
| 161 |
-
updateThemeIcons();
|
| 162 |
-
initSearchHandlers();
|
| 163 |
-
initRecHandlers();
|
| 164 |
-
|
| 165 |
-
// ββ Before HTMX Request ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
-
document.body.addEventListener('htmx:beforeRequest', function (evt) {
|
| 167 |
-
const target = evt.detail.target;
|
| 168 |
-
if (!target) return;
|
| 169 |
-
|
| 170 |
-
// Search results loading
|
| 171 |
-
if (target.id === 'search-results') {
|
| 172 |
-
const loadingEl = document.getElementById('search-loading');
|
| 173 |
-
const resultsEl = document.getElementById('search-results');
|
| 174 |
-
if (loadingEl) loadingEl.classList.remove('hidden');
|
| 175 |
-
if (resultsEl) resultsEl.classList.add('opacity-20', 'pointer-events-none');
|
| 176 |
-
// Hide recs
|
| 177 |
-
const recWrapper = document.getElementById('rec-wrapper');
|
| 178 |
-
if (recWrapper) recWrapper.classList.add('hidden');
|
| 179 |
-
// Swap button text
|
| 180 |
-
document.querySelectorAll('.search-btn-text').forEach(el => el.classList.add('hidden'));
|
| 181 |
-
document.querySelectorAll('.search-btn-loading').forEach(el => el.classList.remove('hidden'));
|
| 182 |
-
// Start progress messages
|
| 183 |
-
if (searchProgress) searchProgress.start(2000);
|
| 184 |
-
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
const target = evt.detail.target;
|
| 195 |
-
if (!target) return;
|
| 196 |
-
|
| 197 |
-
// Search results loaded
|
| 198 |
-
if (target.id === 'search-results') {
|
| 199 |
-
const loadingEl = document.getElementById('search-loading');
|
| 200 |
-
const resultsEl = document.getElementById('search-results');
|
| 201 |
-
if (loadingEl) loadingEl.classList.add('hidden');
|
| 202 |
-
if (resultsEl) resultsEl.classList.remove('opacity-20', 'pointer-events-none');
|
| 203 |
-
// Restore button
|
| 204 |
-
document.querySelectorAll('.search-btn-text').forEach(el => el.classList.remove('hidden'));
|
| 205 |
-
document.querySelectorAll('.search-btn-loading').forEach(el => el.classList.add('hidden'));
|
| 206 |
-
// Stop progress messages
|
| 207 |
-
if (searchProgress) searchProgress.stop();
|
| 208 |
-
}
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
-
/
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
if (
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
<p class="text-sm opacity-60 mb-4">The request took longer than expected. Please try again.</p>
|
| 227 |
-
<button class="btn btn-primary btn-sm" onclick="location.reload()">
|
| 228 |
-
β» Try again
|
| 229 |
-
</button>
|
| 230 |
-
</div>
|
| 231 |
-
`;
|
| 232 |
-
}
|
| 233 |
-
// Stop any progress messages
|
| 234 |
-
if (searchProgress) searchProgress.stop();
|
| 235 |
-
if (recProgress) recProgress.stop();
|
| 236 |
-
}
|
| 237 |
-
});
|
| 238 |
|
| 239 |
-
/
|
| 240 |
-
// After a save action completes, show a toast
|
| 241 |
-
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
| 242 |
-
const target = evt.detail.target;
|
| 243 |
-
if (!target) return;
|
| 244 |
-
|
| 245 |
-
// Check if this was a save action (the target id starts with 'actions-')
|
| 246 |
-
if (target.id && target.id.startsWith('actions-')) {
|
| 247 |
-
// Check if it now contains a "saved" state
|
| 248 |
-
const savedBtn = target.querySelector('.rit-btn-save.saved, .btn-success');
|
| 249 |
-
if (savedBtn) {
|
| 250 |
-
savedBtn.classList.add('save-success');
|
| 251 |
-
showToast('Paper saved to your library', 'success', 2500);
|
| 252 |
-
}
|
| 253 |
-
}
|
| 254 |
|
| 255 |
-
|
| 256 |
-
if (
|
| 257 |
-
|
| 258 |
-
}
|
| 259 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
-
/
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
});
|
| 282 |
-
});
|
|
|
|
| 1 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
ResearchIT β client script
|
| 3 |
+
|
| 4 |
+
Deliberately small. Everything that htmx can express in markup is left in
|
| 5 |
+
markup; this file covers only the three things it cannot:
|
| 6 |
+
|
| 7 |
+
1. theme persistence
|
| 8 |
+
2. progressive disclosure (abstract, "why this?")
|
| 9 |
+
3. DEFERRED DISMISSAL, which is the interesting one β see below
|
| 10 |
+
|
| 11 |
+
Removed from the previous version: the rotating "Encoding your query with
|
| 12 |
+
BGE-M3β¦" progress messages. They narrated the implementation to someone who
|
| 13 |
+
only wanted papers, and the skeleton already communicates "loading".
|
| 14 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 15 |
|
| 16 |
+
(function () {
|
| 17 |
+
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
/* How long Undo stays available. Long enough to catch a misfire, short
|
| 20 |
+
enough that the pending queue never grows meaningfully. */
|
| 21 |
+
var UNDO_MS = 5000;
|
| 22 |
|
| 23 |
+
/* ββ Theme βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
Only ever writes an explicit choice. With nothing stored the document
|
| 25 |
+
stays unstamped and follows prefers-color-scheme, so a user who has
|
| 26 |
+
never touched the toggle tracks their OS. Icon swapping is pure CSS. */
|
| 27 |
|
| 28 |
+
function currentTheme() {
|
| 29 |
+
var explicit = document.documentElement.getAttribute('data-theme');
|
| 30 |
+
if (explicit) return explicit;
|
| 31 |
+
return window.matchMedia &&
|
| 32 |
+
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
window.toggleTheme = function () {
|
| 36 |
+
var next = currentTheme() === 'dark' ? 'light' : 'dark';
|
| 37 |
+
document.documentElement.setAttribute('data-theme', next);
|
| 38 |
+
try { localStorage.setItem('researchit-theme', next); } catch (e) { /* private mode */ }
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
/* ββ Progressive disclosure ββββββββββββββββββββββββββββββββββββββββββ */
|
| 42 |
+
|
| 43 |
+
window.toggleAbstract = function (btn) {
|
| 44 |
+
var p = document.getElementById(btn.getAttribute('aria-controls'));
|
| 45 |
+
if (!p) return;
|
| 46 |
+
var clamped = p.classList.toggle('is-clamped');
|
| 47 |
+
btn.setAttribute('aria-expanded', String(!clamped));
|
| 48 |
+
btn.textContent = clamped ? 'Read more' : 'Show less';
|
| 49 |
};
|
| 50 |
|
| 51 |
+
window.toggleWhy = function (btn, id) {
|
| 52 |
+
var p = document.getElementById('why-' + id);
|
| 53 |
+
if (!p) return;
|
| 54 |
+
var hidden = p.classList.toggle('hidden');
|
| 55 |
+
btn.setAttribute('aria-expanded', String(!hidden));
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
/* ββ Deferred dismissal + undo βββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
The card is hidden immediately, but the POST is held for UNDO_MS. Undo
|
| 60 |
+
cancels the request rather than compensating for it.
|
| 61 |
+
|
| 62 |
+
Why not commit-then-reverse: a dismissal is folded into the user's
|
| 63 |
+
negative EWMA profile, and an EWMA is a lossy running average with no
|
| 64 |
+
exact inverse. A reversed dismissal would leave a permanent smudge on the
|
| 65 |
+
profile. Deferring keeps it out entirely.
|
| 66 |
+
|
| 67 |
+
NN/g's rule for repetitive destructive actions is undo over confirmation,
|
| 68 |
+
since a confirm dialog charges everyone who meant it. */
|
| 69 |
+
|
| 70 |
+
var pending = new Map(); // arxivId -> { timer, card, vals }
|
| 71 |
+
|
| 72 |
+
function endpointFor(id) {
|
| 73 |
+
return '/api/papers/' + encodeURIComponent(id) + '/not-interested';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
|
| 76 |
+
function payloadFor(vals) {
|
| 77 |
+
var body = new FormData();
|
| 78 |
+
Object.keys(vals || {}).forEach(function (k) {
|
| 79 |
+
if (vals[k] !== null && vals[k] !== undefined) body.append(k, vals[k]);
|
| 80 |
+
});
|
| 81 |
+
return body;
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
+
function commitDismiss(id) {
|
| 85 |
+
var p = pending.get(id);
|
| 86 |
+
if (!p) return;
|
| 87 |
+
pending.delete(id);
|
| 88 |
+
fetch(endpointFor(id), { method: 'POST', body: payloadFor(p.vals) })
|
| 89 |
+
.catch(function () { /* the card is already gone; a lost dismissal is
|
| 90 |
+
recoverable, an error dialog here is not worth it */ });
|
| 91 |
+
if (p.card && p.card.parentNode) p.card.remove();
|
| 92 |
+
topUpFeed();
|
| 93 |
}
|
| 94 |
|
| 95 |
+
function undoDismiss(id) {
|
| 96 |
+
var p = pending.get(id);
|
| 97 |
+
if (!p) return;
|
| 98 |
+
clearTimeout(p.timer);
|
| 99 |
+
pending.delete(id);
|
| 100 |
+
if (p.card) p.card.classList.remove('is-leaving');
|
|
|
|
| 101 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
function dismissPaper(btn) {
|
| 104 |
+
var id = btn.getAttribute('data-paper-id');
|
| 105 |
+
if (!id || pending.has(id)) return;
|
| 106 |
+
var card = btn.closest('.card');
|
| 107 |
+
if (!card) return;
|
| 108 |
|
| 109 |
+
var vals = {};
|
| 110 |
+
try { vals = JSON.parse(btn.getAttribute('data-vals') || '{}'); } catch (e) { vals = {}; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
card.classList.add('is-leaving');
|
| 113 |
+
pending.set(id, {
|
| 114 |
+
card: card,
|
| 115 |
+
vals: vals,
|
| 116 |
+
timer: setTimeout(function () { commitDismiss(id); }, UNDO_MS)
|
| 117 |
+
});
|
| 118 |
+
showUndoToast(id);
|
| 119 |
+
}
|
| 120 |
|
| 121 |
+
/* Leaving the page with dismissals still pending would silently drop them.
|
| 122 |
+
sendBeacon survives unload, unlike fetch. */
|
| 123 |
+
function flushPending() {
|
| 124 |
+
pending.forEach(function (p, id) {
|
| 125 |
+
clearTimeout(p.timer);
|
| 126 |
+
if (navigator.sendBeacon) navigator.sendBeacon(endpointFor(id), payloadFor(p.vals));
|
| 127 |
+
});
|
| 128 |
+
pending.clear();
|
| 129 |
+
}
|
| 130 |
+
window.addEventListener('pagehide', flushPending);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
/* ββ Toasts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
function closeToast(el) {
|
| 135 |
+
if (!el || el.classList.contains('is-out')) return;
|
| 136 |
+
el.classList.add('is-out');
|
| 137 |
+
setTimeout(function () { if (el.parentNode) el.remove(); }, 200);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function makeToast(message) {
|
| 141 |
+
var host = document.getElementById('toasts');
|
| 142 |
+
if (!host) return null;
|
| 143 |
+
var t = document.createElement('div');
|
| 144 |
+
t.className = 'toast';
|
| 145 |
+
var msg = document.createElement('span');
|
| 146 |
+
msg.className = 'msg';
|
| 147 |
+
msg.textContent = message; // textContent, never innerHTML
|
| 148 |
+
t.appendChild(msg);
|
| 149 |
+
host.appendChild(t);
|
| 150 |
+
return t;
|
| 151 |
+
}
|
| 152 |
|
| 153 |
+
function showUndoToast(id) {
|
| 154 |
+
var t = makeToast('Removed from your feed');
|
| 155 |
+
if (!t) return;
|
| 156 |
+
var undo = document.createElement('button');
|
| 157 |
+
undo.type = 'button';
|
| 158 |
+
undo.className = 'undo';
|
| 159 |
+
undo.textContent = 'Undo';
|
| 160 |
+
undo.addEventListener('click', function () {
|
| 161 |
+
undoDismiss(id);
|
| 162 |
+
closeToast(t);
|
| 163 |
});
|
| 164 |
+
t.appendChild(undo);
|
| 165 |
+
setTimeout(function () { closeToast(t); }, UNDO_MS);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function showError(message) {
|
| 169 |
+
var t = makeToast(message);
|
| 170 |
+
if (t) setTimeout(function () { closeToast(t); }, 4000);
|
| 171 |
}
|
| 172 |
|
| 173 |
+
/* ββ Infinite scroll βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 174 |
+
The loader button carries only hx-trigger="click"; this observer supplies
|
| 175 |
+
the scroll half by clicking it when it comes near the viewport.
|
| 176 |
+
|
| 177 |
+
Both htmx built-ins were tried against the deployed build first and are
|
| 178 |
+
documented in rec_page.html: `revealed` cannot fire when combined with a
|
| 179 |
+
second trigger (htmx's poller matches the attribute exactly), and
|
| 180 |
+
`intersect once` wired its handler but never fired on real scrolling.
|
| 181 |
+
Owning the observer is deterministic and testable.
|
| 182 |
+
|
| 183 |
+
rootMargin pre-loads a screen early so the next page is usually already
|
| 184 |
+
in place by the time the user reaches the bottom. */
|
| 185 |
+
|
| 186 |
+
var feedObserver = null;
|
| 187 |
+
|
| 188 |
+
function watchLoader() {
|
| 189 |
+
var more = document.querySelector('.feed-more');
|
| 190 |
+
if (!more) return;
|
| 191 |
+
if (!feedObserver) {
|
| 192 |
+
feedObserver = new IntersectionObserver(function (entries) {
|
| 193 |
+
entries.forEach(function (e) {
|
| 194 |
+
if (!e.isIntersecting) return;
|
| 195 |
+
var el = e.target;
|
| 196 |
+
feedObserver.unobserve(el); // one shot per loader
|
| 197 |
+
if (!el.classList.contains('htmx-request')) el.click();
|
| 198 |
+
});
|
| 199 |
+
}, { rootMargin: '800px 0px' });
|
| 200 |
}
|
| 201 |
+
if (more.dataset.watched !== '1') {
|
| 202 |
+
more.dataset.watched = '1';
|
| 203 |
+
feedObserver.observe(more);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* Each appended page brings its own loader, so re-arm after every swap. */
|
| 208 |
+
document.body.addEventListener('htmx:afterSwap', watchLoader);
|
| 209 |
+
document.addEventListener('DOMContentLoaded', watchLoader);
|
| 210 |
+
watchLoader();
|
| 211 |
+
|
| 212 |
+
/* ββ Keep the feed populated βββββββββββββββββββββββββββββββββββββββββββ
|
| 213 |
+
Dismissing shrinks the list. Without this the feed drains toward empty
|
| 214 |
+
as the user triages, which is the opposite of what a feed should do. */
|
| 215 |
+
|
| 216 |
+
function topUpFeed() {
|
| 217 |
+
var feed = document.querySelector('.feed');
|
| 218 |
+
if (!feed) return;
|
| 219 |
+
if (feed.querySelectorAll('.card:not(.is-leaving)').length > 3) return;
|
| 220 |
+
var more = document.querySelector('.feed-more');
|
| 221 |
+
if (more && !more.classList.contains('htmx-request')) more.click();
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* ββ Wiring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 225 |
+
Delegated so htmx-appended pages need no re-binding. */
|
| 226 |
+
|
| 227 |
+
document.addEventListener('click', function (e) {
|
| 228 |
+
var btn = e.target.closest && e.target.closest('[data-dismiss]');
|
| 229 |
+
if (btn) { e.preventDefault(); dismissPaper(btn); }
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
document.body.addEventListener('htmx:responseError', function () {
|
| 233 |
+
showError('Something went wrong. Please try again.');
|
| 234 |
+
});
|
| 235 |
+
document.body.addEventListener('htmx:sendError', function () {
|
| 236 |
+
showError('Connection lost. Check your network.');
|
| 237 |
});
|
| 238 |
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=s("<body><template>"+n+"</template></body>",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("<table>"+n+"</table>",1);case"col":return s("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return s("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return s("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return s("<div>"+n+"</div>",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n<r.length;n++){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING){return i}}};var Y=function(e,t){var r=re().querySelectorAll(t);for(var n=r.length-1;n>=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r<t.length;r++){if(e===t[r]){return true}}return false}function we(t,r){oe(t.attributes,function(e){if(!r.hasAttribute(e.name)&&be(e.name)){t.removeAttribute(e.name)}});oe(r.attributes,function(e){if(be(e.name)){t.setAttribute(e.name,e.value)}})}function Se(e,t){var r=Fr(t);for(var n=0;n<r.length;n++){var i=r[n];try{if(i.isInlineSwap(e)){return true}}catch(e){b(e)}}return e==="outerHTML"}function Ee(e,i,a){var t="#"+ee(i,"id");var o="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a<i.length;a++){var o=i[a].split(":",2);var s=o[0].trim();if(s.indexOf("#")===0){s=s.substring(1)}var l=o[1]||"true";var u=t.querySelector("#"+s);if(u){Ee(l,u,r)}}}oe(f(t,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){var t=te(e,"hx-swap-oob");if(t!=null){Ee(t,e,r)}})}function Re(e){oe(f(e,"[hx-preserve], [data-hx-preserve]"),function(e){var t=te(e,"id");var r=re().getElementById(t);if(r!=null){e.parentNode.replaceChild(r,e)}})}function Te(o,e,s){oe(e.querySelectorAll("[id]"),function(e){var t=ee(e,"id");if(t&&t.length>0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r<e.length){t=(t<<5)-t+e.charCodeAt(r++)|0}return t}function Le(e){var t=0;if(e.attributes){for(var r=0;r<e.attributes.length;r++){var n=e.attributes[r];if(n.value){t=He(n.name,t);t=He(n.value,t)}}}return t}function Ae(e){var t=ae(e);if(t.onHandlers){for(var r=0;r<t.onHandlers.length;r++){const n=t.onHandlers[r];e.removeEventListener(n.event,n.listener)}delete t.onHandlers}}function Ne(e){var t=ae(e);if(t.timeout){clearTimeout(t.timeout)}if(t.webSocket){t.webSocket.close()}if(t.sseEventSource){t.sseEventSource.close()}if(t.listenerInfos){oe(t.listenerInfos,function(e){if(e.on){e.on.removeEventListener(e.trigger,e.listener)}})}Ae(e);oe(Object.keys(t),function(e){delete t[e]})}function m(e){ce(e,"htmx:beforeCleanupElement");Ne(e);if(e.children){oe(e.children,function(e){m(e)})}}function Ie(t,e,r){if(t.tagName==="BODY"){return Ue(t,e,r)}else{var n;var i=t.previousSibling;a(u(t),t,e,r);if(i==null){n=u(t).firstChild}else{n=i.nextSibling}r.elts=r.elts.filter(function(e){return e!=t});while(n&&n!==t){if(n.nodeType===Node.ELEMENT_NODE){r.elts.push(n)}n=n.nextElementSibling}m(t);u(t).removeChild(t)}}function ke(e,t,r){return a(e,e.firstChild,t,r)}function Pe(e,t,r){return a(u(e),e,t,r)}function Me(e,t,r){return a(e,null,t,r)}function Xe(e,t,r){return a(u(e),e.nextSibling,t,r)}function De(e,t,r){m(e);return u(e).removeChild(e)}function Ue(e,t,r){var n=e.firstChild;a(e,n,t,r);if(n){while(n.nextSibling){m(n.nextSibling);e.removeChild(n.nextSibling)}m(n);e.removeChild(n)}}function Be(e,t,r){var n=r||ne(e,"hx-select");if(n){var i=re().createDocumentFragment();oe(t.querySelectorAll(n),function(e){i.appendChild(e)});t=i}return t}function Fe(e,t,r,n,i){switch(e){case"none":return;case"outerHTML":Ie(r,n,i);return;case"afterbegin":ke(r,n,i);return;case"beforebegin":Pe(r,n,i);return;case"beforeend":Me(r,n,i);return;case"afterend":Xe(r,n,i);return;case"delete":De(r,n,i);return;default:var a=Fr(t);for(var o=0;o<a.length;o++){var s=a[o];try{var l=s.handleSwap(e,r,n,i);if(l){if(typeof l.length!=="undefined"){for(var u=0;u<l.length;u++){var f=l[u];if(f.nodeType!==Node.TEXT_NODE&&f.nodeType!==Node.COMMENT_NODE){i.tasks.push(Oe(f))}}}return}}catch(e){b(e)}}if(e==="innerHTML"){Ue(r,n,i)}else{Fe(Q.config.defaultSwapStyle,t,r,n,i)}}}function Ve(e){if(e.indexOf("<title")>-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l<s.length;l++){ce(r,s[l].trim(),[])}}}var ze=/\s/;var x=/[\s,]/;var $e=/[_$a-zA-Z]/;var We=/[_$a-zA-Z0-9]/;var Ge=['"',"'","/"];var Je=/[^\s]/;var Ze=/[{(]/;var Ke=/[})]/;function Ye(e){var t=[];var r=0;while(r<e.length){if($e.exec(e.charAt(r))){var n=r;while(We.exec(e.charAt(r+1))){r++}t.push(e.substr(n,r-n+1))}else if(Ge.indexOf(e.charAt(r))!==-1){var i=e.charAt(r);var n=r;r++;while(r<e.length&&e.charAt(r)!==i){if(e.charAt(r)==="\\"){r++}r++}t.push(e.substr(n,r-n+1))}else{var a=e.charAt(r);t.push(a)}r++}return t}function Qe(e,t,r){return $e.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==r&&t!=="."}function et(e,t,r){if(t[0]==="["){t.shift();var n=1;var i=" return (function("+r+"){ return (";var a=null;while(t.length>0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){xt(e,a[1],0)}if(a[0]==="send"){bt(e)}}}function xt(s,r,n){if(!se(s)){return}if(r.indexOf("/")==0){var e=location.hostname+(location.port?":"+location.port:"");if(location.protocol=="https:"){r="wss://"+e+r}else if(location.protocol=="http:"){r="ws://"+e+r}}var t=Q.createWebSocket(r);t.onerror=function(e){fe(s,"htmx:wsError",{error:e,socket:t});yt(s)};t.onclose=function(e){if([1006,1012,1013].indexOf(e.code)>=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a<i.length;a++){var o=i[a];Ee(te(o,"hx-swap-oob")||"true",o,r)}nr(r.tasks)})}function yt(e){if(!se(e)){ae(e).webSocket.close();return true}}function bt(u){var f=c(u,function(e){return ae(e).webSocket!=null});if(f){u.addEventListener(it(u)[0].trigger,function(e){var t=ae(f).webSocket;var r=xr(u,f);var n=dr(u,"post");var i=n.errors;var a=n.values;var o=Hr(u);var s=le(a,o);var l=yr(s,u);l["HEADERS"]=r;if(i&&i.length>0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){Et(e,a[1])}if(a[0]==="swap"){Ct(e,a[1])}}}function Et(t,e){var r=Q.createEventSource(e);r.onerror=function(e){fe(t,"htmx:sseError",{error:e,source:r});Tt(t)};ae(t).sseEventSource=r}function Ct(a,o){var s=c(a,Ot);if(s){var l=ae(s).sseEventSource;var u=function(e){if(Tt(s)){return}if(!se(a)){l.removeEventListener(o,u);return}var t=e.data;R(a,function(e){t=e.transformResponse(t,null,a)});var r=wr(a);var n=ye(a);var i=T(a);je(r.swapStyle,n,a,t,i);nr(i.tasks);ce(a,"htmx:sseMessage",e)};ae(a).sseListener=u;l.addEventListener(o,u)}else{fe(a,"htmx:noSSESourceError")}}function Rt(e,t,r){var n=c(e,Ot);if(n){var i=ae(n).sseEventSource;var a=function(){if(!Tt(n)){if(se(e)){t(e)}else{i.removeEventListener(r,a)}}};ae(e).sseListener=a;i.addEventListener(r,a)}else{fe(e,"htmx:noSSESourceError")}}function Tt(e){if(!se(e)){ae(e).sseEventSource.close();return true}}function Ot(e){return ae(e).sseEventSource!=null}function qt(e,t,r,n){var i=function(){if(!r.loaded){r.loaded=true;t(e)}};if(n>0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t<e.length;t++){var r=e[t];if(r.isIntersecting){ce(n,"intersect");break}}},i);a.observe(n);ht(n,r,t,e)}else if(e.trigger==="load"){if(!ct(e,n,Wt("load",{elt:n}))){qt(n,r,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r<t.length;r++){var n=t[r].name;if(g(n,"hx-on:")||g(n,"data-hx-on:")||g(n,"hx-on-")||g(n,"data-hx-on-")){return true}}return false}function kt(e){var t=null;var r=[];if(It(e)){r.push(e)}if(document.evaluate){var n=document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]',e);while(t=n.iterateNext())r.push(t)}else if(typeof e.getElementsByTagName==="function"){var i=e.getElementsByTagName("*");for(var a=0;a<i.length;a++){if(It(i[a])){r.push(i[a])}}}return r}function Pt(e){if(e.querySelectorAll){var t=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";var r=e.querySelectorAll(i+t+", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws],"+" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");return r}else{return[]}}function Mt(e){var t=v(e.target,"button, input[type='submit']");var r=Dt(e);if(r){r.lastButtonClicked=t}}function Xt(e){var t=Dt(e);if(t){t.lastButtonClicked=null}}function Dt(e){var t=v(e.target,"button, input[type='submit']");if(!t){return}var r=p("#"+ee(t,"form"))||v(t,"form");if(!r){return}return ae(r)}function Ut(e){e.addEventListener("click",Mt);e.addEventListener("focusin",Mt);e.addEventListener("focusout",Xt)}function Bt(e){var t=Ye(e);var r=0;for(var n=0;n<t.length;n++){const i=t[n];if(i==="{"){r++}else if(i==="}"){r--}}return r}function Ft(t,e,r){var n=ae(t);if(!Array.isArray(n.onHandlers)){n.onHandlers=[]}var i;var a=function(e){return Tr(t,function(){if(!i){i=new Function("event",r)}i.call(t,e)})};t.addEventListener(e,a);n.onHandlers.push({event:e,listener:a})}function Vt(e){var t=te(e,"hx-on");if(t){var r={};var n=t.split("\n");var i=null;var a=0;while(n.length>0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;t<e.attributes.length;t++){var r=e.attributes[t].name;var n=e.attributes[t].value;if(g(r,"hx-on")||g(r,"data-hx-on")){var i=r.indexOf("-on")+3;var a=r.slice(i,i+1);if(a==="-"||a===":"){var o=r.slice(i+1);if(g(o,":")){o="htmx"+o}else if(g(o,"-")){o="htmx:"+o.slice(1)}else if(g(o,"htmx-")){o="htmx:"+o.slice(5)}Ft(e,o,n)}}}}function _t(t){if(v(t,Q.config.disableSelector)){m(t);return}var r=ae(t);if(r.initHash!==Le(t)){Ne(t);r.initHash=Le(t);Vt(t);ce(t,"htmx:beforeProcessNode");if(t.value){r.lastValue=t.value}var e=it(t);var n=Ht(t,r,e);if(!n){if(ne(t,"hx-boost")==="true"){lt(t,r,e)}else if(o(t,"hx-trigger")){e.forEach(function(e){Lt(t,e,r,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&o(t,"form")){Ut(t)}var i=te(t,"hx-sse");if(i){St(t,r,i)}var a=te(t,"hx-ws");if(a){mt(t,r,a)}ce(t,"htmx:afterProcessNode")}}function zt(e){e=p(e);if(v(e,Q.config.disableSelector)){m(e);return}_t(e);oe(Pt(e),function(e){_t(e)});oe(kt(e),jt)}function $t(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Wt(e,t){var r;if(window.CustomEvent&&typeof window.CustomEvent==="function"){r=new CustomEvent(e,{bubbles:true,cancelable:true,detail:t})}else{r=re().createEvent("CustomEvent");r.initCustomEvent(e,true,true,t)}return r}function fe(e,t,r){ce(e,t,le({error:t},r))}function Gt(e){return e==="htmx:afterProcessNode"}function R(e,t){oe(Fr(e),function(e){try{t(e)}catch(e){b(e)}})}function b(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function ce(e,t,r){e=p(e);if(r==null){r={}}r["elt"]=e;var n=Wt(t,r);if(Q.logger&&!Gt(t)){Q.logger(e,t,r)}if(r.error){b(r.error);ce(e,"htmx:error",{errorInfo:r})}var i=e.dispatchEvent(n);var a=$t(t);if(i&&a!==t){var o=Wt(a,n.detail);i=i&&e.dispatchEvent(o)}R(e,function(e){i=i&&(e.onEvent(t,n)!==false&&!n.defaultPrevented)});return i}var Jt=location.pathname+location.search;function Zt(){var e=re().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||re().body}function Kt(e,t,r,n){if(!U()){return}if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}e=B(e);var i=E(localStorage.getItem("htmx-history-cache"))||[];for(var a=0;a<i.length;a++){if(i[a].url===e){i.splice(a,1);break}}var o={url:e,content:t,title:r,scroll:n};ce(re().body,"htmx:historyItemCreated",{item:o,cache:i});i.push(o);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r<t.length;r++){if(t[r].url===e){return t[r]}}return null}function Qt(e){var t=Q.config.requestClass;var r=e.cloneNode(true);oe(f(r,"."+t),function(e){n(e,t)});return r.innerHTML}function er(){var e=Zt();var t=Jt||location.pathname+location.search;var r;try{r=re().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){r=re().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!r){ce(re().body,"htmx:beforeHistorySave",{path:t,historyElt:e});Kt(t,Qt(e),re().title,window.scrollY)}if(Q.config.historyEnabled)history.replaceState({htmx:true},re().title,window.location.href)}function tr(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(G(e,"&")||G(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Jt=e}function rr(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Jt=e}function nr(e){oe(e,function(e){e.call()})}function ir(a){var e=new XMLHttpRequest;var o={path:a,xhr:e};ce(re().body,"htmx:historyCacheMiss",o);e.open("GET",a,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",re().location.href);e.onload=function(){if(this.status>=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r<e.length;r++){var n=e[r];if(n.isSameNode(t)){return true}}return false}function fr(e){if(e.name===""||e.name==null||e.disabled||v(e,"fieldset[disabled]")){return false}if(e.type==="button"||e.type==="submit"||e.tagName==="image"||e.tagName==="reset"||e.tagName==="file"){return false}if(e.type==="checkbox"||e.type==="radio"){return e.checked}return true}function cr(e,t,r){if(e!=null&&t!=null){var n=r[e];if(n===undefined){r[e]=t}else if(Array.isArray(n)){if(Array.isArray(t)){r[e]=n.concat(t)}else{n.push(t)}}else{if(Array.isArray(t)){r[e]=[n].concat(t)}else{r[e]=[n,t]}}}}function hr(t,r,n,e,i){if(e==null||ur(t,e)){return}else{t.push(e)}if(fr(e)){var a=ee(e,"name");var o=e.value;if(e.multiple&&e.tagName==="SELECT"){o=M(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e.files){o=M(e.files)}cr(a,o,r);if(i){vr(e,n)}}if(h(e,"form")){var s=e.elements;oe(s,function(e){hr(t,r,n,e,i)})}}function vr(e,t){if(e.willValidate){ce(e,"htmx:validation:validate");if(!e.checkValidity()){t.push({elt:e,message:e.validationMessage,validity:e.validity});ce(e,"htmx:validation:failed",{message:e.validationMessage,validity:e.validity})}}}function dr(e,t){var r=[];var n={};var i={};var a=[];var o=ae(e);if(o.lastButtonClicked&&!se(o.lastButtonClicked)){o.lastButtonClicked=null}var s=h(e,"form")&&e.noValidate!==true||te(e,"hx-validate")==="true";if(o.lastButtonClicked){s=s&&o.lastButtonClicked.formNoValidate!==true}if(t!=="get"){hr(r,i,a,v(e,"form"),s)}hr(r,n,a,e,s);if(o.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){var l=o.lastButtonClicked||e;var u=ee(l,"name");cr(u,l.value,i)}var f=me(e,"hx-include");oe(f,function(e){hr(r,n,a,e,s);if(!h(e,"form")){oe(e.querySelectorAll(rt),function(e){hr(r,n,a,e,s)})}});n=le(n,i);return{errors:a,values:n}}function gr(e,t,r){if(e!==""){e+="&"}if(String(r)==="[object Object]"){r=JSON.stringify(r)}var n=encodeURIComponent(r);e+=encodeURIComponent(t)+"="+n;return e}function pr(e){var t="";for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t=gr(t,r,e)})}else{t=gr(t,r,n)}}}return t}function mr(e){var t=new FormData;for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t.append(r,e)})}else{t.append(r,n)}}}return t}function xr(e,t,r){var n={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":re().location.href};Rr(e,"hx-headers",false,n);if(r!==undefined){n["HX-Prompt"]=r}if(ae(e).boosted){n["HX-Boosted"]="true"}return n}function yr(t,e){var r=ne(e,"hx-params");if(r){if(r==="none"){return{}}else if(r==="*"){return t}else if(r.indexOf("not ")===0){oe(r.substr(4).split(","),function(e){e=e.trim();delete t[e]});return t}else{var n={};oe(r.split(","),function(e){e=e.trim();n[e]=t[e]});return n}}else{return t}}function br(e){return ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a<i.length;a++){var o=i[a];if(o.indexOf("swap:")===0){n["swapDelay"]=d(o.substr(5))}else if(o.indexOf("settle:")===0){n["settleDelay"]=d(o.substr(7))}else if(o.indexOf("transition:")===0){n["transition"]=o.substr(11)==="true"}else if(o.indexOf("ignoreTitle:")===0){n["ignoreTitle"]=o.substr(12)==="true"}else if(o.indexOf("scroll:")===0){var s=o.substr(7);var l=s.split(":");var u=l.pop();var f=l.length>0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","<style> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()});
|
|
@@ -1,983 +1,737 @@
|
|
| 1 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
-
ResearchIT β
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 5 |
|
| 6 |
-
/* ββ 1.
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
:root {
|
| 10 |
-
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
--
|
| 20 |
-
--
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
--
|
| 43 |
-
--
|
| 44 |
-
--
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
--
|
| 53 |
-
--
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
[data-theme="light"] .rit-navbar {
|
| 86 |
-
background: rgba(255, 255, 255, 0.82);
|
| 87 |
-
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
| 88 |
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.rit-nav-link {
|
| 92 |
-
position: relative;
|
| 93 |
-
color: rgba(255, 255, 255, 0.6);
|
| 94 |
-
font-size: 0.875rem;
|
| 95 |
-
font-weight: 500;
|
| 96 |
-
padding: 0.4rem 0.75rem;
|
| 97 |
-
border-radius: 8px;
|
| 98 |
-
transition: all var(--rit-transition);
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
.rit-nav-link:hover, .rit-nav-link.active {
|
| 102 |
-
color: rgba(255, 255, 255, 0.95);
|
| 103 |
-
background: rgba(255, 255, 255, 0.06);
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
.rit-nav-link.active::after {
|
| 107 |
-
content: '';
|
| 108 |
-
position: absolute;
|
| 109 |
-
bottom: -2px;
|
| 110 |
-
left: 50%;
|
| 111 |
-
transform: translateX(-50%);
|
| 112 |
-
width: 16px;
|
| 113 |
-
height: 2px;
|
| 114 |
-
background: var(--rit-accent);
|
| 115 |
-
border-radius: 1px;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
[data-theme="light"] .rit-nav-link {
|
| 119 |
-
color: rgba(0, 0, 0, 0.5);
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
[data-theme="light"] .rit-nav-link:hover,
|
| 123 |
-
[data-theme="light"] .rit-nav-link.active {
|
| 124 |
-
color: rgba(0, 0, 0, 0.9);
|
| 125 |
-
background: rgba(0, 0, 0, 0.04);
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
/* ββ 5. Brand βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 129 |
-
.brand-text {
|
| 130 |
-
background: linear-gradient(135deg, var(--rit-brand-from), var(--rit-brand-via), var(--rit-brand-to));
|
| 131 |
-
-webkit-background-clip: text;
|
| 132 |
-
-webkit-text-fill-color: transparent;
|
| 133 |
-
background-clip: text;
|
| 134 |
-
font-weight: 700;
|
| 135 |
-
letter-spacing: -0.03em;
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
.brand-text-sm {
|
| 139 |
-
background: linear-gradient(135deg, var(--rit-brand-from), var(--rit-brand-to));
|
| 140 |
-
-webkit-background-clip: text;
|
| 141 |
-
-webkit-text-fill-color: transparent;
|
| 142 |
-
background-clip: text;
|
| 143 |
-
font-weight: 600;
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
/* ββ 6. Paper Card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 147 |
-
.paper-card {
|
| 148 |
-
position: relative;
|
| 149 |
-
border-radius: var(--rit-radius);
|
| 150 |
-
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 151 |
-
transition: all var(--rit-transition);
|
| 152 |
-
overflow: hidden;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
.paper-card::before {
|
| 156 |
-
content: '';
|
| 157 |
-
position: absolute;
|
| 158 |
-
top: 0;
|
| 159 |
-
left: 0;
|
| 160 |
-
bottom: 0;
|
| 161 |
-
width: 3px;
|
| 162 |
-
background: var(--rit-card-accent, var(--rit-accent));
|
| 163 |
-
opacity: 0;
|
| 164 |
-
transition: opacity var(--rit-transition);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
.paper-card:hover {
|
| 168 |
-
transform: translateY(-2px);
|
| 169 |
-
border-color: rgba(14, 165, 233, 0.15);
|
| 170 |
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
|
| 171 |
-
0 0 0 1px rgba(14, 165, 233, 0.08);
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
.paper-card:hover::before {
|
| 175 |
-
opacity: 1;
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
[data-theme="light"] .paper-card {
|
| 179 |
-
border-color: rgba(0, 0, 0, 0.06);
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
[data-theme="light"] .paper-card:hover {
|
| 183 |
-
border-color: rgba(14, 165, 233, 0.2);
|
| 184 |
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06),
|
| 185 |
-
0 0 0 1px rgba(14, 165, 233, 0.1);
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
/* Card accent colors per category domain */
|
| 189 |
-
.paper-card[data-cat^="cs"] { --rit-card-accent: #38BDF8; }
|
| 190 |
-
.paper-card[data-cat^="hep"],
|
| 191 |
-
.paper-card[data-cat^="physics"],
|
| 192 |
-
.paper-card[data-cat^="astro"],
|
| 193 |
-
.paper-card[data-cat^="quant"],
|
| 194 |
-
.paper-card[data-cat^="cond"] { --rit-card-accent: #C084FC; }
|
| 195 |
-
.paper-card[data-cat^="math"],
|
| 196 |
-
.paper-card[data-cat^="stat"] { --rit-card-accent: #34D399; }
|
| 197 |
-
.paper-card[data-cat^="q-bio"] { --rit-card-accent: #FBBF24; }
|
| 198 |
-
.paper-card[data-cat^="econ"] { --rit-card-accent: #FB7185; }
|
| 199 |
-
.paper-card[data-cat^="eess"] { --rit-card-accent: #FB923C; }
|
| 200 |
-
|
| 201 |
-
/* Paper card title link */
|
| 202 |
-
.paper-title {
|
| 203 |
-
font-weight: 600;
|
| 204 |
-
line-height: 1.4;
|
| 205 |
-
color: var(--rit-accent-light);
|
| 206 |
-
text-decoration: none;
|
| 207 |
-
transition: color var(--rit-transition);
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
.paper-title:hover {
|
| 211 |
-
color: var(--rit-accent);
|
| 212 |
-
text-decoration: underline;
|
| 213 |
-
text-decoration-color: rgba(14, 165, 233, 0.3);
|
| 214 |
-
text-underline-offset: 3px;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
[data-theme="light"] .paper-title {
|
| 218 |
-
color: var(--rit-accent-dim);
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
[data-theme="light"] .paper-title:hover {
|
| 222 |
-
color: #0369A1;
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
-
/* Abstract expand/collapse */
|
| 226 |
-
.abstract-text {
|
| 227 |
-
display: -webkit-box;
|
| 228 |
-
-webkit-line-clamp: 3;
|
| 229 |
-
-webkit-box-orient: vertical;
|
| 230 |
-
overflow: hidden;
|
| 231 |
-
transition: all 0.3s ease;
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
.abstract-text.expanded {
|
| 235 |
-
-webkit-line-clamp: unset;
|
| 236 |
-
display: block;
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
.abstract-toggle {
|
| 240 |
-
cursor: pointer;
|
| 241 |
-
color: var(--rit-accent);
|
| 242 |
-
font-size: 0.75rem;
|
| 243 |
-
font-weight: 500;
|
| 244 |
-
opacity: 0.8;
|
| 245 |
-
transition: opacity var(--rit-transition);
|
| 246 |
-
background: none;
|
| 247 |
-
border: none;
|
| 248 |
-
padding: 0;
|
| 249 |
}
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
letter-spacing: 0.04em;
|
| 259 |
-
text-transform: uppercase;
|
| 260 |
-
font-weight: 600;
|
| 261 |
-
padding: 3px 8px;
|
| 262 |
-
border-radius: 6px;
|
| 263 |
-
white-space: nowrap;
|
| 264 |
-
}
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
.cat-econ { background: rgba(251,113,133,0.12); color: #FB7185; }
|
| 271 |
-
.cat-stat { background: rgba(34,211,238,0.12); color: #22D3EE; }
|
| 272 |
-
.cat-eess { background: rgba(251,146,60,0.12); color: #FB923C; }
|
| 273 |
-
.cat-other { background: rgba(148,163,184,0.1); color: #94A3B8; }
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
[data-theme="light"] .cat-econ { background: rgba(244,63,94,0.1); color: #E11D48; }
|
| 280 |
-
[data-theme="light"] .cat-stat { background: rgba(6,182,212,0.1); color: #0891B2; }
|
| 281 |
-
[data-theme="light"] .cat-eess { background: rgba(249,115,22,0.1); color: #C2410C; }
|
| 282 |
-
[data-theme="light"] .cat-other { background: rgba(100,116,139,0.08); color: #475569; }
|
| 283 |
|
| 284 |
-
|
| 285 |
-
.rit-action-btn {
|
| 286 |
-
display: inline-flex;
|
| 287 |
-
align-items: center;
|
| 288 |
-
gap: 4px;
|
| 289 |
-
padding: 5px 12px;
|
| 290 |
-
border-radius: 8px;
|
| 291 |
-
font-size: 0.75rem;
|
| 292 |
-
font-weight: 500;
|
| 293 |
-
border: 1px solid transparent;
|
| 294 |
-
cursor: pointer;
|
| 295 |
-
transition: all var(--rit-transition);
|
| 296 |
-
background: none;
|
| 297 |
-
}
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
transition: transform 0.2s ease;
|
| 302 |
}
|
| 303 |
|
| 304 |
-
.
|
| 305 |
-
color: var(--rit-accent);
|
| 306 |
-
border-color: rgba(14, 165, 233, 0.2);
|
| 307 |
-
background: rgba(14, 165, 233, 0.06);
|
| 308 |
-
}
|
| 309 |
|
| 310 |
-
|
| 311 |
-
background: rgba(14, 165, 233, 0.15);
|
| 312 |
-
border-color: rgba(14, 165, 233, 0.3);
|
| 313 |
-
transform: translateY(-1px);
|
| 314 |
-
}
|
| 315 |
|
| 316 |
-
|
| 317 |
-
transform: scale(1.15);
|
| 318 |
-
}
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
}
|
|
|
|
| 336 |
|
| 337 |
-
|
| 338 |
-
color: rgba(255, 255, 255, 0.4);
|
| 339 |
-
border-color: transparent;
|
| 340 |
-
}
|
| 341 |
|
| 342 |
-
.
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
| 351 |
}
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
}
|
|
|
|
| 356 |
|
| 357 |
-
|
| 358 |
-
color: rgba(0, 0, 0, 0.35);
|
| 359 |
-
}
|
| 360 |
|
| 361 |
-
|
| 362 |
-
color: rgba(0, 0, 0, 0.7);
|
| 363 |
-
background: rgba(0, 0, 0, 0.04);
|
| 364 |
-
}
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
background: rgba(255, 255, 255, 0.02);
|
| 372 |
}
|
| 373 |
|
| 374 |
-
|
| 375 |
-
border-color: rgba(0, 0, 0, 0.04);
|
| 376 |
-
background: rgba(0, 0, 0, 0.015);
|
| 377 |
-
}
|
| 378 |
|
| 379 |
-
|
| 380 |
-
border-radius: 6px;
|
| 381 |
-
background: linear-gradient(
|
| 382 |
-
90deg,
|
| 383 |
-
rgba(148, 163, 184, 0.06) 25%,
|
| 384 |
-
rgba(148, 163, 184, 0.12) 50%,
|
| 385 |
-
rgba(148, 163, 184, 0.06) 75%
|
| 386 |
-
);
|
| 387 |
-
background-size: 200% 100%;
|
| 388 |
-
animation: shimmer 1.8s infinite ease-in-out;
|
| 389 |
-
}
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
rgba(100, 116, 139, 0.14) 50%,
|
| 396 |
-
rgba(100, 116, 139, 0.08) 75%
|
| 397 |
-
);
|
| 398 |
-
background-size: 200% 100%;
|
| 399 |
-
animation: shimmer 1.8s infinite ease-in-out;
|
| 400 |
-
}
|
| 401 |
-
|
| 402 |
-
.skeleton-line-title { height: 18px; width: 65%; }
|
| 403 |
-
.skeleton-line-badge { height: 14px; width: 48px; border-radius: 4px; }
|
| 404 |
-
.skeleton-line-meta { height: 12px; width: 45%; }
|
| 405 |
-
.skeleton-line-text { height: 12px; width: 100%; }
|
| 406 |
-
.skeleton-line-text-md { height: 12px; width: 82%; }
|
| 407 |
-
.skeleton-line-text-sm { height: 12px; width: 55%; }
|
| 408 |
-
.skeleton-line-btn { height: 28px; width: 72px; border-radius: 8px; }
|
| 409 |
-
|
| 410 |
-
/* ββ 10. Toast Notifications ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 411 |
-
#toast-container {
|
| 412 |
-
position: fixed;
|
| 413 |
-
bottom: 80px;
|
| 414 |
-
right: 20px;
|
| 415 |
-
z-index: 9999;
|
| 416 |
-
display: flex;
|
| 417 |
-
flex-direction: column;
|
| 418 |
-
gap: 8px;
|
| 419 |
-
pointer-events: none;
|
| 420 |
}
|
| 421 |
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
}
|
|
|
|
|
|
|
| 428 |
|
| 429 |
-
.
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
gap: 8px;
|
| 434 |
-
padding: 10px 16px;
|
| 435 |
-
border-radius: 10px;
|
| 436 |
-
font-size: 0.8125rem;
|
| 437 |
-
font-weight: 500;
|
| 438 |
-
background: rgba(30, 35, 50, 0.95);
|
| 439 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 440 |
-
color: #E2E8F0;
|
| 441 |
-
backdrop-filter: blur(12px);
|
| 442 |
-
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
| 443 |
-
animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
| 444 |
-
max-width: 340px;
|
| 445 |
-
}
|
| 446 |
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
}
|
|
|
|
| 453 |
|
| 454 |
-
.
|
| 455 |
-
|
|
|
|
| 456 |
}
|
| 457 |
-
|
| 458 |
-
.
|
| 459 |
-
|
| 460 |
-
flex-shrink: 0;
|
| 461 |
}
|
| 462 |
|
| 463 |
-
.
|
| 464 |
-
|
| 465 |
-
.rit-toast-info { border-left: 3px solid var(--rit-accent); }
|
| 466 |
|
| 467 |
-
|
| 468 |
-
.coming-soon-wrapper {
|
| 469 |
position: relative;
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
cursor: default;
|
| 473 |
}
|
| 474 |
-
|
| 475 |
-
.
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
.coming-soon-badge {
|
| 481 |
-
display: inline-flex;
|
| 482 |
-
align-items: center;
|
| 483 |
-
gap: 4px;
|
| 484 |
-
font-size: 0.6rem;
|
| 485 |
-
font-weight: 600;
|
| 486 |
-
text-transform: uppercase;
|
| 487 |
-
letter-spacing: 0.06em;
|
| 488 |
-
padding: 2px 8px;
|
| 489 |
-
border-radius: 4px;
|
| 490 |
-
background: rgba(14, 165, 233, 0.1);
|
| 491 |
-
color: var(--rit-accent);
|
| 492 |
-
border: 1px solid rgba(14, 165, 233, 0.15);
|
| 493 |
-
white-space: nowrap;
|
| 494 |
-
}
|
| 495 |
-
|
| 496 |
-
.coming-soon-section {
|
| 497 |
-
position: relative;
|
| 498 |
-
padding: 1rem;
|
| 499 |
-
border-radius: var(--rit-radius);
|
| 500 |
-
border: 1px dashed rgba(255, 255, 255, 0.08);
|
| 501 |
-
background: rgba(255, 255, 255, 0.01);
|
| 502 |
}
|
| 503 |
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
| 507 |
}
|
|
|
|
|
|
|
| 508 |
|
| 509 |
-
/*
|
| 510 |
-
.
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
z-index: 100;
|
| 516 |
-
display: none;
|
| 517 |
-
background: rgba(11, 13, 20, 0.92);
|
| 518 |
-
backdrop-filter: blur(20px);
|
| 519 |
-
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
| 520 |
-
padding: 6px 0 env(safe-area-inset-bottom, 8px);
|
| 521 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
|
| 523 |
-
|
| 524 |
-
background: rgba(255, 255, 255, 0.92);
|
| 525 |
-
border-top-color: rgba(0, 0, 0, 0.06);
|
| 526 |
-
}
|
| 527 |
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
| 531 |
}
|
|
|
|
| 532 |
|
| 533 |
-
.
|
| 534 |
flex: 1;
|
| 535 |
-
display: flex;
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
transition:
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
.
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
}
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
}
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
background:
|
| 586 |
-
border
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
.
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
line-height: 1.
|
| 618 |
-
}
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
font-weight:
|
| 634 |
-
|
| 635 |
-
}
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
.
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
}
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
}
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
}
|
| 674 |
-
|
| 675 |
-
/* ββ 14. AI Summary Card ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 676 |
-
.ai-summary-card {
|
| 677 |
-
position: relative;
|
| 678 |
-
background: rgba(14, 165, 233, 0.04);
|
| 679 |
-
border-radius: var(--rit-radius);
|
| 680 |
-
padding: 1.25rem;
|
| 681 |
-
margin-bottom: 1rem;
|
| 682 |
overflow: hidden;
|
| 683 |
-
border: 1px solid rgba(14, 165, 233, 0.1);
|
| 684 |
-
}
|
| 685 |
-
|
| 686 |
-
[data-theme="light"] .ai-summary-card {
|
| 687 |
-
background: rgba(14, 165, 233, 0.03);
|
| 688 |
-
border-color: rgba(14, 165, 233, 0.12);
|
| 689 |
}
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
.
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
.
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
}
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
background: transparent;
|
| 725 |
-
border: none;
|
| 726 |
-
outline: none;
|
| 727 |
-
font-size: 1rem;
|
| 728 |
-
width: 100%;
|
| 729 |
-
padding: 0.75rem 1rem;
|
| 730 |
-
color: inherit;
|
| 731 |
-
}
|
| 732 |
-
|
| 733 |
-
.rit-search-input::placeholder {
|
| 734 |
-
color: rgba(255, 255, 255, 0.3);
|
| 735 |
-
}
|
| 736 |
-
|
| 737 |
-
[data-theme="light"] .rit-search-input::placeholder {
|
| 738 |
-
color: rgba(0, 0, 0, 0.3);
|
| 739 |
-
}
|
| 740 |
-
|
| 741 |
-
/* ββ 16. Pills & Badges βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 742 |
-
.stat-pill {
|
| 743 |
-
display: inline-flex;
|
| 744 |
-
align-items: center;
|
| 745 |
-
gap: 4px;
|
| 746 |
-
padding: 4px 12px;
|
| 747 |
-
border-radius: 9999px;
|
| 748 |
-
font-size: 0.7rem;
|
| 749 |
-
font-weight: 500;
|
| 750 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
|
| 752 |
-
.
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
border-radius: 5px;
|
| 757 |
-
background: rgba(148, 163, 184, 0.1);
|
| 758 |
-
color: #94A3B8;
|
| 759 |
}
|
|
|
|
| 760 |
|
| 761 |
-
.
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
background: rgba(100, 116, 139, 0.1);
|
| 768 |
-
color: #475569;
|
| 769 |
-
}
|
| 770 |
-
|
| 771 |
-
[data-theme="light"] .year-pill.recent {
|
| 772 |
-
background: rgba(16, 185, 129, 0.1);
|
| 773 |
-
color: #059669;
|
| 774 |
-
}
|
| 775 |
-
|
| 776 |
-
.cite-count {
|
| 777 |
-
font-size: 0.7rem;
|
| 778 |
-
display: inline-flex;
|
| 779 |
-
align-items: center;
|
| 780 |
-
gap: 3px;
|
| 781 |
-
color: #94A3B8;
|
| 782 |
-
}
|
| 783 |
-
|
| 784 |
-
[data-theme="light"] .cite-count {
|
| 785 |
-
color: #64748B;
|
| 786 |
-
}
|
| 787 |
-
|
| 788 |
-
/* ββ 17. Progress Messages ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 789 |
-
.rit-progress-container {
|
| 790 |
-
text-align: center;
|
| 791 |
-
padding: 1rem 0;
|
| 792 |
-
}
|
| 793 |
-
|
| 794 |
-
.rit-progress-msg {
|
| 795 |
-
font-size: 0.8125rem;
|
| 796 |
-
font-weight: 500;
|
| 797 |
-
color: rgba(255, 255, 255, 0.5);
|
| 798 |
-
animation: fadeInUp 0.4s ease;
|
| 799 |
-
}
|
| 800 |
-
|
| 801 |
-
[data-theme="light"] .rit-progress-msg {
|
| 802 |
-
color: rgba(0, 0, 0, 0.45);
|
| 803 |
-
}
|
| 804 |
-
|
| 805 |
-
.rit-progress-dots {
|
| 806 |
-
display: inline-flex;
|
| 807 |
-
gap: 4px;
|
| 808 |
-
margin-left: 4px;
|
| 809 |
-
}
|
| 810 |
-
|
| 811 |
-
.rit-progress-dots span {
|
| 812 |
-
width: 4px;
|
| 813 |
-
height: 4px;
|
| 814 |
-
border-radius: 50%;
|
| 815 |
-
background: var(--rit-accent);
|
| 816 |
-
animation: dotPulse 1.4s ease-in-out infinite;
|
| 817 |
-
}
|
| 818 |
-
|
| 819 |
-
.rit-progress-dots span:nth-child(2) { animation-delay: 0.2s; }
|
| 820 |
-
.rit-progress-dots span:nth-child(3) { animation-delay: 0.4s; }
|
| 821 |
-
|
| 822 |
-
/* ββ 18. Seed Card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 823 |
-
.seed-card {
|
| 824 |
-
padding: 0.75rem 1rem;
|
| 825 |
-
border-radius: var(--rit-radius-sm);
|
| 826 |
-
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 827 |
-
background: rgba(255, 255, 255, 0.02);
|
| 828 |
-
transition: all var(--rit-transition);
|
| 829 |
-
}
|
| 830 |
-
|
| 831 |
-
[data-theme="light"] .seed-card {
|
| 832 |
-
background: rgba(255, 255, 255, 0.8);
|
| 833 |
-
border-color: rgba(0, 0, 0, 0.06);
|
| 834 |
-
}
|
| 835 |
-
|
| 836 |
-
.seed-card:hover {
|
| 837 |
-
border-color: rgba(14, 165, 233, 0.2);
|
| 838 |
-
background: rgba(14, 165, 233, 0.02);
|
| 839 |
-
}
|
| 840 |
-
|
| 841 |
-
/* ββ 19. Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 842 |
-
.app-footer {
|
| 843 |
-
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
| 844 |
-
margin-top: 4rem;
|
| 845 |
-
padding: 2rem 0;
|
| 846 |
-
text-align: center;
|
| 847 |
-
color: rgba(255, 255, 255, 0.3);
|
| 848 |
-
font-size: 0.75rem;
|
| 849 |
-
}
|
| 850 |
-
|
| 851 |
-
[data-theme="light"] .app-footer {
|
| 852 |
-
border-top-color: rgba(0, 0, 0, 0.04);
|
| 853 |
-
color: rgba(0, 0, 0, 0.35);
|
| 854 |
-
}
|
| 855 |
-
|
| 856 |
-
.footer-tech-badge {
|
| 857 |
-
display: inline-flex;
|
| 858 |
-
align-items: center;
|
| 859 |
-
gap: 3px;
|
| 860 |
-
padding: 2px 8px;
|
| 861 |
-
border-radius: 4px;
|
| 862 |
-
font-size: 0.625rem;
|
| 863 |
-
font-weight: 500;
|
| 864 |
-
background: rgba(255, 255, 255, 0.04);
|
| 865 |
-
color: rgba(255, 255, 255, 0.4);
|
| 866 |
-
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 867 |
-
}
|
| 868 |
-
|
| 869 |
-
[data-theme="light"] .footer-tech-badge {
|
| 870 |
-
background: rgba(0, 0, 0, 0.03);
|
| 871 |
-
color: rgba(0, 0, 0, 0.4);
|
| 872 |
-
border-color: rgba(0, 0, 0, 0.04);
|
| 873 |
-
}
|
| 874 |
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
background: rgba(14, 165, 233, 0.2);
|
| 880 |
-
border-radius: 3px;
|
| 881 |
-
}
|
| 882 |
-
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
| 883 |
-
background: rgba(14, 165, 233, 0.35);
|
| 884 |
-
}
|
| 885 |
-
|
| 886 |
-
/* ββ 21. HTMX Transitions βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 887 |
-
.htmx-swapping {
|
| 888 |
-
opacity: 0;
|
| 889 |
-
transition: opacity 0.15s ease-out;
|
| 890 |
}
|
|
|
|
| 891 |
|
| 892 |
-
.
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
.
|
| 897 |
-
|
| 898 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
}
|
|
|
|
|
|
|
| 900 |
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
display: flex;
|
| 904 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
|
| 906 |
-
.
|
| 907 |
-
display: none;
|
| 908 |
-
flex-direction: column;
|
| 909 |
-
position: absolute;
|
| 910 |
-
top: 100%;
|
| 911 |
-
left: 0;
|
| 912 |
-
right: 0;
|
| 913 |
-
background: rgba(11, 13, 20, 0.96);
|
| 914 |
-
backdrop-filter: blur(20px);
|
| 915 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 916 |
-
padding: 0.75rem;
|
| 917 |
-
z-index: 50;
|
| 918 |
-
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
| 919 |
-
}
|
| 920 |
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
}
|
|
|
|
| 926 |
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
}
|
| 931 |
-
|
| 932 |
-
.line-
|
| 933 |
-
|
| 934 |
-
-webkit-line-clamp: 1;
|
| 935 |
-
-webkit-box-orient: vertical;
|
| 936 |
-
overflow: hidden;
|
| 937 |
}
|
|
|
|
| 938 |
|
| 939 |
-
.
|
| 940 |
-
display: -webkit-box;
|
| 941 |
-
-webkit-line-clamp: 3;
|
| 942 |
-
-webkit-box-orient: vertical;
|
| 943 |
-
overflow: hidden;
|
| 944 |
-
}
|
| 945 |
|
| 946 |
-
|
| 947 |
-
.
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
border: 1px dashed rgba(239, 68, 68, 0.2);
|
| 952 |
-
background: rgba(239, 68, 68, 0.03);
|
| 953 |
-
}
|
| 954 |
|
| 955 |
-
/*
|
| 956 |
-
.
|
| 957 |
-
|
| 958 |
-
width: 400px;
|
| 959 |
-
height: 400px;
|
| 960 |
-
border-radius: 50%;
|
| 961 |
-
background: radial-gradient(circle, var(--rit-accent-glow) 0%, transparent 70%);
|
| 962 |
-
filter: blur(80px);
|
| 963 |
-
pointer-events: none;
|
| 964 |
-
z-index: 0;
|
| 965 |
-
}
|
| 966 |
|
| 967 |
-
/*
|
| 968 |
-
.
|
| 969 |
-
|
| 970 |
-
border-radius: var(--rit-radius-sm);
|
| 971 |
-
background: rgba(255, 255, 255, 0.02);
|
| 972 |
-
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 973 |
-
}
|
| 974 |
|
| 975 |
-
|
| 976 |
-
background: rgba(0, 0, 0, 0.015);
|
| 977 |
-
border-color: rgba(0, 0, 0, 0.04);
|
| 978 |
-
}
|
| 979 |
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
}
|
|
|
|
| 1 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
ResearchIT β design system
|
| 3 |
+
---------------------------------------------------------------------------
|
| 4 |
+
Single hand-written stylesheet. Replaces Tailwind Play CDN + DaisyUI +
|
| 5 |
+
animations.css + the Google Fonts @import.
|
| 6 |
+
|
| 7 |
+
Why no Tailwind CDN: tailwindcss.com/docs/installation/play-cdn states the
|
| 8 |
+
Play CDN is a development tool, not for production. The maintainer's reason
|
| 9 |
+
(tailwindlabs/tailwindcss#7637) is that it applies styles at RUNTIME, with
|
| 10 |
+
the worst case being elements added to the page after first load β which is
|
| 11 |
+
exactly every htmx-swapped paper card. On an infinite feed that means a
|
| 12 |
+
flash of unstyled content on every appended page.
|
| 13 |
+
|
| 14 |
+
Why no webfont: a @font-face CDN link is a render-blocking cross-origin
|
| 15 |
+
round trip, and the previous @import sat INSIDE this stylesheet, so fonts
|
| 16 |
+
could not even begin loading until this file was fetched and parsed. System
|
| 17 |
+
stacks render instantly with zero network. If a brand face is wanted later,
|
| 18 |
+
self-host it (woff2, subset, font-display: swap, preload) rather than
|
| 19 |
+
reintroducing a CDN.
|
| 20 |
+
|
| 21 |
+
Identity β "reading room": warm near-black rather than the blue-black every
|
| 22 |
+
research tool uses (warm dark also reduces halation around light text), one
|
| 23 |
+
restrained green accent reserved for Save, content set in a serif and
|
| 24 |
+
interface chrome in sans/mono so the two never compete.
|
| 25 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 26 |
|
| 27 |
+
/* ββ 1. Tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
Three theme states, per the artifact spec:
|
| 29 |
+
:root complete LIGHT palette (base)
|
| 30 |
+
@media dark + :not([data-theme=light]) dark for "system" users
|
| 31 |
+
:root[data-theme="dark"] dark when explicitly toggled
|
| 32 |
+
Every colour is defined in the bare :root first, so nothing depends on a
|
| 33 |
+
media query or attribute being present. */
|
| 34 |
|
| 35 |
:root {
|
| 36 |
+
/* surfaces */
|
| 37 |
+
--bg: #FBFAF8;
|
| 38 |
+
--bg-2: #F4F1EC;
|
| 39 |
+
--surface: #FFFFFF;
|
| 40 |
+
--surface-2: #F2EFEA;
|
| 41 |
+
--border: #E5E1D9;
|
| 42 |
+
--border-2: #D3CDC2;
|
| 43 |
+
|
| 44 |
+
/* text */
|
| 45 |
+
--text: #1C1A16;
|
| 46 |
+
--text-2: #58524A;
|
| 47 |
+
--text-3: #8A837A;
|
| 48 |
+
|
| 49 |
+
/* accent β Save, active nav, focus */
|
| 50 |
+
--accent: #2C5638;
|
| 51 |
+
--accent-2: #3E7A4E;
|
| 52 |
+
--accent-bg: #E4EFE5;
|
| 53 |
+
--accent-br: #CADFCD;
|
| 54 |
+
|
| 55 |
+
/* semantic */
|
| 56 |
+
--danger: #8E3033;
|
| 57 |
+
--danger-bg: #F9E3E3;
|
| 58 |
+
--info: #13528C;
|
| 59 |
+
--info-bg: #DDEAFA;
|
| 60 |
+
|
| 61 |
+
/* highlight behind the abstract's lead sentence */
|
| 62 |
+
--mark: #DDE9DA;
|
| 63 |
+
|
| 64 |
+
--shadow-1: 0 1px 2px rgba(28, 26, 22, .05);
|
| 65 |
+
--shadow-2: 0 1px 2px rgba(28, 26, 22, .05), 0 8px 24px -16px rgba(28, 26, 22, .25);
|
| 66 |
+
|
| 67 |
+
/* type */
|
| 68 |
+
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 69 |
+
--font-read: Charter, "Bitstream Charter", "Sitka Text", Cambria, Georgia, serif;
|
| 70 |
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
| 71 |
+
|
| 72 |
+
/* metrics β feed width is set for READING measure (~68ch at .95rem serif),
|
| 73 |
+
not for filling the viewport. The old max-w-4xl (896px) ran well past the
|
| 74 |
+
line length reading research supports. */
|
| 75 |
+
--feed-w: 42rem;
|
| 76 |
+
--page-w: 64rem;
|
| 77 |
+
--radius: 10px;
|
| 78 |
+
--radius-sm: 6px;
|
| 79 |
+
--topbar-h: 3.25rem;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
@media (prefers-color-scheme: dark) {
|
| 83 |
+
:root:not([data-theme="light"]) {
|
| 84 |
+
--bg: #14120F;
|
| 85 |
+
--bg-2: #100E0C;
|
| 86 |
+
--surface: #1C1916;
|
| 87 |
+
--surface-2: #24211C;
|
| 88 |
+
--border: #2C2823;
|
| 89 |
+
--border-2: #3B3630;
|
| 90 |
+
|
| 91 |
+
--text: #E8E3DB;
|
| 92 |
+
--text-2: #A9A197;
|
| 93 |
+
--text-3: #7C756B;
|
| 94 |
+
|
| 95 |
+
--accent: #7FB58C;
|
| 96 |
+
--accent-2: #A7D4B2;
|
| 97 |
+
--accent-bg: #263B2C;
|
| 98 |
+
--accent-br: #37543F;
|
| 99 |
+
|
| 100 |
+
--danger: #E89B8C;
|
| 101 |
+
--danger-bg: #38201B;
|
| 102 |
+
--info: #7FB6E8;
|
| 103 |
+
--info-bg: #16283A;
|
| 104 |
+
|
| 105 |
+
--mark: #2E3B2C;
|
| 106 |
+
|
| 107 |
+
--shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
|
| 108 |
+
--shadow-2: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px -20px rgba(0, 0, 0, .9);
|
| 109 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
+
:root[data-theme="dark"] {
|
| 113 |
+
--bg: #14120F;
|
| 114 |
+
--bg-2: #100E0C;
|
| 115 |
+
--surface: #1C1916;
|
| 116 |
+
--surface-2: #24211C;
|
| 117 |
+
--border: #2C2823;
|
| 118 |
+
--border-2: #3B3630;
|
| 119 |
|
| 120 |
+
--text: #E8E3DB;
|
| 121 |
+
--text-2: #A9A197;
|
| 122 |
+
--text-3: #7C756B;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
--accent: #7FB58C;
|
| 125 |
+
--accent-2: #A7D4B2;
|
| 126 |
+
--accent-bg: #263B2C;
|
| 127 |
+
--accent-br: #37543F;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
--danger: #E89B8C;
|
| 130 |
+
--danger-bg: #38201B;
|
| 131 |
+
--info: #7FB6E8;
|
| 132 |
+
--info-bg: #16283A;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
--mark: #2E3B2C;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
--shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
|
| 137 |
+
--shadow-2: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px -20px rgba(0, 0, 0, .9);
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
+
/* ββ 2. Reset & base βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
html { -webkit-text-size-adjust: 100%; }
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
body {
|
| 147 |
+
margin: 0;
|
| 148 |
+
min-height: 100vh;
|
| 149 |
+
background: var(--bg);
|
| 150 |
+
color: var(--text);
|
| 151 |
+
font-family: var(--font-ui);
|
| 152 |
+
font-size: 16px;
|
| 153 |
+
line-height: 1.55;
|
| 154 |
+
-webkit-font-smoothing: antialiased;
|
| 155 |
+
-moz-osx-font-smoothing: grayscale;
|
| 156 |
}
|
| 157 |
|
| 158 |
+
h1, h2, h3, h4, h5, p, figure { margin: 0; }
|
| 159 |
+
ul, ol { margin: 0; padding: 0; }
|
| 160 |
+
img, svg { display: block; max-width: 100%; }
|
| 161 |
+
button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
|
| 162 |
+
input, textarea, select { font: inherit; color: inherit; }
|
| 163 |
+
a { color: inherit; text-decoration: none; }
|
| 164 |
|
| 165 |
+
::selection { background: var(--accent-bg); color: var(--text); }
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
+
/* One visible focus style everywhere. */
|
| 168 |
+
:focus-visible {
|
| 169 |
+
outline: 2px solid var(--accent-2);
|
| 170 |
+
outline-offset: 2px;
|
| 171 |
+
border-radius: var(--radius-sm);
|
| 172 |
}
|
| 173 |
|
| 174 |
+
/* Screen-reader-only, but still focusable. Used by the skip link and by the
|
| 175 |
+
feed's "load more" control's helper text. */
|
| 176 |
+
.sr-only {
|
| 177 |
+
position: absolute; width: 1px; height: 1px;
|
| 178 |
+
padding: 0; margin: -1px; overflow: hidden;
|
| 179 |
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
| 180 |
}
|
| 181 |
|
| 182 |
+
.skip-link {
|
| 183 |
+
position: absolute; left: .5rem; top: -3rem; z-index: 200;
|
| 184 |
+
background: var(--surface); color: var(--text);
|
| 185 |
+
border: 1px solid var(--border-2); border-radius: var(--radius-sm);
|
| 186 |
+
padding: .5rem .85rem; font-size: .85rem; font-weight: 550;
|
| 187 |
+
transition: top .15s ease;
|
| 188 |
}
|
| 189 |
+
.skip-link:focus { top: .5rem; }
|
| 190 |
|
| 191 |
+
/* ββ 3. Layout βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
.shell { display: flex; flex-direction: column; min-height: 100vh; }
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
.wrap {
|
| 196 |
+
width: 100%;
|
| 197 |
+
max-width: var(--page-w);
|
| 198 |
+
margin-inline: auto;
|
| 199 |
+
padding-inline: clamp(1rem, 4vw, 2rem);
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
+
.wrap--feed { max-width: calc(var(--feed-w) + 4rem); }
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
+
main { flex: 1; padding-block: 1.5rem 4rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
/* Mobile bottom nav is fixed; keep content clear of it and of the home
|
| 207 |
+
indicator on iOS. */
|
| 208 |
+
@media (max-width: 719px) {
|
| 209 |
+
main { padding-bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px)); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
+
.stack { display: flex; flex-direction: column; }
|
| 213 |
+
.stack-2 { gap: .5rem; }
|
| 214 |
+
.stack-3 { gap: .75rem; }
|
| 215 |
+
.stack-4 { gap: 1rem; }
|
| 216 |
+
.stack-6 { gap: 1.5rem; }
|
| 217 |
+
.row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
| 218 |
+
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
| 219 |
+
.grow { flex: 1; min-width: 0; }
|
| 220 |
|
| 221 |
+
/* ββ 4. Top bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 222 |
+
Solid, not glass. backdrop-filter blur+saturate is the default "AI SaaS"
|
| 223 |
+
navbar and it composites the whole scrolling feed underneath it on every
|
| 224 |
+
frame β a real cost on a 2-vCPU box with an infinite list below. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
+
.topbar {
|
| 227 |
+
position: sticky; top: 0; z-index: 50;
|
| 228 |
+
height: var(--topbar-h);
|
| 229 |
+
background: var(--bg);
|
| 230 |
+
border-bottom: 1px solid var(--border);
|
| 231 |
}
|
| 232 |
+
.topbar .wrap { height: 100%; display: flex; align-items: center; gap: 1rem; }
|
| 233 |
|
| 234 |
+
.brand {
|
| 235 |
+
display: inline-flex; align-items: center; gap: .5rem;
|
| 236 |
+
font-weight: 640; font-size: 1.02rem; letter-spacing: -.015em;
|
| 237 |
}
|
| 238 |
+
.brand-mark {
|
| 239 |
+
width: 1.35rem; height: 1.35rem; flex: none;
|
| 240 |
+
color: var(--accent);
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
+
.nav { display: none; align-items: center; gap: .15rem; margin-left: auto; }
|
| 244 |
+
@media (min-width: 720px) { .nav { display: flex; } }
|
|
|
|
| 245 |
|
| 246 |
+
.nav-link {
|
|
|
|
| 247 |
position: relative;
|
| 248 |
+
padding: .4rem .7rem; border-radius: var(--radius-sm);
|
| 249 |
+
font-size: .89rem; font-weight: 520; color: var(--text-2);
|
|
|
|
| 250 |
}
|
| 251 |
+
.nav-link:hover { color: var(--text); background: var(--surface-2); }
|
| 252 |
+
.nav-link[aria-current="page"] { color: var(--text); font-weight: 580; }
|
| 253 |
+
.nav-link[aria-current="page"]::after {
|
| 254 |
+
content: ""; position: absolute; left: .7rem; right: .7rem; bottom: -.05rem;
|
| 255 |
+
height: 2px; background: var(--accent); border-radius: 1px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
+
.icon-btn {
|
| 259 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 260 |
+
width: 2rem; height: 2rem; border-radius: var(--radius-sm);
|
| 261 |
+
color: var(--text-2);
|
| 262 |
}
|
| 263 |
+
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
|
| 264 |
+
.icon-btn svg { width: 1.05rem; height: 1.05rem; }
|
| 265 |
|
| 266 |
+
/* Theme toggle shows exactly one icon; JS never has to guess. */
|
| 267 |
+
.theme-toggle .i-moon { display: none; }
|
| 268 |
+
.theme-toggle .i-sun { display: block; }
|
| 269 |
+
@media (prefers-color-scheme: dark) {
|
| 270 |
+
:root:not([data-theme="light"]) .theme-toggle .i-moon { display: block; }
|
| 271 |
+
:root:not([data-theme="light"]) .theme-toggle .i-sun { display: none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
}
|
| 273 |
+
:root[data-theme="dark"] .theme-toggle .i-moon { display: block; }
|
| 274 |
+
:root[data-theme="dark"] .theme-toggle .i-sun { display: none; }
|
| 275 |
+
:root[data-theme="light"] .theme-toggle .i-moon { display: none; }
|
| 276 |
+
:root[data-theme="light"] .theme-toggle .i-sun { display: block; }
|
| 277 |
|
| 278 |
+
/* ββ 5. Bottom nav (mobile) ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
+
.botnav {
|
| 281 |
+
position: fixed; z-index: 60; left: 0; right: 0; bottom: 0;
|
| 282 |
+
display: flex; align-items: stretch;
|
| 283 |
+
background: var(--bg);
|
| 284 |
+
border-top: 1px solid var(--border);
|
| 285 |
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
| 286 |
}
|
| 287 |
+
@media (min-width: 720px) { .botnav { display: none; } }
|
| 288 |
|
| 289 |
+
.botnav-item {
|
| 290 |
flex: 1;
|
| 291 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 292 |
+
gap: .15rem; padding: .5rem 0 .45rem;
|
| 293 |
+
font-size: .68rem; font-weight: 540; color: var(--text-3);
|
| 294 |
+
min-height: 3.25rem;
|
| 295 |
+
}
|
| 296 |
+
.botnav-item svg { width: 1.25rem; height: 1.25rem; }
|
| 297 |
+
.botnav-item[aria-current="page"] { color: var(--accent); }
|
| 298 |
+
|
| 299 |
+
/* ββ 6. Buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 300 |
+
|
| 301 |
+
.btn {
|
| 302 |
+
display: inline-flex; align-items: center; justify-content: center; gap: .4em;
|
| 303 |
+
font-family: var(--font-ui); font-size: .87rem; font-weight: 550;
|
| 304 |
+
padding: .5em 1em; border-radius: var(--radius-sm);
|
| 305 |
+
border: 1px solid transparent; white-space: nowrap;
|
| 306 |
+
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
| 307 |
+
}
|
| 308 |
+
.btn svg { width: 1em; height: 1em; }
|
| 309 |
+
.btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
| 310 |
+
:root[data-theme="dark"] .btn-primary,
|
| 311 |
+
.btn-primary { color: #FBFAF8; }
|
| 312 |
+
@media (prefers-color-scheme: dark) {
|
| 313 |
+
:root:not([data-theme="light"]) .btn-primary { color: #14120F; }
|
| 314 |
+
}
|
| 315 |
+
:root[data-theme="dark"] .btn-primary { color: #14120F; }
|
| 316 |
+
.btn-primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
|
| 317 |
+
|
| 318 |
+
.btn-quiet { background: transparent; color: var(--text-2); border-color: var(--border-2); }
|
| 319 |
+
.btn-quiet:hover { background: var(--surface-2); color: var(--text); }
|
| 320 |
+
|
| 321 |
+
.btn-ghost { background: transparent; color: var(--text-3); }
|
| 322 |
+
.btn-ghost:hover { color: var(--text); background: var(--surface-2); }
|
| 323 |
+
|
| 324 |
+
.btn-sm { font-size: .81rem; padding: .4em .8em; }
|
| 325 |
+
|
| 326 |
+
/* ββ 7. Paper card βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 327 |
+
Anatomy, top to bottom:
|
| 328 |
+
meta line one dim mono row β category, year, citations
|
| 329 |
+
title the largest thing on the card; the only element users
|
| 330 |
+
reliably read (NN/g F-pattern eyetracking)
|
| 331 |
+
authors small, dim
|
| 332 |
+
abstract lead sentence marked, remainder dimmed
|
| 333 |
+
actions Save / Pass / Why this
|
| 334 |
+
The old card gave five facts five different visual treatments (coloured
|
| 335 |
+
badge pulled right, year pill, mono id, authors, citation count + icon)
|
| 336 |
+
and they competed with the title. */
|
| 337 |
+
|
| 338 |
+
.feed { display: flex; flex-direction: column; gap: .85rem; }
|
| 339 |
+
|
| 340 |
+
.card {
|
| 341 |
+
background: var(--surface);
|
| 342 |
+
border: 1px solid var(--border);
|
| 343 |
+
border-radius: var(--radius);
|
| 344 |
+
padding: 1.05rem 1.1rem 0.95rem;
|
| 345 |
+
display: flex; flex-direction: column; gap: .55rem;
|
| 346 |
+
box-shadow: var(--shadow-1);
|
| 347 |
+
/* No fixed height anywhere: WCAG 2.2 SC 1.4.12 requires the layout to
|
| 348 |
+
survive a user forcing line-height 1.5x / spacing 2x / letter .12em /
|
| 349 |
+
word .16em without clipping. Everything here reflows. */
|
| 350 |
+
}
|
| 351 |
+
@media (min-width: 720px) { .card { padding: 1.25rem 1.35rem 1.1rem; } }
|
| 352 |
+
|
| 353 |
+
.card-meta {
|
| 354 |
+
display: flex; align-items: center; gap: .45rem; flex-wrap: wrap;
|
| 355 |
+
font-family: var(--font-mono); font-size: .7rem; letter-spacing: .02em;
|
| 356 |
+
color: var(--text-3); font-variant-numeric: tabular-nums;
|
| 357 |
+
}
|
| 358 |
+
.card-meta .sep { opacity: .45; }
|
| 359 |
+
|
| 360 |
+
.card-title {
|
| 361 |
+
font-family: var(--font-read);
|
| 362 |
+
font-size: 1.14rem; line-height: 1.32; font-weight: 600;
|
| 363 |
+
letter-spacing: -.005em; text-wrap: balance;
|
| 364 |
+
color: var(--text);
|
| 365 |
+
}
|
| 366 |
+
@media (min-width: 720px) { .card-title { font-size: 1.2rem; } }
|
| 367 |
+
a.card-title:hover { color: var(--accent); }
|
| 368 |
+
|
| 369 |
+
.card-authors { font-size: .82rem; color: var(--text-3); }
|
| 370 |
+
|
| 371 |
+
.card-abstract {
|
| 372 |
+
font-family: var(--font-read);
|
| 373 |
+
font-size: .95rem; line-height: 1.62; color: var(--text-2);
|
| 374 |
+
}
|
| 375 |
+
/* The lead sentence carries the paper's claim ~always, and NN/g's eyetracking
|
| 376 |
+
shows attention decays sharply after the first couple of lines β so it is
|
| 377 |
+
emphasised rather than buried at character 400. Approximates Scholar
|
| 378 |
+
Inbox's "highlight the sentence most related to the core idea". */
|
| 379 |
+
.card-abstract .lead {
|
| 380 |
+
color: var(--text); font-weight: 500;
|
| 381 |
+
box-shadow: inset 0 -.45em 0 var(--mark);
|
| 382 |
+
}
|
| 383 |
+
.card-abstract .rest { color: var(--text-3); }
|
| 384 |
+
|
| 385 |
+
.card-abstract.is-clamped .rest { display: none; }
|
| 386 |
+
|
| 387 |
+
.card-more {
|
| 388 |
+
align-self: flex-start;
|
| 389 |
+
font-size: .78rem; font-weight: 550; color: var(--text-3);
|
| 390 |
+
padding: .15rem 0;
|
| 391 |
+
}
|
| 392 |
+
.card-more:hover { color: var(--accent); }
|
| 393 |
+
|
| 394 |
+
.card-actions {
|
| 395 |
+
display: flex; align-items: center; gap: .4rem; flex-wrap: wrap;
|
| 396 |
+
padding-top: .2rem;
|
| 397 |
+
}
|
| 398 |
+
.card-why { margin-left: auto; }
|
| 399 |
+
|
| 400 |
+
.btn-save { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-br); }
|
| 401 |
+
.btn-save:hover { background: var(--accent-br); }
|
| 402 |
+
.btn-save[disabled] { opacity: 1; cursor: default; }
|
| 403 |
+
|
| 404 |
+
.btn-pass { background: transparent; color: var(--text-3); border-color: var(--border); }
|
| 405 |
+
.btn-pass:hover { background: var(--surface-2); color: var(--text-2); }
|
| 406 |
+
|
| 407 |
+
/* Deep link into the 3D map. Quiet by default β it is a side door, not a
|
| 408 |
+
primary action, and it must not compete with Save. */
|
| 409 |
+
.card-map {
|
| 410 |
+
display: inline-flex; align-items: center; gap: .35rem;
|
| 411 |
+
align-self: flex-start;
|
| 412 |
+
font-size: .78rem; font-weight: 520; color: var(--text-3);
|
| 413 |
+
}
|
| 414 |
+
.card-map svg { width: .85rem; height: .85rem; }
|
| 415 |
+
.card-map:hover { color: var(--accent); }
|
| 416 |
+
|
| 417 |
+
/* "Why this?" β the serving tier already knows why each paper was retrieved
|
| 418 |
+
(candidate_source); this surfaces it instead of the old Coming Soon badge. */
|
| 419 |
+
.card-reason {
|
| 420 |
+
font-size: .82rem; line-height: 1.5; color: var(--text-3);
|
| 421 |
+
border-left: 2px solid var(--accent-br);
|
| 422 |
+
padding: .1rem 0 .1rem .6rem; margin-top: .1rem;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/* Dismiss animation. The card is only hidden here β the POST is deferred so
|
| 426 |
+
Undo can cancel it outright (see app.js). */
|
| 427 |
+
.card.is-leaving {
|
| 428 |
+
animation: card-leave .18s ease forwards;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
}
|
| 431 |
+
@keyframes card-leave {
|
| 432 |
+
to { opacity: 0; transform: translateX(-8px); max-height: 0;
|
| 433 |
+
padding-block: 0; margin-bottom: -.85rem; }
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* ββ 8. Category chips βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 437 |
+
Radix Colors step semantics: step 3 as the chip background, step 11 as its
|
| 438 |
+
text, with a matched dark variant. Every chip therefore carries identical
|
| 439 |
+
visual weight and guaranteed legibility, and reassigning a category to a
|
| 440 |
+
different hue can never change its contrast. Green is deliberately absent β
|
| 441 |
+
it is reserved for Save, and a green chip would read as "saved". */
|
| 442 |
+
|
| 443 |
+
.cat {
|
| 444 |
+
font-family: var(--font-mono); font-weight: 600;
|
| 445 |
+
font-size: .68rem; letter-spacing: .02em;
|
| 446 |
+
padding: .2em .45em; border-radius: 4px;
|
| 447 |
+
background: var(--surface-2); color: var(--text-2);
|
| 448 |
+
}
|
| 449 |
+
.cat-cl { background: #D6EDF0; color: #0A5C66; }
|
| 450 |
+
.cat-cv { background: #DDEAFA; color: #13528C; }
|
| 451 |
+
.cat-lg { background: #E2E6F8; color: #31418D; }
|
| 452 |
+
.cat-ai { background: #EDE5F6; color: #612E88; }
|
| 453 |
+
.cat-ro { background: #F9E7D6; color: #8A4816; }
|
| 454 |
+
.cat-phys { background: #F5EBD0; color: #75540C; }
|
| 455 |
+
.cat-astro{ background: #F6E2ED; color: #892D5D; }
|
| 456 |
+
.cat-math { background: #E8ECDB; color: #4D591C; }
|
| 457 |
+
.cat-bio { background: #D9EDEA; color: #0C5B4E; }
|
| 458 |
+
.cat-eng { background: #E4E8EA; color: #3E4C53; }
|
| 459 |
+
|
| 460 |
+
@media (prefers-color-scheme: dark) {
|
| 461 |
+
:root:not([data-theme="light"]) .cat-cl { background: #0F2E33; color: #63CCD8; }
|
| 462 |
+
:root:not([data-theme="light"]) .cat-cv { background: #12283F; color: #6EB6F5; }
|
| 463 |
+
:root:not([data-theme="light"]) .cat-lg { background: #1A2140; color: #93A4F5; }
|
| 464 |
+
:root:not([data-theme="light"]) .cat-ai { background: #271A3B; color: #C193F0; }
|
| 465 |
+
:root:not([data-theme="light"]) .cat-ro { background: #33220F; color: #F0A468; }
|
| 466 |
+
:root:not([data-theme="light"]) .cat-phys { background: #2E2711; color: #DDBB55; }
|
| 467 |
+
:root:not([data-theme="light"]) .cat-astro{ background: #331A2A; color: #EE8CC0; }
|
| 468 |
+
:root:not([data-theme="light"]) .cat-math { background: #232811; color: #B8C96A; }
|
| 469 |
+
:root:not([data-theme="light"]) .cat-bio { background: #0F2E2A; color: #63CCB4; }
|
| 470 |
+
:root:not([data-theme="light"]) .cat-eng { background: #1E262A; color: #9BB0BA; }
|
| 471 |
+
}
|
| 472 |
+
:root[data-theme="dark"] .cat-cl { background: #0F2E33; color: #63CCD8; }
|
| 473 |
+
:root[data-theme="dark"] .cat-cv { background: #12283F; color: #6EB6F5; }
|
| 474 |
+
:root[data-theme="dark"] .cat-lg { background: #1A2140; color: #93A4F5; }
|
| 475 |
+
:root[data-theme="dark"] .cat-ai { background: #271A3B; color: #C193F0; }
|
| 476 |
+
:root[data-theme="dark"] .cat-ro { background: #33220F; color: #F0A468; }
|
| 477 |
+
:root[data-theme="dark"] .cat-phys { background: #2E2711; color: #DDBB55; }
|
| 478 |
+
:root[data-theme="dark"] .cat-astro{ background: #331A2A; color: #EE8CC0; }
|
| 479 |
+
:root[data-theme="dark"] .cat-math { background: #232811; color: #B8C96A; }
|
| 480 |
+
:root[data-theme="dark"] .cat-bio { background: #0F2E2A; color: #63CCB4; }
|
| 481 |
+
:root[data-theme="dark"] .cat-eng { background: #1E262A; color: #9BB0BA; }
|
| 482 |
+
|
| 483 |
+
/* ββ 9. Section headers, pills, notices ββββββββββββββββββββββββββββββββββββ */
|
| 484 |
+
|
| 485 |
+
.sec-head {
|
| 486 |
+
display: flex; align-items: baseline; justify-content: space-between;
|
| 487 |
+
gap: 1rem; margin-bottom: .8rem;
|
| 488 |
+
}
|
| 489 |
+
.sec-title { font-size: 1.02rem; font-weight: 620; letter-spacing: -.012em; }
|
| 490 |
+
|
| 491 |
+
.pill {
|
| 492 |
+
font-family: var(--font-mono); font-size: .68rem; letter-spacing: .04em;
|
| 493 |
+
padding: .22em .5em; border-radius: 4px;
|
| 494 |
+
background: var(--surface-2); color: var(--text-3);
|
| 495 |
+
font-variant-numeric: tabular-nums;
|
| 496 |
+
}
|
| 497 |
+
.pill-accent { background: var(--accent-bg); color: var(--accent); }
|
| 498 |
+
|
| 499 |
+
.notice {
|
| 500 |
+
display: flex; align-items: flex-start; gap: .6rem;
|
| 501 |
+
padding: .7rem .85rem; border-radius: var(--radius-sm);
|
| 502 |
+
background: var(--surface-2); border: 1px solid var(--border);
|
| 503 |
+
font-size: .87rem; color: var(--text-2);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/* ββ 10. Empty / end states ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 507 |
+
|
| 508 |
+
.empty {
|
| 509 |
+
display: flex; flex-direction: column; align-items: center; gap: .7rem;
|
| 510 |
+
text-align: center; padding: 3.5rem 1rem; max-width: 30rem; margin-inline: auto;
|
| 511 |
+
}
|
| 512 |
+
.empty-title { font-family: var(--font-read); font-size: 1.15rem; font-weight: 600; }
|
| 513 |
+
.empty-body { font-size: .9rem; color: var(--text-3); }
|
| 514 |
+
|
| 515 |
+
.feed-end {
|
| 516 |
+
text-align: center; padding: 2rem 1rem 1rem;
|
| 517 |
+
font-size: .85rem; color: var(--text-3);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
/* ββ 11. Feed loader (infinite scroll) βββββββββββββββββββββββββββββββββββ
|
| 521 |
+
A real <button>, not a bare sentinel div. NN/g's infinite-scroll findings:
|
| 522 |
+
screen-reader users "will only see the first chunk of the list, without
|
| 523 |
+
being able to trigger the loading of new content", and a list with no
|
| 524 |
+
terminus also breaks footer access and hides whether more exists.
|
| 525 |
+
Carrying hx-trigger="revealed, click" makes one element serve both β it
|
| 526 |
+
auto-fires on scroll and stays keyboard-focusable and activatable. */
|
| 527 |
+
|
| 528 |
+
.feed-more {
|
| 529 |
+
display: flex; align-items: center; justify-content: center; gap: .5rem;
|
| 530 |
+
width: 100%; padding: .9rem 1rem; margin-top: .3rem;
|
| 531 |
+
border: 1px dashed var(--border-2); border-radius: var(--radius);
|
| 532 |
+
color: var(--text-3); font-size: .85rem; font-weight: 540;
|
| 533 |
background: transparent;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
}
|
| 535 |
+
.feed-more:hover { color: var(--text-2); background: var(--surface-2); border-style: solid; }
|
| 536 |
+
.feed-more .spinner { display: none; }
|
| 537 |
+
.feed-more.htmx-request .spinner { display: block; }
|
| 538 |
+
.feed-more.htmx-request .label { opacity: .55; }
|
| 539 |
|
| 540 |
+
.spinner {
|
| 541 |
+
width: 1rem; height: 1rem; flex: none;
|
| 542 |
+
border: 2px solid var(--border-2); border-top-color: var(--accent);
|
| 543 |
+
border-radius: 50%; animation: spin .6s linear infinite;
|
|
|
|
|
|
|
|
|
|
| 544 |
}
|
| 545 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 546 |
|
| 547 |
+
/* ββ 12. Skeletons βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 548 |
+
Nielsen's response-time limits: under 0.1s feels instant, up to 1s keeps
|
| 549 |
+
flow, and beyond that the user needs feedback. Tier 1 of this pipeline is
|
| 550 |
+
multi-second, so a skeleton that mirrors the real card's geometry is
|
| 551 |
+
load-bearing, not decoration β it also prevents the layout shift that a
|
| 552 |
+
spinner-then-content swap causes. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
+
.skel-card {
|
| 555 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 556 |
+
border-radius: var(--radius); padding: 1.05rem 1.1rem;
|
| 557 |
+
display: flex; flex-direction: column; gap: .6rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
}
|
| 559 |
+
@media (min-width: 720px) { .skel-card { padding: 1.25rem 1.35rem; } }
|
| 560 |
|
| 561 |
+
.skel {
|
| 562 |
+
height: .7rem; border-radius: 4px;
|
| 563 |
+
background: linear-gradient(90deg, var(--surface-2) 25%, var(--border) 50%, var(--surface-2) 75%);
|
| 564 |
+
background-size: 200% 100%;
|
| 565 |
+
animation: shimmer 1.4s ease-in-out infinite;
|
| 566 |
+
}
|
| 567 |
+
@keyframes shimmer { to { background-position: -200% 0; } }
|
| 568 |
+
|
| 569 |
+
.skel-meta { width: 42%; height: .6rem; }
|
| 570 |
+
.skel-title { width: 92%; height: 1.05rem; }
|
| 571 |
+
.skel-title2 { width: 64%; height: 1.05rem; }
|
| 572 |
+
.skel-auth { width: 38%; height: .62rem; }
|
| 573 |
+
.skel-text { width: 100%; }
|
| 574 |
+
.skel-text-2 { width: 96%; }
|
| 575 |
+
.skel-text-3 { width: 71%; }
|
| 576 |
+
.skel-btn { width: 4.6rem; height: 1.9rem; border-radius: var(--radius-sm); }
|
| 577 |
+
|
| 578 |
+
/* ββ 13. Toast (undo) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 579 |
+
NN/g on repetitive destructive actions: "if the action is highly repetitive
|
| 580 |
+
and asking for confirmations would be tedious, support easy undo. Make sure
|
| 581 |
+
that the undo option is highly salient on the screen." A confirm dialog
|
| 582 |
+
would tax every user who meant it. */
|
| 583 |
+
|
| 584 |
+
.toasts {
|
| 585 |
+
position: fixed; z-index: 100; left: 50%; transform: translateX(-50%);
|
| 586 |
+
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
| 587 |
+
display: flex; flex-direction: column-reverse; gap: .5rem;
|
| 588 |
+
width: min(24rem, calc(100vw - 2rem));
|
| 589 |
+
pointer-events: none;
|
| 590 |
}
|
| 591 |
+
@media (max-width: 719px) { .toasts { bottom: calc(4.6rem + env(safe-area-inset-bottom, 0px)); } }
|
| 592 |
+
@media (min-width: 720px) { .toasts { left: auto; right: 1.25rem; transform: none; } }
|
| 593 |
|
| 594 |
+
.toast {
|
| 595 |
+
pointer-events: auto;
|
| 596 |
+
display: flex; align-items: center; gap: .75rem;
|
| 597 |
+
padding: .65rem .75rem .65rem .95rem;
|
| 598 |
+
background: var(--surface); color: var(--text);
|
| 599 |
+
border: 1px solid var(--border-2); border-radius: var(--radius);
|
| 600 |
+
box-shadow: var(--shadow-2); font-size: .87rem;
|
| 601 |
+
animation: toast-in .18s ease;
|
| 602 |
+
}
|
| 603 |
+
.toast .msg { flex: 1; min-width: 0; }
|
| 604 |
+
.toast .undo {
|
| 605 |
+
font-weight: 620; color: var(--accent); font-size: .85rem;
|
| 606 |
+
padding: .25rem .5rem; border-radius: var(--radius-sm);
|
| 607 |
+
}
|
| 608 |
+
.toast .undo:hover { background: var(--accent-bg); }
|
| 609 |
+
.toast.is-out { animation: toast-out .16s ease forwards; }
|
| 610 |
+
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } }
|
| 611 |
+
@keyframes toast-out { to { opacity: 0; transform: translateY(6px); } }
|
| 612 |
|
| 613 |
+
/* ββ 14. Search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
+
.searchbar {
|
| 616 |
+
display: flex; align-items: center; gap: .5rem;
|
| 617 |
+
background: var(--surface); border: 1px solid var(--border-2);
|
| 618 |
+
border-radius: var(--radius); padding: .35rem .35rem .35rem .8rem;
|
| 619 |
+
box-shadow: var(--shadow-1);
|
| 620 |
+
}
|
| 621 |
+
.searchbar:focus-within { border-color: var(--accent-2); }
|
| 622 |
+
.searchbar svg { width: 1.05rem; height: 1.05rem; color: var(--text-3); flex: none; }
|
| 623 |
+
.searchbar input {
|
| 624 |
+
flex: 1; min-width: 0; border: 0; outline: 0; background: none;
|
| 625 |
+
padding: .5rem 0; font-size: .97rem;
|
| 626 |
}
|
| 627 |
+
.searchbar input::placeholder { color: var(--text-3); }
|
| 628 |
|
| 629 |
+
.search-meta {
|
| 630 |
+
font-family: var(--font-mono); font-size: .7rem; color: var(--text-3);
|
| 631 |
+
display: flex; flex-wrap: wrap; gap: .25rem .8rem;
|
| 632 |
+
font-variant-numeric: tabular-nums;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.ai-summary {
|
| 636 |
+
border-left: 2px solid var(--accent-br);
|
| 637 |
+
background: var(--surface-2);
|
| 638 |
+
border-radius: var(--radius-sm);
|
| 639 |
+
padding: .85rem 1rem;
|
| 640 |
+
font-family: var(--font-read); font-size: .93rem; line-height: 1.6;
|
| 641 |
+
color: var(--text-2);
|
| 642 |
+
}
|
| 643 |
+
.ai-summary strong { color: var(--text); font-weight: 620; }
|
| 644 |
+
.ai-summary .label {
|
| 645 |
+
display: block; font-family: var(--font-mono); font-size: .64rem;
|
| 646 |
+
letter-spacing: .09em; text-transform: uppercase; color: var(--text-3);
|
| 647 |
+
margin-bottom: .35rem;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
/* ββ 15. Onboarding ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 651 |
+
|
| 652 |
+
.ob-step { display: flex; flex-direction: column; gap: 1.25rem; }
|
| 653 |
+
.ob-head { display: flex; flex-direction: column; gap: .4rem; }
|
| 654 |
+
.ob-kicker {
|
| 655 |
+
font-family: var(--font-mono); font-size: .68rem; letter-spacing: .1em;
|
| 656 |
+
text-transform: uppercase; color: var(--accent);
|
| 657 |
+
}
|
| 658 |
+
.ob-title {
|
| 659 |
+
font-family: var(--font-read); font-size: 1.5rem; line-height: 1.2;
|
| 660 |
+
font-weight: 620; letter-spacing: -.01em; text-wrap: balance;
|
| 661 |
+
}
|
| 662 |
+
.ob-sub { font-size: .92rem; color: var(--text-3); max-width: 46ch; }
|
| 663 |
+
|
| 664 |
+
.cat-grid {
|
| 665 |
+
display: grid; gap: .55rem;
|
| 666 |
+
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
| 667 |
+
}
|
| 668 |
+
.cat-opt {
|
| 669 |
+
display: flex; flex-direction: column; gap: .2rem; text-align: left;
|
| 670 |
+
padding: .7rem .8rem; border-radius: var(--radius-sm);
|
| 671 |
+
border: 1px solid var(--border); background: var(--surface);
|
| 672 |
+
transition: border-color .12s ease, background .12s ease;
|
| 673 |
+
}
|
| 674 |
+
.cat-opt:hover { border-color: var(--border-2); }
|
| 675 |
+
.cat-opt[aria-pressed="true"] {
|
| 676 |
+
border-color: var(--accent); background: var(--accent-bg);
|
| 677 |
+
}
|
| 678 |
+
.cat-opt .name { font-size: .87rem; font-weight: 560; }
|
| 679 |
+
.cat-opt .desc { font-size: .75rem; color: var(--text-3); line-height: 1.4; }
|
| 680 |
+
.cat-opt[aria-pressed="true"] .name { color: var(--accent); }
|
| 681 |
+
|
| 682 |
+
.ob-progress {
|
| 683 |
+
display: flex; align-items: center; gap: .5rem;
|
| 684 |
+
font-family: var(--font-mono); font-size: .72rem; color: var(--text-3);
|
| 685 |
+
font-variant-numeric: tabular-nums;
|
| 686 |
+
}
|
| 687 |
+
.ob-bar { flex: 1; height: 3px; border-radius: 2px; background: var(--surface-2); overflow: hidden; }
|
| 688 |
+
.ob-bar > i { display: block; height: 100%; background: var(--accent); transition: width .25s ease; }
|
| 689 |
+
|
| 690 |
+
.ob-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
| 691 |
+
|
| 692 |
+
.ob-footer {
|
| 693 |
+
position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
|
| 694 |
+
background: var(--bg); border-top: 1px solid var(--border);
|
| 695 |
+
padding: .75rem 0 calc(.75rem + env(safe-area-inset-bottom, 0px));
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
/* Seed picker rows β denser than a feed card, because this is a pick list and
|
| 699 |
+
the decision is "is this roughly my field?", not "do I want to read it now?" */
|
| 700 |
+
.seed-row {
|
| 701 |
+
display: flex; align-items: flex-start; gap: .9rem;
|
| 702 |
+
padding: .8rem .95rem; background: var(--surface);
|
| 703 |
+
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
| 704 |
}
|
| 705 |
+
.seed-title {
|
| 706 |
+
font-family: var(--font-read); font-size: .95rem; line-height: 1.35;
|
| 707 |
+
font-weight: 560; color: var(--text); display: block;
|
|
|
|
|
|
|
|
|
|
| 708 |
}
|
| 709 |
+
.seed-title:hover { color: var(--accent); }
|
| 710 |
|
| 711 |
+
/* ββ 16. Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
|
| 713 |
+
.muted { color: var(--text-3); }
|
| 714 |
+
.small { font-size: .85rem; }
|
| 715 |
+
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
| 716 |
+
.center { text-align: center; }
|
| 717 |
+
.hidden { display: none !important; }
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
+
/* Any wide content (tables, code, pre) scrolls inside itself so the page body
|
| 720 |
+
never scrolls sideways. */
|
| 721 |
+
.scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
|
| 723 |
+
/* htmx indicator convention */
|
| 724 |
+
.htmx-indicator { display: none; }
|
| 725 |
+
.htmx-request .htmx-indicator { display: inline-block; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
|
| 727 |
+
/* ββ 17. Motion preferences ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
| 728 |
|
| 729 |
+
@media (prefers-reduced-motion: reduce) {
|
| 730 |
+
*, *::before, *::after {
|
| 731 |
+
animation-duration: .01ms !important;
|
| 732 |
+
animation-iteration-count: 1 !important;
|
| 733 |
+
transition-duration: .01ms !important;
|
| 734 |
+
scroll-behavior: auto !important;
|
| 735 |
+
}
|
| 736 |
+
.card.is-leaving { animation: none; display: none; }
|
| 737 |
}
|
|
@@ -1,137 +1,121 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<meta name="description" content="ResearchIT β
|
|
|
|
| 7 |
<title>{% block title %}ResearchIT{% endblock %}</title>
|
| 8 |
|
| 9 |
-
<!-- DaisyUI + TailwindCSS (CDN, no build step) -->
|
| 10 |
-
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" />
|
| 11 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 12 |
-
|
| 13 |
-
<!-- Custom styles -->
|
| 14 |
<link rel="stylesheet" href="/static/styles.css" />
|
| 15 |
-
<link rel="stylesheet" href="/static/animations.css" />
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
</style>
|
| 25 |
-
|
| 26 |
-
<!-- Theme restore (runs before body to prevent flash) -->
|
| 27 |
<script>
|
| 28 |
-
(function() {
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
})();
|
| 32 |
</script>
|
| 33 |
|
| 34 |
{% block head %}{% endblock %}
|
| 35 |
</head>
|
| 36 |
-
<body
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
<
|
| 39 |
-
<nav class="rit-navbar navbar sticky top-0 z-50 px-4 md:px-6">
|
| 40 |
-
<div class="flex-1">
|
| 41 |
-
<a href="/" class="flex items-center gap-2 text-xl group">
|
| 42 |
-
<svg class="w-7 h-7 text-sky-400 group-hover:text-sky-300 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 43 |
-
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
| 44 |
-
<path d="M11 8v6M8 11h6" opacity="0.5"/>
|
| 45 |
-
</svg>
|
| 46 |
-
<span class="brand-text text-lg">ResearchIT</span>
|
| 47 |
-
</a>
|
| 48 |
-
</div>
|
| 49 |
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
</div>
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
| 79 |
</div>
|
| 80 |
-
</
|
| 81 |
-
</nav>
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
</main>
|
| 87 |
|
| 88 |
-
<
|
| 89 |
-
<footer class="app-footer">
|
| 90 |
-
<div class="container mx-auto px-4 max-w-4xl">
|
| 91 |
-
<p class="mb-2">
|
| 92 |
-
<span class="brand-text font-semibold">ResearchIT</span>
|
| 93 |
-
<span class="mx-2 opacity-30">Β·</span>
|
| 94 |
-
<span>Your personal research feed</span>
|
| 95 |
-
</p>
|
| 96 |
-
<div class="flex flex-wrap justify-center gap-2 mt-3">
|
| 97 |
-
<span class="footer-tech-badge">Qdrant</span>
|
| 98 |
-
<span class="footer-tech-badge">Zilliz</span>
|
| 99 |
-
<span class="footer-tech-badge">BGE-M3</span>
|
| 100 |
-
<span class="footer-tech-badge">LightGBM</span>
|
| 101 |
-
<span class="footer-tech-badge">Groq</span>
|
| 102 |
-
<span class="footer-tech-badge">Turso</span>
|
| 103 |
-
</div>
|
| 104 |
-
</div>
|
| 105 |
-
</footer>
|
| 106 |
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
<path
|
|
|
|
| 112 |
</svg>
|
| 113 |
-
<span>
|
| 114 |
</a>
|
| 115 |
-
<a href="/search" class="
|
| 116 |
-
<svg
|
| 117 |
-
|
|
|
|
| 118 |
</svg>
|
| 119 |
<span>Search</span>
|
| 120 |
</a>
|
| 121 |
-
<a href="/saved" class="
|
| 122 |
-
<svg
|
| 123 |
-
|
|
|
|
| 124 |
</svg>
|
| 125 |
-
<span>
|
| 126 |
</a>
|
| 127 |
-
</
|
| 128 |
|
| 129 |
-
|
| 130 |
-
<div id="
|
| 131 |
|
| 132 |
-
<
|
| 133 |
<script src="/static/app.js"></script>
|
| 134 |
{% block scripts %}{% endblock %}
|
| 135 |
-
|
| 136 |
</body>
|
| 137 |
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
{#
|
| 3 |
+
Base layout.
|
| 4 |
+
|
| 5 |
+
Loads NOTHING from an external origin. Previously this page pulled from four:
|
| 6 |
+
cdn.jsdelivr.net (DaisyUI), cdn.tailwindcss.com (Play CDN), unpkg.com (htmx)
|
| 7 |
+
and fonts.googleapis.com (via an @import inside styles.css, which serialised
|
| 8 |
+
the font request behind fetching and parsing that stylesheet).
|
| 9 |
+
|
| 10 |
+
The Play CDN in particular applies styles at runtime and its documented worst
|
| 11 |
+
case is "elements added to the page after first load" β i.e. every htmx-swapped
|
| 12 |
+
paper card on an infinite feed. See the note at the top of styles.css.
|
| 13 |
+
#}
|
| 14 |
+
<html lang="en">
|
| 15 |
<head>
|
| 16 |
<meta charset="UTF-8" />
|
| 17 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 18 |
+
<meta name="description" content="ResearchIT β a personal reading feed for arXiv. Papers picked for the things you actually work on." />
|
| 19 |
+
<meta name="color-scheme" content="light dark" />
|
| 20 |
<title>{% block title %}ResearchIT{% endblock %}</title>
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
<link rel="stylesheet" href="/static/styles.css" />
|
|
|
|
| 23 |
|
| 24 |
+
{#
|
| 25 |
+
Theme restore. Runs before first paint so an explicit choice never flashes.
|
| 26 |
+
Only an EXPLICIT choice is stamped β with nothing in localStorage the page
|
| 27 |
+
is left unstamped and follows the OS via prefers-color-scheme, which is the
|
| 28 |
+
correct default. The old build hardcoded data-theme="dark" on <html>, so a
|
| 29 |
+
light-mode user got a dark page regardless of their system setting.
|
| 30 |
+
#}
|
|
|
|
|
|
|
|
|
|
| 31 |
<script>
|
| 32 |
+
(function () {
|
| 33 |
+
try {
|
| 34 |
+
var t = localStorage.getItem('researchit-theme');
|
| 35 |
+
if (t === 'dark' || t === 'light') {
|
| 36 |
+
document.documentElement.setAttribute('data-theme', t);
|
| 37 |
+
}
|
| 38 |
+
} catch (e) { /* private mode β fall through to system preference */ }
|
| 39 |
})();
|
| 40 |
</script>
|
| 41 |
|
| 42 |
{% block head %}{% endblock %}
|
| 43 |
</head>
|
| 44 |
+
<body>
|
| 45 |
+
{% set path = request.url.path if request else '/' %}
|
| 46 |
+
|
| 47 |
+
<a class="skip-link" href="#main">Skip to content</a>
|
| 48 |
|
| 49 |
+
<div class="shell">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
<header class="topbar">
|
| 52 |
+
<div class="wrap">
|
| 53 |
+
<a href="/" class="brand">
|
| 54 |
+
<svg class="brand-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 55 |
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 56 |
+
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15H6.5A2.5 2.5 0 0 0 4 20.5z" />
|
| 57 |
+
<path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H19v3H6.5A2.5 2.5 0 0 1 4 20.5z" />
|
| 58 |
+
<path d="M9 8h6M9 11.5h4" opacity=".55" />
|
| 59 |
+
</svg>
|
| 60 |
+
<span>ResearchIT</span>
|
| 61 |
+
</a>
|
|
|
|
| 62 |
|
| 63 |
+
<nav class="nav" aria-label="Main">
|
| 64 |
+
<a href="/" class="nav-link" {% if path == '/' %}aria-current="page"{% endif %}>Feed</a>
|
| 65 |
+
<a href="/search" class="nav-link" {% if path.startswith('/search') %}aria-current="page"{% endif %}>Search</a>
|
| 66 |
+
<a href="/saved" class="nav-link" {% if path.startswith('/saved') %}aria-current="page"{% endif %}>Library</a>
|
| 67 |
+
<button type="button" class="icon-btn theme-toggle" onclick="toggleTheme()"
|
| 68 |
+
aria-label="Switch between light and dark theme">
|
| 69 |
+
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 70 |
+
stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
| 71 |
+
<circle cx="12" cy="12" r="4.2" />
|
| 72 |
+
<path d="M12 2.5v2M12 19.5v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2.5 12h2M19.5 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
|
| 73 |
+
</svg>
|
| 74 |
+
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 75 |
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 76 |
+
<path d="M20.5 13.4A8.5 8.5 0 1 1 10.6 3.5a6.7 6.7 0 0 0 9.9 9.9z" />
|
| 77 |
+
</svg>
|
| 78 |
+
</button>
|
| 79 |
+
</nav>
|
| 80 |
</div>
|
| 81 |
+
</header>
|
|
|
|
| 82 |
|
| 83 |
+
<main id="main">
|
| 84 |
+
{% block content %}{% endblock %}
|
| 85 |
+
</main>
|
|
|
|
| 86 |
|
| 87 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
<nav class="botnav" aria-label="Main">
|
| 90 |
+
<a href="/" class="botnav-item" {% if path == '/' %}aria-current="page"{% endif %}>
|
| 91 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 92 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 93 |
+
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4H19v14H6.5A2.5 2.5 0 0 0 4 20.5z" />
|
| 94 |
+
<path d="M9 9h6" opacity=".55" />
|
| 95 |
</svg>
|
| 96 |
+
<span>Feed</span>
|
| 97 |
</a>
|
| 98 |
+
<a href="/search" class="botnav-item" {% if path.startswith('/search') %}aria-current="page"{% endif %}>
|
| 99 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 100 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 101 |
+
<circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" />
|
| 102 |
</svg>
|
| 103 |
<span>Search</span>
|
| 104 |
</a>
|
| 105 |
+
<a href="/saved" class="botnav-item" {% if path.startswith('/saved') %}aria-current="page"{% endif %}>
|
| 106 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
| 107 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 108 |
+
<path d="M6 4h12a1 1 0 0 1 1 1v15l-7-3.6L5 20V5a1 1 0 0 1 1-1z" />
|
| 109 |
</svg>
|
| 110 |
+
<span>Library</span>
|
| 111 |
</a>
|
| 112 |
+
</nav>
|
| 113 |
|
| 114 |
+
{# Undo toasts mount here. See app.js β dismissal is deferred, not confirmed. #}
|
| 115 |
+
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
|
| 116 |
|
| 117 |
+
<script src="/static/htmx.min.js"></script>
|
| 118 |
<script src="/static/app.js"></script>
|
| 119 |
{% block scripts %}{% endblock %}
|
|
|
|
| 120 |
</body>
|
| 121 |
</html>
|
|
@@ -1,98 +1,33 @@
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
-
{% block title %}ResearchIT
|
| 4 |
|
| 5 |
-
{
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
<!-- βββ Hero Section βββ -->
|
| 9 |
-
<div class="py-8 md:py-14 text-center relative">
|
| 10 |
-
<!-- Decorative glow -->
|
| 11 |
-
<div class="rit-hero-glow animate-glow" style="top: -100px; left: 50%; transform: translateX(-50%);"></div>
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
<div class="rit-search-wrapper">
|
| 23 |
-
<div class="card bg-base-100 shadow-lg rounded-xl">
|
| 24 |
-
<form action="/search" method="get" class="flex gap-2 p-2 items-center">
|
| 25 |
-
<svg class="w-5 h-5 ml-3 text-base-content/30 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 26 |
-
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>
|
| 27 |
-
</svg>
|
| 28 |
-
<input type="text"
|
| 29 |
-
name="q"
|
| 30 |
-
placeholder="e.g. efficient transformers for edge devicesβ¦"
|
| 31 |
-
class="rit-search-input text-base md:text-lg py-3"
|
| 32 |
-
autofocus />
|
| 33 |
-
<button class="btn btn-primary btn-md rounded-lg px-5 flex-shrink-0" type="submit">
|
| 34 |
-
Search
|
| 35 |
-
</button>
|
| 36 |
-
</form>
|
| 37 |
-
</div>
|
| 38 |
-
</div>
|
| 39 |
-
</div>
|
| 40 |
-
|
| 41 |
-
<!-- Tech badges -->
|
| 42 |
-
<div class="flex flex-wrap justify-center gap-4 mt-6 text-xs text-base-content/40 relative z-10">
|
| 43 |
-
<span class="flex items-center gap-1.5">
|
| 44 |
-
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
| 45 |
-
Qdrant + Zilliz Vector Search
|
| 46 |
-
</span>
|
| 47 |
-
<span class="flex items-center gap-1.5">
|
| 48 |
-
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
| 49 |
-
Groq AI Summaries
|
| 50 |
-
</span>
|
| 51 |
-
<span class="flex items-center gap-1.5">
|
| 52 |
-
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342" /></svg>
|
| 53 |
-
LightGBM Reranker
|
| 54 |
-
</span>
|
| 55 |
-
</div>
|
| 56 |
-
</div>
|
| 57 |
-
|
| 58 |
-
<!-- βββ Recommendations Section βββ -->
|
| 59 |
-
<div>
|
| 60 |
-
<div class="flex items-center justify-between mb-4">
|
| 61 |
-
<h2 class="text-lg md:text-xl font-semibold font-display">Recommended for You</h2>
|
| 62 |
-
{% if save_count %}
|
| 63 |
-
<span class="stat-pill bg-primary/10 text-primary text-xs">
|
| 64 |
-
{{ save_count }} paper{{ "s" if save_count != 1 else "" }} saved
|
| 65 |
-
</span>
|
| 66 |
-
{% endif %}
|
| 67 |
-
</div>
|
| 68 |
-
|
| 69 |
-
<!-- Progress messages (above skeleton/content) -->
|
| 70 |
-
<div id="rec-progress-msgs" class="rit-progress-container"></div>
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<!-- Skeleton loaders while recommendations load -->
|
| 78 |
-
{% set count = 4 %}
|
| 79 |
-
{% include "partials/skeleton.html" %}
|
| 80 |
-
</div>
|
| 81 |
-
</div>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
<span class="text-sm font-medium text-base-content/50">Researchers like you also readβ¦</span>
|
| 91 |
-
<span class="coming-soon-badge">π Coming Soon</span>
|
| 92 |
-
</div>
|
| 93 |
-
<p class="text-xs text-base-content/30">Collaborative filtering recommendations based on similar research profiles.</p>
|
| 94 |
-
</div>
|
| 95 |
-
</div>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
</div>
|
|
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
+
{% block title %}ResearchIT{% endblock %}
|
| 4 |
|
| 5 |
+
{#
|
| 6 |
+
Feed page.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
The hero is gone. It previously ran py-8/py-14 with a brand headline, a
|
| 9 |
+
strapline naming the embedding model and reranker, a search box and three
|
| 10 |
+
vendor badges β so on a phone the first paper sat entirely below the fold.
|
| 11 |
+
A feed's job is to show the feed. Search has its own page and is one tap away
|
| 12 |
+
in both navs.
|
| 13 |
+
#}
|
| 14 |
|
| 15 |
+
{% block content %}
|
| 16 |
+
<div class="wrap wrap--feed">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
<div class="sec-head">
|
| 19 |
+
<h1 class="sec-title">Your feed</h1>
|
| 20 |
+
{% if save_count %}
|
| 21 |
+
<span class="pill">{{ save_count }} saved</span>
|
| 22 |
+
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
| 24 |
|
| 25 |
+
<div id="rec-section"
|
| 26 |
+
hx-get="/api/recommendations"
|
| 27 |
+
hx-trigger="load"
|
| 28 |
+
hx-swap="innerHTML">
|
| 29 |
+
{% set count = 4 %}
|
| 30 |
+
{% include "partials/skeleton.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</div>
|
| 32 |
|
| 33 |
</div>
|
|
@@ -1,187 +1,122 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<
|
| 7 |
-
|
| 8 |
-
<!-- DaisyUI + TailwindCSS (CDN) -->
|
| 9 |
-
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" />
|
| 10 |
-
<script src="https://cdn.tailwindcss.com"></script>
|
| 11 |
-
|
| 12 |
-
<!-- Custom styles -->
|
| 13 |
<link rel="stylesheet" href="/static/styles.css" />
|
| 14 |
-
<link rel="stylesheet" href="/static/animations.css" />
|
| 15 |
-
|
| 16 |
-
<!-- HTMX -->
|
| 17 |
-
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
| 18 |
-
|
| 19 |
-
<style>
|
| 20 |
-
/* HTMX indicator visibility */
|
| 21 |
-
.htmx-request .htmx-indicator { display: inline-block !important; }
|
| 22 |
-
.htmx-indicator { display: none; }
|
| 23 |
-
</style>
|
| 24 |
-
|
| 25 |
-
<!-- Theme restore -->
|
| 26 |
<script>
|
| 27 |
-
(function() {
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
})();
|
| 31 |
</script>
|
| 32 |
</head>
|
| 33 |
-
<body
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
</div>
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
<h1 class="text-3xl md:text-5xl font-extrabold mb-4 font-display relative z-10">
|
| 49 |
-
<span class="brand-text">Build Your Research Feed</span>
|
| 50 |
-
</h1>
|
| 51 |
-
<p class="text-base-content/60 max-w-lg mx-auto relative z-10">
|
| 52 |
-
Select your research areas to get personalized recommendations. Choose at least 1 topic.
|
| 53 |
</p>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
-
<div class="
|
| 57 |
{% for key, cat in categories.items() %}
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
<
|
| 64 |
-
<span class="tile-icon">{{ cat.icon }}</span>
|
| 65 |
-
<h3 class="tile-name">{{ cat.name }}</h3>
|
| 66 |
-
<p class="tile-desc">{{ cat.desc }}</p>
|
| 67 |
-
</div>
|
| 68 |
{% endfor %}
|
| 69 |
</div>
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
<button id="continue-btn" onclick="submitCategories()" class="btn btn-primary btn-sm md:btn-md px-6 shadow-lg shadow-primary/20" {% if selected|length == 0 %}disabled{% endif %}>
|
| 83 |
-
Continue
|
| 84 |
-
<svg class="w-4 h-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
|
| 85 |
-
</button>
|
| 86 |
-
</div>
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
</div>
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
<div id="step-2" class="hidden">
|
| 93 |
-
<!-- Content loaded via HTMX from /api/onboarding/categories -->
|
| 94 |
-
</div>
|
| 95 |
-
|
| 96 |
-
</main>
|
| 97 |
|
|
|
|
| 98 |
<script src="/static/app.js"></script>
|
| 99 |
<script>
|
| 100 |
-
|
| 101 |
-
let selectedCategories = new Set({{ selected | tojson }});
|
| 102 |
|
| 103 |
-
function toggleCategory(
|
| 104 |
-
|
| 105 |
-
|
| 106 |
if (selectedCategories.has(key)) {
|
| 107 |
selectedCategories.delete(key);
|
| 108 |
-
|
| 109 |
} else {
|
| 110 |
-
if (selectedCategories.size >= 8)
|
| 111 |
-
showToast('Maximum 8 categories allowed', 'error');
|
| 112 |
-
return;
|
| 113 |
-
}
|
| 114 |
selectedCategories.add(key);
|
| 115 |
-
|
| 116 |
-
}
|
| 117 |
-
updateUI();
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
function updateUI() {
|
| 121 |
-
const countEl = document.getElementById('selected-count');
|
| 122 |
-
const btn = document.getElementById('continue-btn');
|
| 123 |
-
|
| 124 |
-
countEl.innerText = selectedCategories.size;
|
| 125 |
-
|
| 126 |
-
if (selectedCategories.size > 0) {
|
| 127 |
-
btn.removeAttribute('disabled');
|
| 128 |
-
btn.classList.add('animate-pulse-once');
|
| 129 |
-
} else {
|
| 130 |
-
btn.setAttribute('disabled', 'true');
|
| 131 |
-
btn.classList.remove('animate-pulse-once');
|
| 132 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
async function submitCategories() {
|
| 136 |
-
if (selectedCategories.size
|
| 137 |
-
|
| 138 |
-
const btn = document.getElementById('continue-btn');
|
| 139 |
-
const originalText = btn.innerHTML;
|
| 140 |
-
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Saving...';
|
| 141 |
btn.setAttribute('disabled', 'true');
|
| 142 |
-
|
| 143 |
try {
|
| 144 |
-
|
| 145 |
method: 'POST',
|
| 146 |
headers: { 'Content-Type': 'application/json' },
|
| 147 |
body: JSON.stringify({ categories: Array.from(selectedCategories) })
|
| 148 |
});
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
step2.classList.remove('hidden');
|
| 158 |
-
step2.classList.add('fade-in');
|
| 159 |
-
|
| 160 |
-
// Update dots
|
| 161 |
-
document.getElementById('dot-1').classList.replace('active', 'completed');
|
| 162 |
-
document.getElementById('dot-2').classList.add('active');
|
| 163 |
-
|
| 164 |
-
// Initialize HTMX on the newly injected HTML
|
| 165 |
-
htmx.process(step2);
|
| 166 |
-
} else {
|
| 167 |
-
showToast('Failed to save categories. Please try again.', 'error');
|
| 168 |
-
btn.innerHTML = originalText;
|
| 169 |
-
btn.removeAttribute('disabled');
|
| 170 |
-
}
|
| 171 |
} catch (e) {
|
| 172 |
-
showToast('Network error. Please try again.', 'error');
|
| 173 |
-
btn.innerHTML = originalText;
|
| 174 |
btn.removeAttribute('disabled');
|
|
|
|
| 175 |
}
|
| 176 |
}
|
| 177 |
|
| 178 |
async function skipOnboarding() {
|
| 179 |
-
try {
|
| 180 |
-
|
| 181 |
-
window.location.href = '/';
|
| 182 |
-
} catch (e) {
|
| 183 |
-
window.location.href = '/';
|
| 184 |
-
}
|
| 185 |
}
|
| 186 |
</script>
|
| 187 |
</body>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
{#
|
| 3 |
+
Onboarding wizard. Standalone rather than extending base.html β the nav would
|
| 4 |
+
invite people out of a two-step flow β but it loads the same local stylesheet
|
| 5 |
+
and the same vendored htmx, so no external origins here either.
|
| 6 |
+
#}
|
| 7 |
+
<html lang="en">
|
| 8 |
<head>
|
| 9 |
+
<meta charset="UTF-8" />
|
| 10 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 11 |
+
<meta name="color-scheme" content="light dark" />
|
| 12 |
+
<title>Welcome β ResearchIT</title>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
<link rel="stylesheet" href="/static/styles.css" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<script>
|
| 15 |
+
(function () {
|
| 16 |
+
try {
|
| 17 |
+
var t = localStorage.getItem('researchit-theme');
|
| 18 |
+
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
|
| 19 |
+
} catch (e) {}
|
| 20 |
})();
|
| 21 |
</script>
|
| 22 |
</head>
|
| 23 |
+
<body>
|
| 24 |
+
<main class="wrap" style="max-width:46rem;padding-block:clamp(2rem,7vh,4.5rem) 8rem">
|
| 25 |
|
| 26 |
+
<div id="step-1" class="ob-step">
|
| 27 |
+
<div class="ob-progress">
|
| 28 |
+
<span>Step 1 of 2</span>
|
| 29 |
+
<span class="ob-bar"><i style="width:50%"></i></span>
|
| 30 |
+
</div>
|
|
|
|
| 31 |
|
| 32 |
+
<div class="ob-head">
|
| 33 |
+
<span class="ob-kicker">Pick your areas</span>
|
| 34 |
+
<h1 class="ob-title">What do you work on?</h1>
|
| 35 |
+
<p class="ob-sub">
|
| 36 |
+
This filters what you get shown and is one of the signals behind
|
| 37 |
+
ranking. Pick anything that looks close β you are not locked in.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</p>
|
| 39 |
</div>
|
| 40 |
|
| 41 |
+
<div class="cat-grid">
|
| 42 |
{% for key, cat in categories.items() %}
|
| 43 |
+
<button type="button" class="cat-opt" data-key="{{ key }}"
|
| 44 |
+
aria-pressed="{{ 'true' if key in selected else 'false' }}"
|
| 45 |
+
onclick="toggleCategory(this)">
|
| 46 |
+
<span class="name">{{ cat.name }}</span>
|
| 47 |
+
<span class="desc">{{ cat.desc }}</span>
|
| 48 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
{% endfor %}
|
| 50 |
</div>
|
| 51 |
+
</div>
|
| 52 |
|
| 53 |
+
<div id="step-2" class="hidden"></div>
|
| 54 |
+
</main>
|
| 55 |
+
|
| 56 |
+
<div class="ob-footer">
|
| 57 |
+
<div class="wrap" style="max-width:46rem">
|
| 58 |
+
<div class="row-between">
|
| 59 |
+
<span class="small muted"><span id="selected-count">{{ selected|length }}</span> selected Β· max 8</span>
|
| 60 |
+
<div class="row">
|
| 61 |
+
<button type="button" class="btn btn-ghost btn-sm" onclick="skipOnboarding()">Skip</button>
|
| 62 |
+
<button type="button" id="continue-btn" class="btn btn-primary btn-sm"
|
| 63 |
+
onclick="submitCategories()" {% if selected|length == 0 %}disabled{% endif %}>Continue</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
+
</div>
|
| 68 |
|
| 69 |
+
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
<script src="/static/htmx.min.js"></script>
|
| 72 |
<script src="/static/app.js"></script>
|
| 73 |
<script>
|
| 74 |
+
var selectedCategories = new Set({{ selected | tojson }});
|
|
|
|
| 75 |
|
| 76 |
+
function toggleCategory(el) {
|
| 77 |
+
var key = el.dataset.key;
|
|
|
|
| 78 |
if (selectedCategories.has(key)) {
|
| 79 |
selectedCategories.delete(key);
|
| 80 |
+
el.setAttribute('aria-pressed', 'false');
|
| 81 |
} else {
|
| 82 |
+
if (selectedCategories.size >= 8) return;
|
|
|
|
|
|
|
|
|
|
| 83 |
selectedCategories.add(key);
|
| 84 |
+
el.setAttribute('aria-pressed', 'true');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
+
document.getElementById('selected-count').textContent = selectedCategories.size;
|
| 87 |
+
var btn = document.getElementById('continue-btn');
|
| 88 |
+
if (selectedCategories.size > 0) btn.removeAttribute('disabled');
|
| 89 |
+
else btn.setAttribute('disabled', 'true');
|
| 90 |
}
|
| 91 |
|
| 92 |
async function submitCategories() {
|
| 93 |
+
if (!selectedCategories.size) return;
|
| 94 |
+
var btn = document.getElementById('continue-btn');
|
|
|
|
|
|
|
|
|
|
| 95 |
btn.setAttribute('disabled', 'true');
|
| 96 |
+
btn.textContent = 'Savingβ¦';
|
| 97 |
try {
|
| 98 |
+
var res = await fetch('/api/onboarding/categories', {
|
| 99 |
method: 'POST',
|
| 100 |
headers: { 'Content-Type': 'application/json' },
|
| 101 |
body: JSON.stringify({ categories: Array.from(selectedCategories) })
|
| 102 |
});
|
| 103 |
+
if (!res.ok) throw new Error('bad status');
|
| 104 |
+
var html = await res.text();
|
| 105 |
+
document.getElementById('step-1').classList.add('hidden');
|
| 106 |
+
document.querySelector('.ob-footer').classList.add('hidden');
|
| 107 |
+
var s2 = document.getElementById('step-2');
|
| 108 |
+
s2.innerHTML = html;
|
| 109 |
+
s2.classList.remove('hidden');
|
| 110 |
+
htmx.process(s2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
} catch (e) {
|
|
|
|
|
|
|
| 112 |
btn.removeAttribute('disabled');
|
| 113 |
+
btn.textContent = 'Continue';
|
| 114 |
}
|
| 115 |
}
|
| 116 |
|
| 117 |
async function skipOnboarding() {
|
| 118 |
+
try { await fetch('/api/onboarding/skip', { method: 'POST' }); } catch (e) {}
|
| 119 |
+
window.location.href = '/';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
</script>
|
| 122 |
</body>
|
|
@@ -1,65 +1,92 @@
|
|
| 1 |
{#
|
| 2 |
Action buttons for a paper card.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
#}
|
| 11 |
{% set pid = paper_id if paper_id is defined else paper.arxiv_id %}
|
| 12 |
{% set is_saved = saved if saved is defined else (paper.saved | default(false)) %}
|
| 13 |
{% set _source = source if source is defined else "search" %}
|
| 14 |
-
{% set
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
{%
|
| 18 |
-
{% set
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
{% if is_saved %}
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<
|
| 27 |
-
</
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
</button>
|
| 35 |
-
</div>
|
| 36 |
{% else %}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
<
|
| 53 |
-
</
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
| 65 |
{% endif %}
|
|
|
|
| 1 |
{#
|
| 2 |
Action buttons for a paper card.
|
| 3 |
+
|
| 4 |
+
Rendered two ways, and must work in both:
|
| 5 |
+
1. inline from paper_card.html, where `paper` is in scope
|
| 6 |
+
2. standalone as the response body of POST /api/papers/{id}/save, where
|
| 7 |
+
only paper_id / saved / source / candidate_source are passed
|
| 8 |
+
|
| 9 |
+
Save is htmx: non-destructive, immediately reversible via Remove, so it can
|
| 10 |
+
commit straight away.
|
| 11 |
+
|
| 12 |
+
Pass is NOT htmx. It defers the POST by a few seconds so Undo can cancel it
|
| 13 |
+
outright rather than compensating for it afterwards. That matters beyond UX:
|
| 14 |
+
a committed dismissal has already been folded into the negative EWMA profile,
|
| 15 |
+
and an EWMA is a lossy running average β there is no exact inverse. Deferring
|
| 16 |
+
keeps an undone dismissal out of the user's profile entirely. NN/g's guidance
|
| 17 |
+
for repetitive destructive actions is undo rather than confirmation, since a
|
| 18 |
+
confirm dialog taxes everyone who meant it.
|
| 19 |
#}
|
| 20 |
{% set pid = paper_id if paper_id is defined else paper.arxiv_id %}
|
| 21 |
{% set is_saved = saved if saved is defined else (paper.saved | default(false)) %}
|
| 22 |
{% set _source = source if source is defined else "search" %}
|
| 23 |
+
{% set _cand = candidate_source if candidate_source is defined
|
| 24 |
+
else (paper.candidate_source | default("") if paper is defined else "") %}
|
| 25 |
+
|
| 26 |
+
{% if paper is defined %}
|
| 27 |
+
{% set _vals = {
|
| 28 |
+
"source": _source,
|
| 29 |
+
"position": paper.position | default(0),
|
| 30 |
+
"ranker_version": paper.ranker_version | default(""),
|
| 31 |
+
"candidate_source": paper.candidate_source | default(""),
|
| 32 |
+
"cluster_id": paper.cluster_id | default(""),
|
| 33 |
+
"query_id": paper.query_id | default(""),
|
| 34 |
+
"propensity": paper.propensity | default(0),
|
| 35 |
+
"policy_id": paper.policy_id | default("")
|
| 36 |
+
} %}
|
| 37 |
+
{% else %}
|
| 38 |
+
{% set _vals = {
|
| 39 |
+
"source": _source,
|
| 40 |
+
"position": position | default(0),
|
| 41 |
+
"ranker_version": ranker_version | default(""),
|
| 42 |
+
"candidate_source": _cand,
|
| 43 |
+
"cluster_id": cluster_id | default(""),
|
| 44 |
+
"query_id": query_id | default(""),
|
| 45 |
+
"propensity": propensity | default(0),
|
| 46 |
+
"policy_id": policy_id | default("")
|
| 47 |
+
} %}
|
| 48 |
+
{% endif %}
|
| 49 |
|
| 50 |
{% if is_saved %}
|
| 51 |
+
<button type="button" class="btn btn-sm btn-save" disabled aria-label="Saved to your library">
|
| 52 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
| 53 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 54 |
+
<path d="m4.5 12.5 5 5 10-11" />
|
| 55 |
+
</svg>
|
| 56 |
+
Saved
|
| 57 |
+
</button>
|
| 58 |
+
<button type="button" class="btn btn-sm btn-pass"
|
| 59 |
+
data-dismiss
|
| 60 |
+
data-paper-id="{{ pid }}"
|
| 61 |
+
data-vals='{{ _vals | tojson }}'>Remove</button>
|
|
|
|
|
|
|
| 62 |
{% else %}
|
| 63 |
+
{#
|
| 64 |
+
The target MUST be the attribute-selector form, not "#actions-{{ pid }}".
|
| 65 |
+
Every arXiv id contains a dot (1706.03762), and in a CSS selector that dot
|
| 66 |
+
starts a class token β so "#actions-1706.03762" parses as
|
| 67 |
+
id="actions-1706" AND class="03762", which matches nothing. A class token
|
| 68 |
+
also cannot begin with a digit, so querySelector raises a SyntaxError
|
| 69 |
+
rather than merely missing. [id='β¦'] compares the literal string.
|
| 70 |
+
#}
|
| 71 |
+
<button type="button" class="btn btn-sm btn-save"
|
| 72 |
+
hx-post="/api/papers/{{ pid }}/save"
|
| 73 |
+
hx-target="[id='actions-{{ pid }}']"
|
| 74 |
+
hx-swap="innerHTML"
|
| 75 |
+
hx-vals='{{ _vals | tojson }}'>
|
| 76 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
|
| 77 |
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 78 |
+
<path d="M6 4h12a1 1 0 0 1 1 1v15l-7-3.6L5 20V5a1 1 0 0 1 1-1z" />
|
| 79 |
+
</svg>
|
| 80 |
+
Save
|
| 81 |
+
</button>
|
| 82 |
+
<button type="button" class="btn btn-sm btn-pass"
|
| 83 |
+
data-dismiss
|
| 84 |
+
data-paper-id="{{ pid }}"
|
| 85 |
+
data-vals='{{ _vals | tojson }}'>Pass</button>
|
| 86 |
+
{% endif %}
|
| 87 |
+
|
| 88 |
+
{% if _cand | why_shown %}
|
| 89 |
+
<button type="button" class="btn btn-sm btn-ghost card-why"
|
| 90 |
+
aria-expanded="false" aria-controls="why-{{ pid }}"
|
| 91 |
+
onclick="toggleWhy(this, '{{ pid }}')">Why this?</button>
|
| 92 |
{% endif %}
|
|
@@ -1,17 +1,7 @@
|
|
| 1 |
-
{#
|
| 2 |
{% if summary_html %}
|
| 3 |
-
<div class="ai-summary
|
| 4 |
-
<
|
| 5 |
-
|
| 6 |
-
<path d="M12 2L9.19 8.63L2 9.24l5.46 4.73L5.82 21L12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2z" />
|
| 7 |
-
</svg>
|
| 8 |
-
<span class="font-semibold text-sm brand-text-sm">AI Overview</span>
|
| 9 |
-
</div>
|
| 10 |
-
<div class="text-sm text-base-content/90 leading-relaxed">
|
| 11 |
-
{{ summary_html | safe }}
|
| 12 |
-
</div>
|
| 13 |
-
<div class="mt-3 flex justify-end">
|
| 14 |
-
<span class="text-[10px] text-base-content/40 uppercase tracking-wider">Powered by Groq</span>
|
| 15 |
-
</div>
|
| 16 |
</div>
|
| 17 |
{% endif %}
|
|
|
|
| 1 |
+
{# AI overview of the top search results. #}
|
| 2 |
{% if summary_html %}
|
| 3 |
+
<div class="ai-summary">
|
| 4 |
+
<span class="label">Overview</span>
|
| 5 |
+
<div>{{ summary_html | safe }}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
</div>
|
| 7 |
{% endif %}
|
|
@@ -1,25 +1,16 @@
|
|
| 1 |
-
{# Shown when
|
| 2 |
-
<div class="
|
| 3 |
-
<
|
| 4 |
-
<
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
</p>
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
<
|
| 12 |
-
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 13 |
-
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>
|
| 14 |
-
</svg>
|
| 15 |
-
Start Searching
|
| 16 |
-
</a>
|
| 17 |
-
|
| 18 |
-
<button class="btn btn-ghost btn-xs text-base-content/40"
|
| 19 |
hx-get="/api/recommendations"
|
| 20 |
hx-target="#rec-section"
|
| 21 |
-
hx-swap="innerHTML">
|
| 22 |
-
β» Check for recommendations
|
| 23 |
-
</button>
|
| 24 |
</div>
|
| 25 |
</div>
|
|
|
|
| 1 |
+
{# Shown when there is nothing to recommend yet. #}
|
| 2 |
+
<div class="empty">
|
| 3 |
+
<p class="empty-title">Nothing here yet</p>
|
| 4 |
+
<p class="empty-body">
|
| 5 |
+
Save {{ min_saves | default(1) }} paper{{ "s" if (min_saves | default(1)) > 1 else "" }} and
|
| 6 |
+
this feed starts building itself around what you read. It gets noticeably
|
| 7 |
+
better at five.
|
| 8 |
</p>
|
| 9 |
+
<div class="row" style="justify-content:center;padding-top:.4rem">
|
| 10 |
+
<a href="/search" class="btn btn-primary btn-sm">Find papers</a>
|
| 11 |
+
<button type="button" class="btn btn-quiet btn-sm"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
hx-get="/api/recommendations"
|
| 13 |
hx-target="#rec-section"
|
| 14 |
+
hx-swap="innerHTML">Check again</button>
|
|
|
|
|
|
|
| 15 |
</div>
|
| 16 |
</div>
|
|
@@ -1,100 +1,76 @@
|
|
| 1 |
{#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
#}
|
| 8 |
{% set source = source | default("search") %}
|
| 9 |
-
{% set position = position | default(0) %}
|
| 10 |
-
{% set authors_list = paper.authors | default("[]") | tojson_parse
|
| 11 |
-
|
| 12 |
-
{# Fallback: if tojson_parse returned empty but authors is a non-empty string, split by comma #}
|
| 13 |
{% if not authors_list and paper.authors %}
|
| 14 |
{% set authors_list = paper.authors.split(", ") %}
|
| 15 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
{
|
| 18 |
-
{% set cat = paper.category | default("") %}
|
| 19 |
-
{% if cat.startswith("cs.") %}
|
| 20 |
-
{% set cat_class = "cat-cs" %}
|
| 21 |
-
{% elif cat.startswith("hep") or cat.startswith("physics") or cat.startswith("astro") or cat.startswith("quant") or cat.startswith("cond-mat") %}
|
| 22 |
-
{% set cat_class = "cat-phys" %}
|
| 23 |
-
{% elif cat.startswith("math") or cat.startswith("stat") %}
|
| 24 |
-
{% set cat_class = "cat-math" %}
|
| 25 |
-
{% elif cat.startswith("q-bio") %}
|
| 26 |
-
{% set cat_class = "cat-bio" %}
|
| 27 |
-
{% elif cat.startswith("econ") %}
|
| 28 |
-
{% set cat_class = "cat-econ" %}
|
| 29 |
-
{% elif cat.startswith("eess") %}
|
| 30 |
-
{% set cat_class = "cat-eess" %}
|
| 31 |
-
{% else %}
|
| 32 |
-
{% set cat_class = "cat-other" %}
|
| 33 |
-
{% endif %}
|
| 34 |
-
|
| 35 |
-
<div class="paper-card bg-base-100 p-4 md:p-5 space-y-2.5 animate-card-in"
|
| 36 |
-
id="paper-{{ paper.arxiv_id }}"
|
| 37 |
-
data-cat="{{ cat }}">
|
| 38 |
|
| 39 |
-
<
|
| 40 |
-
<div class="flex items-start justify-between gap-3">
|
| 41 |
-
<a href="https://arxiv.org/abs/{{ paper.arxiv_id }}"
|
| 42 |
-
target="_blank"
|
| 43 |
-
rel="noopener"
|
| 44 |
-
class="paper-title leading-snug flex-1">
|
| 45 |
-
{{ paper.title }}
|
| 46 |
-
</a>
|
| 47 |
{% if paper.category %}
|
| 48 |
-
|
| 49 |
{% endif %}
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
{% set p_year = (paper.year | string) if paper.year else (paper.published[:4] if paper.published else "") %}
|
| 55 |
-
{% if p_year %}
|
| 56 |
-
<span class="year-pill {% if p_year in ['2024', '2025', '2026'] %}recent{% endif %}">{{ p_year }}</span>
|
| 57 |
{% endif %}
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<span class="opacity-60 italic">+{{ authors_list | length - 3 }} more</span>
|
| 67 |
-
{% endif %}
|
| 68 |
-
</span>
|
| 69 |
-
{% endif %}
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
<span class="
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
{% endif %}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
<!-- Abstract (expandable) -->
|
| 81 |
-
<p class="abstract-text text-sm text-base-content/75">
|
| 82 |
-
{{ paper.abstract[:500] }}{% if paper.abstract | length > 500 %}β¦{% endif %}
|
| 83 |
-
</p>
|
| 84 |
-
<button class="abstract-toggle" onclick="toggleAbstract(this)">β Show more</button>
|
| 85 |
|
| 86 |
-
<
|
| 87 |
-
<div id="actions-{{ paper.arxiv_id }}">
|
| 88 |
{% include "partials/action_buttons.html" %}
|
| 89 |
</div>
|
| 90 |
|
| 91 |
-
<!-- Why this paper? β Coming Soon -->
|
| 92 |
-
<div class="coming-soon-wrapper interactive" onclick="handleComingSoon(event)">
|
| 93 |
-
<div class="flex items-center gap-2 pt-1 cursor-pointer">
|
| 94 |
-
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
| 95 |
-
<span class="text-xs text-base-content/50">Why this paper?</span>
|
| 96 |
-
<span class="coming-soon-badge">Coming Soon</span>
|
| 97 |
-
</div>
|
| 98 |
-
</div>
|
| 99 |
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
{#
|
| 2 |
+
Paper card.
|
| 3 |
+
|
| 4 |
+
Expects:
|
| 5 |
+
paper dict β arxiv_id, title, abstract, authors (JSON str), category,
|
| 6 |
+
published, year, citation_count, saved, plus the Phase 4.5 /
|
| 7 |
+
6.5 instrumentation keys (ranker_version, candidate_source,
|
| 8 |
+
cluster_id, query_id, position, propensity, policy_id)
|
| 9 |
+
source "search" | "recommendation" | "saved" (default "search")
|
| 10 |
+
position int β GLOBAL rank in the feed, not the index within a page
|
| 11 |
+
|
| 12 |
+
Anatomy, top to bottom: meta line / title / authors / abstract / actions.
|
| 13 |
+
The previous card gave five facts five competing treatments β a coloured
|
| 14 |
+
badge pulled to the right of the title, a year pill, a mono arXiv id, the
|
| 15 |
+
author list and a citation count with its own icon β none of which anyone
|
| 16 |
+
scans for, all of which outweighed the title.
|
| 17 |
#}
|
| 18 |
{% set source = source | default("search") %}
|
| 19 |
+
{% set position = position if position is defined else (paper.position | default(0)) %}
|
| 20 |
+
{% set authors_list = paper.authors | default("[]") | tojson_parse %}
|
|
|
|
|
|
|
| 21 |
{% if not authors_list and paper.authors %}
|
| 22 |
{% set authors_list = paper.authors.split(", ") %}
|
| 23 |
{% endif %}
|
| 24 |
+
{% set lead = paper.abstract | lead %}
|
| 25 |
+
{% set rest = paper.abstract | lead_rest %}
|
| 26 |
+
{% set cites = paper.citation_count | default(0) %}
|
| 27 |
+
{% set pub_year = (paper.year | string) if paper.year else (paper.published[:4] if paper.published else "") %}
|
| 28 |
|
| 29 |
+
<article class="card" id="paper-{{ paper.arxiv_id }}" data-arxiv-id="{{ paper.arxiv_id }}">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
<div class="card-meta">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
{% if paper.category %}
|
| 33 |
+
<span class="cat {{ paper.category | cat_class }}">{{ paper.category }}</span>
|
| 34 |
{% endif %}
|
| 35 |
+
{% if pub_year %}<span>{{ pub_year }}</span>{% endif %}
|
| 36 |
+
{% if cites %}
|
| 37 |
+
<span class="sep">Β·</span>
|
| 38 |
+
<span>{{ cites | commafy }} citations</span>
|
|
|
|
|
|
|
|
|
|
| 39 |
{% endif %}
|
| 40 |
+
</div>
|
| 41 |
|
| 42 |
+
<a class="card-title"
|
| 43 |
+
href="https://arxiv.org/abs/{{ paper.arxiv_id }}"
|
| 44 |
+
target="_blank" rel="noopener">{{ paper.title }}</a>
|
| 45 |
|
| 46 |
+
{% if authors_list %}
|
| 47 |
+
<div class="card-authors">
|
| 48 |
+
{{ authors_list[:3] | join(", ") }}{% if authors_list | length > 3 %} & {{ authors_list | length - 3 }} more{% endif %}
|
| 49 |
+
</div>
|
| 50 |
+
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
{% if lead or rest %}
|
| 53 |
+
<p class="card-abstract{% if rest %} is-clamped{% endif %}" id="abs-{{ paper.arxiv_id }}">
|
| 54 |
+
{%- if lead %}<span class="lead">{{ lead }}</span>{% endif -%}
|
| 55 |
+
{%- if rest %} <span class="rest">{{ rest }}</span>{% endif -%}
|
| 56 |
+
</p>
|
| 57 |
+
{% if rest %}
|
| 58 |
+
<button type="button" class="card-more"
|
| 59 |
+
aria-expanded="false" aria-controls="abs-{{ paper.arxiv_id }}"
|
| 60 |
+
onclick="toggleAbstract(this)">Read more</button>
|
| 61 |
{% endif %}
|
| 62 |
+
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
<div class="card-actions" id="actions-{{ paper.arxiv_id }}">
|
|
|
|
| 65 |
{% include "partials/action_buttons.html" %}
|
| 66 |
</div>
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
{# Reason panel lives outside #actions-β¦ so it survives the htmx swap that
|
| 70 |
+
replaces the buttons after a save. #}
|
| 71 |
+
{% set reason = paper.candidate_source | default("") | why_shown %}
|
| 72 |
+
{% if reason %}
|
| 73 |
+
<p class="card-reason hidden" id="why-{{ paper.arxiv_id }}">{{ reason }}</p>
|
| 74 |
+
{% endif %}
|
| 75 |
+
|
| 76 |
+
</article>
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{#
|
| 2 |
+
One appendable page of the feed: cards, then the loader for the page after it.
|
| 3 |
+
|
| 4 |
+
Swapped in via hx-swap="outerHTML" onto the previous loader button, so this
|
| 5 |
+
fragment replaces the button that requested it and brings its own successor.
|
| 6 |
+
That makes the chain self-perpetuating with no JavaScript, exactly as the
|
| 7 |
+
htmx infinite-scroll example prescribes.
|
| 8 |
+
|
| 9 |
+
The loader is a real <button>, not a bare sentinel div, and carries
|
| 10 |
+
hx-trigger="revealed, click". NN/g's infinite-scroll findings are that
|
| 11 |
+
screen-reader users "will only see the first chunk of the list, without being
|
| 12 |
+
able to trigger the loading of new content", and that an endless list also
|
| 13 |
+
destroys footer access and any sense of whether more content exists. One
|
| 14 |
+
focusable, clickable element that also auto-fires on scroll answers all three.
|
| 15 |
+
|
| 16 |
+
The button carries ONLY hx-trigger="click"; the scroll-to-load behaviour is
|
| 17 |
+
driven by an IntersectionObserver in app.js that clicks it. That split is
|
| 18 |
+
deliberate, and both of htmx's built-in options were tried against the live
|
| 19 |
+
deployment first:
|
| 20 |
+
|
| 21 |
+
* `revealed` β htmx's poller collects elements with an exact attribute
|
| 22 |
+
match, querySelectorAll("[hx-trigger='revealed'],β¦"). This element also
|
| 23 |
+
needs a click trigger, so its attribute reads "revealed, click", which
|
| 24 |
+
that selector never matches. Verified dead: scrolled with the loader in
|
| 25 |
+
view (rect.top 745 < clientHeight 861), data-hx-revealed stayed null and
|
| 26 |
+
no request was made.
|
| 27 |
+
* `intersect once, click` β the handler wires up correctly (dispatching a
|
| 28 |
+
synthetic `intersect` did load the next page) but the observer never
|
| 29 |
+
fired on real scrolling in the deployed build.
|
| 30 |
+
|
| 31 |
+
Owning the observer removes the dependency on those internals entirely, and
|
| 32 |
+
keeps one real <button> serving both paths: pointer users and scrollers get
|
| 33 |
+
it automatically, keyboard and screen-reader users can focus and activate it.
|
| 34 |
+
NN/g's infinite-scroll finding is that screen-reader users otherwise "only
|
| 35 |
+
see the first chunk of the list, without being able to trigger the loading of
|
| 36 |
+
new content".
|
| 37 |
+
#}
|
| 38 |
+
{% for paper in papers %}
|
| 39 |
+
{% set source = "recommendation" %}
|
| 40 |
+
{% set position = paper.position %}
|
| 41 |
+
{% include "partials/paper_card.html" %}
|
| 42 |
+
{% endfor %}
|
| 43 |
+
|
| 44 |
+
{% if has_more %}
|
| 45 |
+
<button type="button" class="feed-more"
|
| 46 |
+
hx-get="/api/recommendations?page={{ next_page }}&query_id={{ query_id }}"
|
| 47 |
+
hx-trigger="click"
|
| 48 |
+
hx-target="this"
|
| 49 |
+
hx-swap="outerHTML transition:true">
|
| 50 |
+
<span class="spinner" aria-hidden="true"></span>
|
| 51 |
+
<span class="label">More papers</span>
|
| 52 |
+
</button>
|
| 53 |
+
{% else %}
|
| 54 |
+
<p class="feed-end">That's everything for now β save a few papers and the feed will refill.</p>
|
| 55 |
+
{% endif %}
|
|
@@ -1,72 +1,25 @@
|
|
| 1 |
-
{#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
{% if papers %}
|
| 3 |
{% if trending %}
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
<div class="space-y-4">
|
| 11 |
-
{% for paper in papers %}
|
| 12 |
-
{% set position = loop.index0 %}
|
| 13 |
-
{% set source = "recommendation" %}
|
| 14 |
-
{% include "partials/paper_card.html" %}
|
| 15 |
-
{% endfor %}
|
| 16 |
-
</div>
|
| 17 |
-
|
| 18 |
-
{# Pipeline timing breakdown #}
|
| 19 |
-
{% if timing is defined and timing %}
|
| 20 |
-
<details class="mt-5 group">
|
| 21 |
-
<summary class="text-xs font-semibold text-base-content/40 cursor-pointer list-none flex items-center gap-1.5 w-fit hover:text-base-content/60 transition-colors">
|
| 22 |
-
<svg class="h-3 w-3 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
|
| 23 |
-
β‘ How we found these
|
| 24 |
-
{% if timing.clustering_ms is defined %}
|
| 25 |
-
<span class="font-normal opacity-50">(pipeline)</span>
|
| 26 |
-
{% endif %}
|
| 27 |
-
</summary>
|
| 28 |
-
<div class="rit-pipeline-detail mt-2">
|
| 29 |
-
<div class="flex flex-wrap gap-x-4 gap-y-1.5 text-xs font-mono text-base-content/50">
|
| 30 |
-
{% if timing.clustering_ms is defined %}
|
| 31 |
-
<span>Ward Clustering: <span class="text-primary">{{ timing.clustering_ms }}ms</span></span>
|
| 32 |
-
{% endif %}
|
| 33 |
-
{% if timing.ann_retrieval_ms is defined %}
|
| 34 |
-
<span>ANN Retrieval: <span class="text-primary">{{ timing.ann_retrieval_ms }}ms</span></span>
|
| 35 |
-
{% endif %}
|
| 36 |
-
{% if timing.candidate_meta_ms is defined %}
|
| 37 |
-
<span>Candidate Meta: <span class="text-primary">{{ timing.candidate_meta_ms }}ms</span></span>
|
| 38 |
-
{% endif %}
|
| 39 |
-
{% if rerank_time_ms is defined %}
|
| 40 |
-
<span>LightGBM Rerank: <span class="text-primary">{{ rerank_time_ms }}ms</span></span>
|
| 41 |
-
{% endif %}
|
| 42 |
-
{% if timing.mmr_ms is defined %}
|
| 43 |
-
<span>MMR Diversity: <span class="text-primary">{{ timing.mmr_ms }}ms</span></span>
|
| 44 |
-
{% endif %}
|
| 45 |
-
{% if meta_time_ms is defined %}
|
| 46 |
-
<span>Final Metadata: <span class="text-primary">{{ meta_time_ms }}ms</span></span>
|
| 47 |
-
{% endif %}
|
| 48 |
-
</div>
|
| 49 |
</div>
|
| 50 |
-
</details>
|
| 51 |
-
{% elif rerank_time_ms is defined and meta_time_ms is defined %}
|
| 52 |
-
<div class="text-center pt-3 text-xs text-base-content/30 font-mono">
|
| 53 |
-
β‘ Reranking: {{ rerank_time_ms }}ms Β· Metadata: {{ meta_time_ms }}ms
|
| 54 |
-
</div>
|
| 55 |
{% endif %}
|
| 56 |
|
| 57 |
-
<
|
| 58 |
-
|
| 59 |
-
<button class="btn btn-ghost btn-sm text-base-content/50 hover:text-base-content/80"
|
| 60 |
-
hx-get="/api/recommendations"
|
| 61 |
-
hx-target="#rec-section"
|
| 62 |
-
hx-swap="innerHTML"
|
| 63 |
-
hx-indicator="#rec-refresh-spinner">
|
| 64 |
-
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 65 |
-
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>
|
| 66 |
-
</svg>
|
| 67 |
-
Show different recommendations
|
| 68 |
-
<span id="rec-refresh-spinner" class="htmx-indicator loading loading-spinner loading-xs ml-1"></span>
|
| 69 |
-
</button>
|
| 70 |
</div>
|
| 71 |
{% else %}
|
| 72 |
{% include "partials/empty_recs.html" %}
|
|
|
|
| 1 |
+
{#
|
| 2 |
+
First page of the feed. Wraps the shared page fragment in the .feed column
|
| 3 |
+
so subsequent pages can append into the same flex container.
|
| 4 |
+
|
| 5 |
+
Gone from here: the "How we found these" panel that reported Ward Clustering
|
| 6 |
+
/ ANN Retrieval / MMR timings in milliseconds. That is instrumentation for
|
| 7 |
+
the person who built the pipeline, not an answer to anything a reader wants
|
| 8 |
+
to know β per-paper "Why this?" replaces it. Timings still travel in the
|
| 9 |
+
response for anyone profiling; they are just no longer rendered.
|
| 10 |
+
#}
|
| 11 |
{% if papers %}
|
| 12 |
{% if trending %}
|
| 13 |
+
<div class="notice" style="margin-bottom:.85rem">
|
| 14 |
+
<span>
|
| 15 |
+
Popular in the areas you picked. Save a few papers and the feed starts
|
| 16 |
+
following your own reading instead.
|
| 17 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
{% endif %}
|
| 20 |
|
| 21 |
+
<div class="feed" id="feed">
|
| 22 |
+
{% include "partials/rec_page.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
| 24 |
{% else %}
|
| 25 |
{% include "partials/empty_recs.html" %}
|
|
@@ -1,46 +1,35 @@
|
|
| 1 |
-
{#
|
| 2 |
{% if papers %}
|
| 3 |
-
<div class="
|
| 4 |
-
|
| 5 |
-
<div class="
|
| 6 |
-
<span
|
| 7 |
-
<span>for "<span class="font-medium text-base-content">{{ query }}</span>"</span>
|
| 8 |
{% if search_meta and search_meta.total_time_ms is defined %}
|
| 9 |
-
<span
|
| 10 |
{% endif %}
|
| 11 |
{% if search_meta and search_meta.rewritten_query %}
|
| 12 |
-
<span
|
| 13 |
-
β Expanded to: <span class="italic">"{{ search_meta.rewritten_query }}"</span>
|
| 14 |
-
</span>
|
| 15 |
{% endif %}
|
| 16 |
</div>
|
| 17 |
|
| 18 |
-
{# AI Summary Slot #}
|
| 19 |
{% if query and papers %}
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
<
|
| 32 |
-
</div>
|
| 33 |
-
<div class="space-y-2 animate-pulse opacity-60">
|
| 34 |
-
<div class="h-3 bg-base-content/20 rounded w-full"></div>
|
| 35 |
-
<div class="h-3 bg-base-content/20 rounded w-5/6"></div>
|
| 36 |
-
<div class="h-3 bg-base-content/20 rounded w-4/6"></div>
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
-
</div>
|
| 40 |
{% endif %}
|
| 41 |
|
| 42 |
-
<
|
| 43 |
-
<div class="space-y-4">
|
| 44 |
{% for paper in papers %}
|
| 45 |
{% set position = loop.index0 %}
|
| 46 |
{% set source = "search" %}
|
|
@@ -49,64 +38,38 @@
|
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
|
| 52 |
-
{#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
{% if search_meta %}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<
|
| 57 |
-
Search Pipeline Details
|
| 58 |
-
{% if search_meta.total_time_ms is defined %}
|
| 59 |
-
<span class="font-normal opacity-70">({{ search_meta.total_time_ms }}ms)</span>
|
| 60 |
-
{% endif %}
|
| 61 |
-
</summary>
|
| 62 |
-
<div class="mt-2 p-3 rounded-lg bg-base-200/50 border border-base-300/30">
|
| 63 |
-
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs font-mono text-base-content/60">
|
| 64 |
{% if search_meta.groq_time_ms is defined %}
|
| 65 |
-
<span>
|
| 66 |
-
{% if search_meta.groq_status is defined and search_meta.groq_status != 'rewritten' %}
|
| 67 |
-
<span class="text-warning/60">({{ search_meta.groq_status }})</span>
|
| 68 |
-
{% endif %}
|
| 69 |
-
</span>
|
| 70 |
-
{% endif %}
|
| 71 |
-
{% if search_meta.encode_time_ms is defined %}
|
| 72 |
-
<span>BGE-M3 Encode: <span class="text-primary">{{ search_meta.encode_time_ms }}ms</span></span>
|
| 73 |
-
{% endif %}
|
| 74 |
-
{% if search_meta.retrieval_time_ms is defined %}
|
| 75 |
-
<span>Qdrant+Zilliz: <span class="text-primary">{{ search_meta.retrieval_time_ms }}ms</span>
|
| 76 |
-
{% if search_meta.n_retrieval_tasks is defined %}
|
| 77 |
-
<span class="text-base-content/30">({{ search_meta.n_retrieval_tasks }} parallel tasks)</span>
|
| 78 |
-
{% endif %}
|
| 79 |
-
</span>
|
| 80 |
-
{% endif %}
|
| 81 |
-
{% if search_meta.rrf_time_ms is defined %}
|
| 82 |
-
<span>RRF Fusion: <span class="text-primary">{{ search_meta.rrf_time_ms }}ms</span></span>
|
| 83 |
-
{% endif %}
|
| 84 |
-
{% if search_meta.bge_rerank_time_ms is defined %}
|
| 85 |
-
<span>BGE Rerank: <span class="text-primary">{{ search_meta.bge_rerank_time_ms }}ms</span></span>
|
| 86 |
{% endif %}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
{% if search_meta.turso_boost_fetch_ms is defined %}
|
| 88 |
-
<span>
|
| 89 |
-
<span>
|
| 90 |
{% elif search_meta.rerank_time_ms is defined %}
|
| 91 |
-
<span>
|
| 92 |
-
{% endif %}
|
| 93 |
-
{% if search_meta.meta_time_ms is defined %}
|
| 94 |
-
<span>Final Metadata: <span class="text-primary">{{ search_meta.meta_time_ms }}ms</span></span>
|
| 95 |
{% endif %}
|
|
|
|
|
|
|
| 96 |
</div>
|
| 97 |
-
</
|
| 98 |
-
</details>
|
| 99 |
{% endif %}
|
| 100 |
|
| 101 |
{% elif query %}
|
| 102 |
-
<div class="
|
| 103 |
-
<
|
| 104 |
-
|
| 105 |
-
</svg>
|
| 106 |
-
<p class="text-lg font-medium text-base-content/50 mb-1">No papers found for "{{ query }}"</p>
|
| 107 |
-
<p class="text-sm text-base-content/40">Try a broader search or different keywords.</p>
|
| 108 |
-
{% if search_meta and search_meta.total_time_ms is defined %}
|
| 109 |
-
<p class="text-xs text-base-content/30 mt-3">Search completed in {{ search_meta.total_time_ms }}ms</p>
|
| 110 |
-
{% endif %}
|
| 111 |
</div>
|
| 112 |
{% endif %}
|
|
|
|
| 1 |
+
{# Search result list. #}
|
| 2 |
{% if papers %}
|
| 3 |
+
<div class="stack stack-4">
|
| 4 |
+
|
| 5 |
+
<div class="search-meta">
|
| 6 |
+
<span>{{ papers | length }} result{{ "s" if papers | length != 1 else "" }} for “{{ query }}”</span>
|
|
|
|
| 7 |
{% if search_meta and search_meta.total_time_ms is defined %}
|
| 8 |
+
<span>{{ search_meta.total_time_ms }}ms</span>
|
| 9 |
{% endif %}
|
| 10 |
{% if search_meta and search_meta.rewritten_query %}
|
| 11 |
+
<span>searched as “{{ search_meta.rewritten_query }}”</span>
|
|
|
|
|
|
|
| 12 |
{% endif %}
|
| 13 |
</div>
|
| 14 |
|
|
|
|
| 15 |
{% if query and papers %}
|
| 16 |
+
{% set arxiv_ids = papers | map(attribute='arxiv_id') | join(',') %}
|
| 17 |
+
<div id="ai-summary-container"
|
| 18 |
+
hx-get="/api/search/summary?q={{ query | urlencode }}&ids={{ arxiv_ids }}"
|
| 19 |
+
hx-trigger="load"
|
| 20 |
+
hx-swap="innerHTML">
|
| 21 |
+
<div class="ai-summary">
|
| 22 |
+
<span class="label">Overview</span>
|
| 23 |
+
<div class="stack stack-2" aria-hidden="true">
|
| 24 |
+
<div class="skel" style="width:100%"></div>
|
| 25 |
+
<div class="skel" style="width:88%"></div>
|
| 26 |
+
<div class="skel" style="width:64%"></div>
|
| 27 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
</div>
|
|
|
|
| 30 |
{% endif %}
|
| 31 |
|
| 32 |
+
<div class="feed">
|
|
|
|
| 33 |
{% for paper in papers %}
|
| 34 |
{% set position = loop.index0 %}
|
| 35 |
{% set source = "search" %}
|
|
|
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
|
| 41 |
+
{#
|
| 42 |
+
Stage timings stay, but collapsed and quiet. They are genuinely useful to a
|
| 43 |
+
single developer profiling a multi-second pipeline; they are just not the
|
| 44 |
+
page's content. Note groq_time_ms OVERLAPS encode_time_ms β the stages run
|
| 45 |
+
concurrently, so these do not sum to the total.
|
| 46 |
+
#}
|
| 47 |
{% if search_meta %}
|
| 48 |
+
<details class="stack" style="margin-top:1.25rem">
|
| 49 |
+
<summary class="small muted" style="cursor:pointer">Pipeline timings</summary>
|
| 50 |
+
<div class="search-meta" style="margin-top:.5rem">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
{% if search_meta.groq_time_ms is defined %}
|
| 52 |
+
<span>rewrite {{ search_meta.groq_time_ms }}ms{% if search_meta.groq_status is defined and search_meta.groq_status != 'rewritten' %} ({{ search_meta.groq_status }}){% endif %}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
{% endif %}
|
| 54 |
+
{% if search_meta.encode_time_ms is defined %}<span>encode {{ search_meta.encode_time_ms }}ms</span>{% endif %}
|
| 55 |
+
{% if search_meta.retrieval_time_ms is defined %}<span>retrieval {{ search_meta.retrieval_time_ms }}ms{% if search_meta.n_retrieval_tasks is defined %} ({{ search_meta.n_retrieval_tasks }} tasks){% endif %}</span>{% endif %}
|
| 56 |
+
{% if search_meta.rrf_time_ms is defined %}<span>rrf {{ search_meta.rrf_time_ms }}ms</span>{% endif %}
|
| 57 |
+
{% if search_meta.bge_rerank_time_ms is defined %}<span>cross-encoder {{ search_meta.bge_rerank_time_ms }}ms</span>{% endif %}
|
| 58 |
{% if search_meta.turso_boost_fetch_ms is defined %}
|
| 59 |
+
<span>title fetch {{ search_meta.turso_boost_fetch_ms }}ms</span>
|
| 60 |
+
<span>boost {{ search_meta.rerank_compute_ms }}ms</span>
|
| 61 |
{% elif search_meta.rerank_time_ms is defined %}
|
| 62 |
+
<span>boost {{ search_meta.rerank_time_ms }}ms</span>
|
|
|
|
|
|
|
|
|
|
| 63 |
{% endif %}
|
| 64 |
+
{% if search_meta.meta_time_ms is defined %}<span>metadata {{ search_meta.meta_time_ms }}ms</span>{% endif %}
|
| 65 |
+
{% if search_meta.groq_overlapped %}<span class="muted">(rewrite overlaps encode β stages do not sum)</span>{% endif %}
|
| 66 |
</div>
|
| 67 |
+
</details>
|
|
|
|
| 68 |
{% endif %}
|
| 69 |
|
| 70 |
{% elif query %}
|
| 71 |
+
<div class="empty">
|
| 72 |
+
<p class="empty-title">No papers for “{{ query }}”</p>
|
| 73 |
+
<p class="empty-body">Try broader wording, or the name of a method or model.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
{% endif %}
|
|
@@ -1,53 +1,35 @@
|
|
|
|
|
| 1 |
{% if papers is defined and papers %}
|
| 2 |
-
<div class="
|
| 3 |
{% for paper in papers %}
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
{% elif paper.category.startswith("math.") or paper.category.startswith("stat.") %} {% set cat_class = "cat-math" %}
|
| 18 |
-
{% elif paper.category.startswith("q-bio.") %} {% set cat_class = "cat-bio" %}
|
| 19 |
-
{% elif paper.category.startswith("econ.") %} {% set cat_class = "cat-econ" %}
|
| 20 |
-
{% elif paper.category.startswith("eess.") %} {% set cat_class = "cat-eess" %}
|
| 21 |
-
{% endif %}
|
| 22 |
-
<span class="cat-badge {{ cat_class }} text-[9px] px-1.5 py-0.5">{{ paper.category.split('.')[0] | upper }}</span>
|
| 23 |
-
|
| 24 |
-
{% if paper.citation_count is defined and paper.citation_count > 0 %}
|
| 25 |
-
<span class="cite-count text-[10px]">
|
| 26 |
-
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
|
| 27 |
-
{{ paper.citation_count }}
|
| 28 |
-
</span>
|
| 29 |
-
{% endif %}
|
| 30 |
</div>
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
<div class="flex-shrink-0">
|
| 34 |
-
<button class="rit-action-btn rit-btn-save"
|
| 35 |
hx-post="/api/papers/{{ paper.arxiv_id }}/save"
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
hx-swap="outerHTML"
|
| 38 |
hx-vals='{"source": "onboarding", "position": {{ loop.index0 }}}'
|
| 39 |
-
onclick="bumpSeedCount()">
|
| 40 |
-
<span class="btn-icon">β</span> Save
|
| 41 |
-
</button>
|
| 42 |
</div>
|
| 43 |
-
|
| 44 |
-
</div>
|
| 45 |
{% endfor %}
|
| 46 |
</div>
|
| 47 |
{% elif query %}
|
| 48 |
-
<div class="
|
| 49 |
-
<
|
| 50 |
-
<p class="
|
| 51 |
-
<p class="text-xs opacity-50 mt-1">Try searching for broader keywords.</p>
|
| 52 |
</div>
|
| 53 |
{% endif %}
|
|
|
|
| 1 |
+
{# Compact seed-picker rows. Denser than a feed card β this is a pick list. #}
|
| 2 |
{% if papers is defined and papers %}
|
| 3 |
+
<div class="stack stack-2">
|
| 4 |
{% for paper in papers %}
|
| 5 |
+
<div class="seed-row" id="seed-paper-{{ paper.arxiv_id }}">
|
| 6 |
+
<div class="grow">
|
| 7 |
+
<a class="seed-title" href="https://arxiv.org/abs/{{ paper.arxiv_id }}"
|
| 8 |
+
target="_blank" rel="noopener">{{ paper.title }}</a>
|
| 9 |
+
<div class="card-meta" style="margin-top:.2rem">
|
| 10 |
+
{% if paper.category %}
|
| 11 |
+
<span class="cat {{ paper.category | cat_class }}">{{ paper.category }}</span>
|
| 12 |
+
{% endif %}
|
| 13 |
+
{% if paper.year %}<span>{{ paper.year }}</span>{% endif %}
|
| 14 |
+
{% if paper.citation_count %}
|
| 15 |
+
<span class="sep">Β·</span><span>{{ paper.citation_count | commafy }} citations</span>
|
| 16 |
+
{% endif %}
|
| 17 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
+
<button type="button" class="btn btn-sm btn-save"
|
|
|
|
|
|
|
|
|
|
| 20 |
hx-post="/api/papers/{{ paper.arxiv_id }}/save"
|
| 21 |
+
{# Attribute selector, not "#seed-paper-β¦": the dot in an arXiv
|
| 22 |
+
id would be read as a CSS class token. See action_buttons.html. #}
|
| 23 |
+
hx-target="[id='seed-paper-{{ paper.arxiv_id }}']"
|
| 24 |
hx-swap="outerHTML"
|
| 25 |
hx-vals='{"source": "onboarding", "position": {{ loop.index0 }}}'
|
| 26 |
+
onclick="if (window.bumpSeedCount) window.bumpSeedCount()">Save</button>
|
|
|
|
|
|
|
| 27 |
</div>
|
|
|
|
|
|
|
| 28 |
{% endfor %}
|
| 29 |
</div>
|
| 30 |
{% elif query %}
|
| 31 |
+
<div class="empty" style="padding:2rem 1rem">
|
| 32 |
+
<p class="empty-title">Nothing for “{{ query }}”</p>
|
| 33 |
+
<p class="empty-body">Try broader wording, or a method or model name.</p>
|
|
|
|
| 34 |
</div>
|
| 35 |
{% endif %}
|
|
@@ -1,86 +1,86 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
<h2 class="text-3xl font-bold mb-3 font-display relative z-10">Save a few papers you like</h2>
|
| 4 |
-
<p class="text-base-content/60 max-w-md mx-auto relative z-10">
|
| 5 |
-
Search for papers in your field and save a few to seed your recommendations.
|
| 6 |
-
</p>
|
| 7 |
-
</div>
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</div>
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
-
</div>
|
| 19 |
|
| 20 |
-
<div class="
|
| 21 |
-
|
| 22 |
-
<
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
hx-target="#seed-results"
|
| 26 |
-
hx-swap="innerHTML"
|
| 27 |
-
hx-indicator="#seed-loading">
|
| 28 |
-
<svg class="w-5 h-5 ml-3 text-base-content/30 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 29 |
-
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>
|
| 30 |
-
</svg>
|
| 31 |
-
<input type="text"
|
| 32 |
-
name="q"
|
| 33 |
-
value="{{ query | default('') }}"
|
| 34 |
-
placeholder="Search by keywords or authors..."
|
| 35 |
-
class="rit-search-input py-2 text-base md:text-lg"
|
| 36 |
-
autofocus />
|
| 37 |
-
<button class="btn btn-primary px-5 rounded-lg flex-shrink-0" type="submit">
|
| 38 |
-
Search
|
| 39 |
-
</button>
|
| 40 |
-
</form>
|
| 41 |
-
</div>
|
| 42 |
</div>
|
| 43 |
-
</div>
|
| 44 |
|
| 45 |
-
<
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
<div id="seed-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
|
| 53 |
-
<
|
| 54 |
-
<div class="
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
<
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
</form>
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
|
| 68 |
<script>
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
</script>
|
|
|
|
| 1 |
+
{#
|
| 2 |
+
Step 2 β seed papers.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
The progress copy names the threshold explicitly. PHASE8 flags this as a known
|
| 5 |
+
gap: the wizard asks for 5 seeds and Tier 1 (the real multi-interest pipeline)
|
| 6 |
+
also needs 5, but nothing told the user that β so stopping at 4 silently
|
| 7 |
+
dropped them onto Tier 3, the weakest path, with no indication why the feed
|
| 8 |
+
felt poor. Now the number means something on screen.
|
| 9 |
+
#}
|
| 10 |
+
<div class="ob-step">
|
| 11 |
+
<div class="ob-progress">
|
| 12 |
+
<span>Step 2 of 2</span>
|
| 13 |
+
<span class="ob-bar"><i style="width:100%"></i></span>
|
| 14 |
</div>
|
| 15 |
+
|
| 16 |
+
<div class="ob-head">
|
| 17 |
+
<span class="ob-kicker">Seed your feed</span>
|
| 18 |
+
<h1 class="ob-title">Save five papers you already rate</h1>
|
| 19 |
+
<p class="ob-sub">
|
| 20 |
+
Five is the real threshold: below it the feed falls back to simpler
|
| 21 |
+
matching, and at five it starts clustering your interests properly and
|
| 22 |
+
can keep several going at once.
|
| 23 |
+
</p>
|
| 24 |
</div>
|
|
|
|
| 25 |
|
| 26 |
+
<div class="ob-progress" aria-live="polite">
|
| 27 |
+
<span id="seed-counter" class="mono">{{ seed_count }} of {{ seed_target }}</span>
|
| 28 |
+
<span class="ob-bar">
|
| 29 |
+
<i id="seed-fill" style="width: {{ ((seed_count / seed_target * 100) if seed_target else 0) | round | int }}%"></i>
|
| 30 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
</div>
|
|
|
|
| 32 |
|
| 33 |
+
<form class="searchbar"
|
| 34 |
+
hx-get="/api/onboarding/seed-search"
|
| 35 |
+
hx-target="#seed-results"
|
| 36 |
+
hx-swap="innerHTML"
|
| 37 |
+
hx-indicator="#seed-loading">
|
| 38 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
|
| 39 |
+
stroke-linecap="round" aria-hidden="true">
|
| 40 |
+
<circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" />
|
| 41 |
+
</svg>
|
| 42 |
+
<input type="search" name="q" value="{{ query | default('') }}"
|
| 43 |
+
placeholder="A topic, method, or authorβ¦" aria-label="Search for seed papers" autofocus />
|
| 44 |
+
<button class="btn btn-primary btn-sm" type="submit">Search</button>
|
| 45 |
+
</form>
|
| 46 |
|
| 47 |
+
<div id="seed-loading" class="htmx-indicator center" style="padding:1.5rem">
|
| 48 |
+
<span class="spinner" style="margin:0 auto"></span>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div id="seed-results">
|
| 52 |
+
{% include "partials/seed_results.html" %}
|
| 53 |
+
</div>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
+
<div class="ob-footer">
|
| 57 |
+
<div class="wrap" style="max-width:46rem">
|
| 58 |
+
<div class="row-between">
|
| 59 |
+
<span class="small muted" id="seed-hint">
|
| 60 |
+
{% if seed_count >= seed_target %}Ready β your feed will use the full pipeline.
|
| 61 |
+
{% else %}{{ seed_target - seed_count }} more for the full pipeline.{% endif %}
|
| 62 |
+
</span>
|
| 63 |
+
<form action="/api/onboarding/complete" method="post">
|
| 64 |
+
<button type="submit" class="btn btn-primary btn-sm">Go to my feed</button>
|
| 65 |
+
</form>
|
| 66 |
+
</div>
|
|
|
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
|
| 70 |
<script>
|
| 71 |
+
(function () {
|
| 72 |
+
var count = {{ seed_count }};
|
| 73 |
+
var target = {{ seed_target }};
|
| 74 |
+
window.bumpSeedCount = function () {
|
| 75 |
+
count++;
|
| 76 |
+
var c = document.getElementById('seed-counter');
|
| 77 |
+
var f = document.getElementById('seed-fill');
|
| 78 |
+
var h = document.getElementById('seed-hint');
|
| 79 |
+
if (c) c.textContent = count + ' of ' + target;
|
| 80 |
+
if (f) f.style.width = Math.min((count / target) * 100, 100) + '%';
|
| 81 |
+
if (h) h.textContent = count >= target
|
| 82 |
+
? 'Ready β your feed will use the full pipeline.'
|
| 83 |
+
: (target - count) + ' more for the full pipeline.';
|
| 84 |
+
};
|
| 85 |
+
})();
|
|
|
|
|
|
|
| 86 |
</script>
|
|
@@ -1,37 +1,25 @@
|
|
| 1 |
{#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
- Action buttons row
|
| 8 |
-
All lines use the shimmer animation.
|
| 9 |
#}
|
| 10 |
-
<div class="
|
| 11 |
{% for i in range(count | default(3)) %}
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<div class="
|
| 16 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
-
<!-- Meta row -->
|
| 19 |
-
<div class="flex items-center gap-2 mb-3">
|
| 20 |
-
<div class="skeleton-line" style="height: 14px; width: 36px; border-radius: 5px;"></div>
|
| 21 |
-
<div class="skeleton-line skeleton-line-meta"></div>
|
| 22 |
-
</div>
|
| 23 |
-
<!-- Abstract lines -->
|
| 24 |
-
<div class="space-y-2 mb-3">
|
| 25 |
-
<div class="skeleton-line skeleton-line-text"></div>
|
| 26 |
-
<div class="skeleton-line skeleton-line-text-md"></div>
|
| 27 |
-
<div class="skeleton-line skeleton-line-text-sm"></div>
|
| 28 |
-
</div>
|
| 29 |
-
<!-- Action buttons row -->
|
| 30 |
-
<div class="flex gap-2">
|
| 31 |
-
<div class="skeleton-line skeleton-line-btn"></div>
|
| 32 |
-
<div class="skeleton-line skeleton-line-btn"></div>
|
| 33 |
-
<div class="skeleton-line" style="height: 28px; width: 56px; border-radius: 8px;"></div>
|
| 34 |
-
</div>
|
| 35 |
-
</div>
|
| 36 |
{% endfor %}
|
| 37 |
</div>
|
|
|
|
|
|
| 1 |
{#
|
| 2 |
+
Skeleton loader. Mirrors the real card's geometry so the swap does not shift
|
| 3 |
+
layout β Nielsen's response-time limits put anything past ~1s in "needs
|
| 4 |
+
feedback" territory, and Tier 1 of this pipeline is multi-second.
|
| 5 |
+
|
| 6 |
+
Include with `count` set, e.g. {% set count = 4 %}{% include "partials/skeleton.html" %}
|
|
|
|
|
|
|
| 7 |
#}
|
| 8 |
+
<div class="feed" aria-hidden="true">
|
| 9 |
{% for i in range(count | default(3)) %}
|
| 10 |
+
<div class="skel-card">
|
| 11 |
+
<div class="skel skel-meta"></div>
|
| 12 |
+
<div class="skel skel-title"></div>
|
| 13 |
+
<div class="skel skel-title2"></div>
|
| 14 |
+
<div class="skel skel-auth"></div>
|
| 15 |
+
<div class="skel skel-text"></div>
|
| 16 |
+
<div class="skel skel-text-2"></div>
|
| 17 |
+
<div class="skel skel-text-3"></div>
|
| 18 |
+
<div class="row" style="gap:.4rem;padding-top:.3rem">
|
| 19 |
+
<div class="skel skel-btn"></div>
|
| 20 |
+
<div class="skel skel-btn"></div>
|
| 21 |
+
</div>
|
| 22 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
{% endfor %}
|
| 24 |
</div>
|
| 25 |
+
<span class="sr-only" role="status">Loading recommendationsβ¦</span>
|
|
@@ -1,80 +1,38 @@
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
-
{% block title %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
{% block content %}
|
| 6 |
-
<div class="
|
| 7 |
|
| 8 |
-
<
|
| 9 |
-
|
| 10 |
-
<
|
| 11 |
-
<span class="stat-pill bg-primary/10 text-primary">{{ count }}</span>
|
| 12 |
</div>
|
| 13 |
-
|
| 14 |
-
{% if count > 0 %}
|
| 15 |
-
<p class="text-sm text-base-content/50 mb-8">
|
| 16 |
-
You've explored {{ count }} paper{{ "s" if count != 1 else "" }} so far.
|
| 17 |
-
</p>
|
| 18 |
-
{% endif %}
|
| 19 |
|
| 20 |
{% if count > 0 %}
|
| 21 |
-
<div class="
|
| 22 |
{% for paper in papers %}
|
| 23 |
{% set position = loop.index0 %}
|
| 24 |
{% set source = "saved" %}
|
| 25 |
{% include "partials/paper_card.html" %}
|
| 26 |
{% endfor %}
|
| 27 |
</div>
|
| 28 |
-
|
| 29 |
-
<!-- Coming Soon: Collections -->
|
| 30 |
-
<div class="mt-8 coming-soon-section" onclick="handleComingSoon(event)">
|
| 31 |
-
<div class="flex items-center justify-between">
|
| 32 |
-
<div class="flex items-center gap-3">
|
| 33 |
-
<span class="text-2xl opacity-50">π</span>
|
| 34 |
-
<div>
|
| 35 |
-
<div class="flex items-center gap-2 mb-0.5">
|
| 36 |
-
<span class="text-sm font-medium text-base-content/50">Collections</span>
|
| 37 |
-
<span class="coming-soon-badge">π Coming Soon</span>
|
| 38 |
-
</div>
|
| 39 |
-
<p class="text-xs text-base-content/40">Organize your papers into named folders and reading lists.</p>
|
| 40 |
-
</div>
|
| 41 |
-
</div>
|
| 42 |
-
<button class="btn btn-ghost btn-sm btn-circle opacity-50 pointer-events-none">
|
| 43 |
-
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
|
| 44 |
-
</button>
|
| 45 |
-
</div>
|
| 46 |
-
</div>
|
| 47 |
{% else %}
|
| 48 |
-
<
|
| 49 |
-
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
Papers you save while searching or browsing your feed will appear here. They are used to build your behavioral profile.
|
| 54 |
</p>
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
<a href="/search" class="btn btn-primary px-6 shadow-lg shadow-primary/20">
|
| 58 |
-
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 59 |
-
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>
|
| 60 |
-
</svg>
|
| 61 |
-
Start Searching
|
| 62 |
-
</a>
|
| 63 |
-
</div>
|
| 64 |
-
</div>
|
| 65 |
-
|
| 66 |
-
<!-- Coming Soon: Weekly Digest -->
|
| 67 |
-
<div class="mt-12 max-w-md mx-auto coming-soon-wrapper interactive" onclick="handleComingSoon(event)">
|
| 68 |
-
<div class="card bg-base-100 border border-base-content/5 p-5 text-center">
|
| 69 |
-
<div class="text-2xl mb-2">π¬</div>
|
| 70 |
-
<div class="flex items-center justify-center gap-2 mb-2">
|
| 71 |
-
<h4 class="font-medium">Weekly Digest</h4>
|
| 72 |
-
<span class="coming-soon-badge">π Soon</span>
|
| 73 |
-
</div>
|
| 74 |
-
<p class="text-xs text-base-content/50 mb-4">
|
| 75 |
-
Get a personalized email summary of new papers matching your interests every Monday.
|
| 76 |
-
</p>
|
| 77 |
-
<button class="btn btn-outline btn-sm w-full pointer-events-none opacity-50">Subscribe</button>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
{% endif %}
|
|
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
+
{% block title %}Library β ResearchIT{% endblock %}
|
| 4 |
+
|
| 5 |
+
{#
|
| 6 |
+
Both "Coming Soon" blocks are gone β Collections and Weekly Digest. They
|
| 7 |
+
occupied the most valuable space on the page to advertise things that do not
|
| 8 |
+
exist. The empty state now says what saving actually does instead.
|
| 9 |
+
#}
|
| 10 |
|
| 11 |
{% block content %}
|
| 12 |
+
<div class="wrap wrap--feed">
|
| 13 |
|
| 14 |
+
<div class="sec-head">
|
| 15 |
+
<h1 class="sec-title">Library</h1>
|
| 16 |
+
{% if count %}<span class="pill">{{ count }}</span>{% endif %}
|
|
|
|
| 17 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
{% if count > 0 %}
|
| 20 |
+
<div class="feed">
|
| 21 |
{% for paper in papers %}
|
| 22 |
{% set position = loop.index0 %}
|
| 23 |
{% set source = "saved" %}
|
| 24 |
{% include "partials/paper_card.html" %}
|
| 25 |
{% endfor %}
|
| 26 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
{% else %}
|
| 28 |
+
<div class="empty">
|
| 29 |
+
<p class="empty-title">Nothing saved yet</p>
|
| 30 |
+
<p class="empty-body">
|
| 31 |
+
Papers you save are what the feed learns from β every save sharpens
|
| 32 |
+
what shows up next. Five is roughly where it starts feeling personal.
|
|
|
|
| 33 |
</p>
|
| 34 |
+
<div style="padding-top:.4rem">
|
| 35 |
+
<a href="/search" class="btn btn-primary btn-sm">Find papers</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
</div>
|
| 38 |
{% endif %}
|
|
@@ -1,72 +1,40 @@
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
-
{% block title %}Search β ResearchIT{% endblock %}
|
| 4 |
|
| 5 |
-
{
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
<form id="search-form"
|
| 12 |
-
hx-get="/search"
|
| 13 |
-
hx-target="#search-results"
|
| 14 |
-
hx-push-url="true"
|
| 15 |
-
hx-trigger="submit"
|
| 16 |
-
class="flex gap-2 items-center">
|
| 17 |
-
<input type="text"
|
| 18 |
-
name="q"
|
| 19 |
-
value="{{ query }}"
|
| 20 |
-
placeholder="Search arXiv papersβ¦"
|
| 21 |
-
class="rit-search-input flex-1"
|
| 22 |
-
autofocus
|
| 23 |
-
onkeydown="if(event.key==='Enter'){event.preventDefault(); htmx.trigger(document.getElementById('search-form'), 'submit');}" />
|
| 24 |
-
<button class="btn btn-primary flex items-center gap-2" type="submit">
|
| 25 |
-
<span class="search-btn-text">
|
| 26 |
-
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 27 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
| 28 |
-
</svg>
|
| 29 |
-
Search
|
| 30 |
-
</span>
|
| 31 |
-
<span class="search-btn-loading hidden">
|
| 32 |
-
<span class="loading loading-spinner loading-sm"></span>
|
| 33 |
-
Searchingβ¦
|
| 34 |
-
</span>
|
| 35 |
-
</button>
|
| 36 |
-
</form>
|
| 37 |
-
<p class="text-xs text-base-content/40 mt-2 text-center">Search across 1.6M+ papers</p>
|
| 38 |
-
</div>
|
| 39 |
-
</div>
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
{% set count = 3 %}{% include "partials/skeleton.html" %}
|
| 44 |
-
<div id="search-progress-msgs" class="rit-progress-container mt-4"></div>
|
| 45 |
</div>
|
| 46 |
|
| 47 |
-
<!-- Recommendations β only when not actively searching -->
|
| 48 |
-
{% if has_recs and not query %}
|
| 49 |
-
<section id="rec-wrapper">
|
| 50 |
-
<h2 class="text-lg font-semibold mb-3">Recommended for You</h2>
|
| 51 |
-
<div id="rec-section"
|
| 52 |
-
hx-get="/api/recommendations"
|
| 53 |
-
hx-trigger="load"
|
| 54 |
-
hx-swap="innerHTML">
|
| 55 |
-
<div class="space-y-3">
|
| 56 |
-
{% set count = 2 %}{% include "partials/skeleton.html" %}
|
| 57 |
-
</div>
|
| 58 |
-
</div>
|
| 59 |
-
</section>
|
| 60 |
-
{% endif %}
|
| 61 |
-
|
| 62 |
-
<!-- Search results -->
|
| 63 |
<div id="search-results">
|
| 64 |
{% include "partials/search_results.html" %}
|
| 65 |
</div>
|
| 66 |
|
| 67 |
</div>
|
| 68 |
{% endblock %}
|
| 69 |
-
|
| 70 |
-
{% block scripts %}
|
| 71 |
-
<!-- Search page JS now handled by app.js global HTMX listeners -->
|
| 72 |
-
{% endblock %}
|
|
|
|
| 1 |
{% extends "base.html" %}
|
| 2 |
|
| 3 |
+
{% block title %}{% if query %}{{ query }} β Search{% else %}Search{% endif %} β ResearchIT{% endblock %}
|
| 4 |
|
| 5 |
+
{#
|
| 6 |
+
Search deliberately does NOT get the infinite feed. NN/g's guidance is that
|
| 7 |
+
infinite scroll suits "homogeneous items with no particular task or goal in
|
| 8 |
+
mind" and works against users who are trying to find something specific or
|
| 9 |
+
compare items β which is exactly what search is for. Results stay bounded.
|
| 10 |
+
#}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
{% block content %}
|
| 13 |
+
<div class="wrap wrap--feed stack stack-4">
|
| 14 |
+
|
| 15 |
+
<form class="searchbar"
|
| 16 |
+
hx-get="/search"
|
| 17 |
+
hx-target="#search-results"
|
| 18 |
+
hx-push-url="true"
|
| 19 |
+
hx-indicator="#search-loading">
|
| 20 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
|
| 21 |
+
stroke-linecap="round" aria-hidden="true">
|
| 22 |
+
<circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" />
|
| 23 |
+
</svg>
|
| 24 |
+
<input type="search" name="q" value="{{ query }}"
|
| 25 |
+
placeholder="Search 1.6M papersβ¦"
|
| 26 |
+
aria-label="Search papers"
|
| 27 |
+
{% if not query %}autofocus{% endif %} />
|
| 28 |
+
<button class="btn btn-primary btn-sm" type="submit">Search</button>
|
| 29 |
+
</form>
|
| 30 |
+
|
| 31 |
+
<div id="search-loading" class="htmx-indicator">
|
| 32 |
{% set count = 3 %}{% include "partials/skeleton.html" %}
|
|
|
|
| 33 |
</div>
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<div id="search-results">
|
| 36 |
{% include "partials/search_results.html" %}
|
| 37 |
</div>
|
| 38 |
|
| 39 |
</div>
|
| 40 |
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -3,6 +3,8 @@ Shared Jinja2 environment with custom filters.
|
|
| 3 |
Import `templates` from here instead of creating it per-router.
|
| 4 |
"""
|
| 5 |
import json
|
|
|
|
|
|
|
| 6 |
from fastapi.templating import Jinja2Templates
|
| 7 |
|
| 8 |
templates = Jinja2Templates(directory="app/templates")
|
|
@@ -19,4 +21,173 @@ def _tojson_parse(value: str | None) -> list:
|
|
| 19 |
return []
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
templates.env.filters["tojson_parse"] = _tojson_parse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Import `templates` from here instead of creating it per-router.
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
from fastapi.templating import Jinja2Templates
|
| 9 |
|
| 10 |
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
| 21 |
return []
|
| 22 |
|
| 23 |
|
| 24 |
+
# ββ Abstract lead-sentence split βββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
#
|
| 26 |
+
# The paper card emphasises the abstract's first sentence and dims the rest.
|
| 27 |
+
# Two pieces of evidence drive this:
|
| 28 |
+
#
|
| 29 |
+
# * Scholar Inbox (ACL 2025 Demo) highlights "the sentence most related to
|
| 30 |
+
# the idea of the research paper" specifically to enable skim-reading on
|
| 31 |
+
# mobile. We have no relevance model over sentences, but an abstract's
|
| 32 |
+
# opening sentence carries its claim almost always, so it is a cheap and
|
| 33 |
+
# honest approximation of the same affordance.
|
| 34 |
+
# * NN/g's F-pattern eyetracking finds attention decays sharply after the
|
| 35 |
+
# first couple of lines, so the highest-signal text has to live there
|
| 36 |
+
# rather than at character 400.
|
| 37 |
+
#
|
| 38 |
+
# Splitting on ". " naively breaks on the things abstracts are full of:
|
| 39 |
+
# decimals (0.95), initials (J. Smith), and abbreviations (e.g., et al.,
|
| 40 |
+
# Fig. 2). The guard below handles those rather than shipping a lead that
|
| 41 |
+
# reads "We evaluate on MS."
|
| 42 |
+
|
| 43 |
+
_ABBREV = {
|
| 44 |
+
"e.g", "i.e", "et al", "al", "cf", "vs", "resp", "approx", "ca",
|
| 45 |
+
"fig", "figs", "eq", "eqs", "ref", "refs", "sec", "secs", "tab", "tabs",
|
| 46 |
+
"no", "nos", "vol", "pp", "ch", "dr", "prof", "mr", "mrs", "ms", "st",
|
| 47 |
+
"inc", "ltd", "etc", "vs", "w.r.t", "s.t", "i.i.d",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# A sentence end: '.', '!' or '?' then whitespace then something that starts a
|
| 51 |
+
# new sentence (capital, digit, quote, or an opening bracket).
|
| 52 |
+
_SENT_END = re.compile(r'([.!?])\s+(?=[A-Z0-9"β(\[])')
|
| 53 |
+
|
| 54 |
+
_MIN_LEAD = 40 # below this the "sentence" is a fragment, not a claim
|
| 55 |
+
_MAX_LEAD = 340 # above this it stops being a scannable lead
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _is_false_stop(text: str, dot_index: int) -> bool:
|
| 59 |
+
"""True when the '.' at dot_index does not actually end a sentence."""
|
| 60 |
+
before = text[:dot_index]
|
| 61 |
+
|
| 62 |
+
# Decimal number: "0.95", "1.5x"
|
| 63 |
+
if before and before[-1].isdigit() and dot_index + 1 < len(text) \
|
| 64 |
+
and text[dot_index + 1: dot_index + 2].isdigit():
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
# Trailing token, lowercased, without its dots β "e.g", "et al", "fig"
|
| 68 |
+
token = re.split(r"[\s(\[]", before)[-1].rstrip(".").lower()
|
| 69 |
+
if token in _ABBREV:
|
| 70 |
+
return True
|
| 71 |
+
|
| 72 |
+
# Single-letter initial: "J. Smith", "A. Vaswani"
|
| 73 |
+
if len(token) == 1 and token.isalpha():
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _lead_split(abstract: str | None) -> tuple[str, str]:
|
| 80 |
+
"""
|
| 81 |
+
Split an abstract into (lead_sentence, remainder).
|
| 82 |
+
|
| 83 |
+
Returns ("", "") for empty input, and (whole, "") when no confident
|
| 84 |
+
sentence boundary is found β callers then render the whole abstract
|
| 85 |
+
unemphasised rather than guessing.
|
| 86 |
+
"""
|
| 87 |
+
text = (abstract or "").strip()
|
| 88 |
+
if not text:
|
| 89 |
+
return "", ""
|
| 90 |
+
|
| 91 |
+
for match in _SENT_END.finditer(text):
|
| 92 |
+
end = match.end(1) # index just past the '.'/'!'/'?'
|
| 93 |
+
if _is_false_stop(text, match.start(1)):
|
| 94 |
+
continue
|
| 95 |
+
if end < _MIN_LEAD:
|
| 96 |
+
continue
|
| 97 |
+
if end > _MAX_LEAD:
|
| 98 |
+
break
|
| 99 |
+
return text[:end].strip(), text[end:].strip()
|
| 100 |
+
|
| 101 |
+
# No usable boundary. If the abstract is short enough to read whole, treat
|
| 102 |
+
# it as all-lead; otherwise return it unsplit so nothing is emphasised.
|
| 103 |
+
if len(text) <= _MAX_LEAD:
|
| 104 |
+
return text, ""
|
| 105 |
+
return "", text
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _lead(abstract: str | None) -> str:
|
| 109 |
+
return _lead_split(abstract)[0]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _lead_rest(abstract: str | None) -> str:
|
| 113 |
+
return _lead_split(abstract)[1]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ββ arXiv category β chip class ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
#
|
| 118 |
+
# Maps a primary arXiv code onto one of the ten chip hues in styles.css.
|
| 119 |
+
# Lives here rather than in the template because the template was carrying a
|
| 120 |
+
# ten-branch if/elif chain that had to be duplicated wherever a card appeared.
|
| 121 |
+
# Green is deliberately unused β it is reserved for Save.
|
| 122 |
+
|
| 123 |
+
_CAT_RULES: list[tuple[tuple[str, ...], str]] = [
|
| 124 |
+
(("cs.CL", "cs.IR"), "cat-cl"),
|
| 125 |
+
(("cs.CV",), "cat-cv"),
|
| 126 |
+
(("cs.LG", "stat.ML", "stat."), "cat-lg"),
|
| 127 |
+
(("cs.AI", "cs.NE", "cs.MA"), "cat-ai"),
|
| 128 |
+
(("cs.RO", "cs.SY", "eess.SY"), "cat-ro"),
|
| 129 |
+
(("astro-ph",), "cat-astro"),
|
| 130 |
+
(("hep", "quant-ph", "physics", "cond-mat", "nucl", "gr-qc", "nlin"), "cat-phys"),
|
| 131 |
+
(("math", "cs.GT", "cs.DM", "cs.CC", "econ", "q-fin"), "cat-math"),
|
| 132 |
+
(("q-bio",), "cat-bio"),
|
| 133 |
+
(("eess", "cs.SD", "cs.SE", "cs.PL", "cs.CR", "cs.DC",
|
| 134 |
+
"cs.NI", "cs.HC", "cs.DB", "cs.OS"), "cat-eng"),
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _cat_class(category: str | None) -> str:
|
| 139 |
+
"""Return the chip class for an arXiv category code."""
|
| 140 |
+
code = (category or "").strip()
|
| 141 |
+
if not code:
|
| 142 |
+
return "cat"
|
| 143 |
+
for prefixes, cls in _CAT_RULES:
|
| 144 |
+
for p in prefixes:
|
| 145 |
+
if code.startswith(p):
|
| 146 |
+
return cls
|
| 147 |
+
return "cat"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ββ "Why am I seeing this?" ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
#
|
| 152 |
+
# The serving tier already records WHY a paper was retrieved, per candidate, in
|
| 153 |
+
# `candidate_source` β it just never reached the user, who instead saw a
|
| 154 |
+
# "Why this paper? π Coming Soon" badge on every card. This turns the existing
|
| 155 |
+
# value into a sentence.
|
| 156 |
+
#
|
| 157 |
+
# Deliberately vague about cluster identity: clusters are numbered, not named
|
| 158 |
+
# (labelling them is Phase 8), so claiming more than "one of your recurring
|
| 159 |
+
# interests" would be inventing detail the system does not have.
|
| 160 |
+
|
| 161 |
+
_WHY = {
|
| 162 |
+
"exploration": "Outside your usual areas β included to keep your feed from narrowing.",
|
| 163 |
+
"short_term_supplement": "Based on what you have been reading in this session.",
|
| 164 |
+
"ewma_longterm": "Close to the overall profile built from your saved papers.",
|
| 165 |
+
"qdrant_recommend": "Similar to papers already in your library.",
|
| 166 |
+
"trending_category_fallback": "Widely cited recently in the areas you picked during setup.",
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _why_shown(candidate_source: str | None) -> str:
|
| 171 |
+
"""Human-readable reason a paper appeared, or '' when there isn't one."""
|
| 172 |
+
src = (candidate_source or "").strip()
|
| 173 |
+
if not src:
|
| 174 |
+
return ""
|
| 175 |
+
if src.startswith("cluster_"):
|
| 176 |
+
return "Matches one of the recurring interests in your library."
|
| 177 |
+
return _WHY.get(src, "")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _commafy(value) -> str:
|
| 181 |
+
"""1204 -> '1,204'. Returns '' for anything non-numeric."""
|
| 182 |
+
try:
|
| 183 |
+
return f"{int(value):,}"
|
| 184 |
+
except (TypeError, ValueError):
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
|
| 188 |
templates.env.filters["tojson_parse"] = _tojson_parse
|
| 189 |
+
templates.env.filters["lead"] = _lead
|
| 190 |
+
templates.env.filters["lead_rest"] = _lead_rest
|
| 191 |
+
templates.env.filters["cat_class"] = _cat_class
|
| 192 |
+
templates.env.filters["why_shown"] = _why_shown
|
| 193 |
+
templates.env.filters["commafy"] = _commafy
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development / test-only dependencies.
|
| 2 |
+
#
|
| 3 |
+
# Deliberately NOT in requirements.txt: the Dockerfile installs that file, and
|
| 4 |
+
# `playwright install chromium` pulls ~95 MB of browser into the image for code
|
| 5 |
+
# that never runs in production.
|
| 6 |
+
#
|
| 7 |
+
# Setup:
|
| 8 |
+
# pip install -r requirements-dev.txt
|
| 9 |
+
# playwright install chromium
|
| 10 |
+
#
|
| 11 |
+
# The browser suite skips itself when playwright is absent or no server is
|
| 12 |
+
# reachable, so the unit suite still runs in an environment without either.
|
| 13 |
+
-r requirements.txt
|
| 14 |
+
|
| 15 |
+
playwright>=1.40
|
| 16 |
+
pytest-playwright>=0.4
|
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Browser end-to-end tests.
|
| 3 |
+
|
| 4 |
+
WHY THIS FILE EXISTS
|
| 5 |
+
--------------------
|
| 6 |
+
Two frontend bugs shipped to production while every server-side check passed:
|
| 7 |
+
HTTP 200s, correct rendered markup, correct database writes, full suite green.
|
| 8 |
+
Both were only observable in a real browser.
|
| 9 |
+
|
| 10 |
+
1. Save did nothing. hx-target="#actions-1706.03762" β a dot in a CSS id
|
| 11 |
+
selector starts a class token, so that parses as id="actions-1706" AND
|
| 12 |
+
class="03762", and a class token cannot begin with a digit, so
|
| 13 |
+
querySelector raises rather than merely missing. The POST fired and the
|
| 14 |
+
row was written; only the swap never happened.
|
| 15 |
+
|
| 16 |
+
2. Infinite scroll never fired. hx-trigger="revealed, click" β htmx's
|
| 17 |
+
revealed poller matches the attribute string exactly
|
| 18 |
+
("[hx-trigger='revealed']"), so a combined trigger can never be selected.
|
| 19 |
+
|
| 20 |
+
Neither is detectable without executing the page. These tests drive a real
|
| 21 |
+
Chromium, so they fail if a swap does not happen β which is the entire point.
|
| 22 |
+
|
| 23 |
+
RUNNING
|
| 24 |
+
-------
|
| 25 |
+
.venv/bin/python -m pytest tests/test_e2e_browser.py -v
|
| 26 |
+
|
| 27 |
+
Requires a live server; by default http://127.0.0.1:7860. Point at the
|
| 28 |
+
deployed Space with:
|
| 29 |
+
|
| 30 |
+
E2E_BASE_URL=https://siddhm11-researchit.hf.space .venv/bin/python -m pytest tests/test_e2e_browser.py -v
|
| 31 |
+
|
| 32 |
+
Skips itself (rather than failing) when playwright is absent or no server is
|
| 33 |
+
reachable, so it never blocks the unit suite in an environment without them.
|
| 34 |
+
"""
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
import uuid
|
| 39 |
+
|
| 40 |
+
import pytest
|
| 41 |
+
|
| 42 |
+
playwright_api = pytest.importorskip(
|
| 43 |
+
"playwright.sync_api", reason="playwright not installed"
|
| 44 |
+
)
|
| 45 |
+
sync_playwright = playwright_api.sync_playwright
|
| 46 |
+
|
| 47 |
+
BASE_URL = os.getenv("E2E_BASE_URL", "http://127.0.0.1:7860").rstrip("/")
|
| 48 |
+
|
| 49 |
+
# The feed pipeline is genuinely slow on a cold cache (Ward clustering, ANN over
|
| 50 |
+
# binary-quantised vectors, metadata fetch), so waits here are generous. These
|
| 51 |
+
# are correctness tests, not latency tests.
|
| 52 |
+
FEED_TIMEOUT_MS = 90_000
|
| 53 |
+
SWAP_TIMEOUT_MS = 30_000
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _server_up() -> bool:
|
| 57 |
+
import urllib.error
|
| 58 |
+
import urllib.request
|
| 59 |
+
try:
|
| 60 |
+
urllib.request.urlopen(f"{BASE_URL}/healthz/reranker", timeout=15)
|
| 61 |
+
return True
|
| 62 |
+
except (urllib.error.URLError, OSError):
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
pytestmark = pytest.mark.skipif(
|
| 67 |
+
not _server_up(), reason=f"no server reachable at {BASE_URL}"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture(scope="module")
|
| 72 |
+
def browser():
|
| 73 |
+
with sync_playwright() as p:
|
| 74 |
+
b = p.chromium.launch()
|
| 75 |
+
yield b
|
| 76 |
+
b.close()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.fixture
|
| 80 |
+
def page(browser):
|
| 81 |
+
# A fresh user per test: cookie identity drives the whole recommendation
|
| 82 |
+
# cascade, so sharing one would let tests leak state into each other.
|
| 83 |
+
ctx = browser.new_context(viewport={"width": 1280, "height": 900})
|
| 84 |
+
ctx.add_cookies([{
|
| 85 |
+
"name": "arxiv_user_id",
|
| 86 |
+
"value": f"e2e-{uuid.uuid4()}",
|
| 87 |
+
"url": BASE_URL,
|
| 88 |
+
}])
|
| 89 |
+
pg = ctx.new_page()
|
| 90 |
+
yield pg
|
| 91 |
+
ctx.close()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _onboard(page, categories=("nlp",)):
|
| 95 |
+
"""Complete onboarding fully, so `/` serves a feed instead of redirecting.
|
| 96 |
+
|
| 97 |
+
Onboarding is only 'complete' once /api/onboarding/complete has been
|
| 98 |
+
POSTed β selecting categories is not enough. Until then `/` answers 302
|
| 99 |
+
back to /onboarding, so any test that skips the final step waits forever
|
| 100 |
+
for a card that is never rendered.
|
| 101 |
+
"""
|
| 102 |
+
page.goto(f"{BASE_URL}/onboarding", wait_until="domcontentloaded")
|
| 103 |
+
page.wait_for_selector(".cat-opt", timeout=SWAP_TIMEOUT_MS)
|
| 104 |
+
for key in categories:
|
| 105 |
+
page.click(f'.cat-opt[data-key="{key}"]')
|
| 106 |
+
page.click("#continue-btn")
|
| 107 |
+
page.wait_for_selector("#step-2 .searchbar", timeout=SWAP_TIMEOUT_MS)
|
| 108 |
+
|
| 109 |
+
# Step 2's footer submits the form that marks onboarding done.
|
| 110 |
+
page.click(".ob-footer button[type=submit]")
|
| 111 |
+
page.wait_for_url(f"{BASE_URL}/", timeout=SWAP_TIMEOUT_MS)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _load_feed(page):
|
| 115 |
+
"""Open the feed and wait for it to resolve either way.
|
| 116 |
+
|
| 117 |
+
Skips β rather than hanging for the full timeout β when the feed comes back
|
| 118 |
+
empty. That is the normal outcome for a deployment with no metadata
|
| 119 |
+
sidecar: Tier 0 trending falls back to a LIKE scan over 1.6M Turso rows,
|
| 120 |
+
which exceeds its own client timeout and yields nothing, so no card will
|
| 121 |
+
ever appear and waiting the full 90s only slows the suite down.
|
| 122 |
+
"""
|
| 123 |
+
page.goto(BASE_URL, wait_until="domcontentloaded")
|
| 124 |
+
page.wait_for_selector("article.card, .empty", timeout=FEED_TIMEOUT_MS)
|
| 125 |
+
if page.locator("article.card").count() == 0:
|
| 126 |
+
pytest.skip("feed served no papers (no metadata sidecar on this target)")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ββ Bug 1: the Save swap βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
|
| 131 |
+
def test_save_button_swaps_to_saved_state(page):
|
| 132 |
+
"""Clicking Save must visibly become 'Saved'.
|
| 133 |
+
|
| 134 |
+
Regression test for the dot-in-id selector bug: the POST succeeded and the
|
| 135 |
+
interaction row was written even while this was broken, so only asserting
|
| 136 |
+
on the response would have passed.
|
| 137 |
+
"""
|
| 138 |
+
page.goto(f"{BASE_URL}/search?q=chain+of+thought+prompting",
|
| 139 |
+
wait_until="domcontentloaded")
|
| 140 |
+
card = page.locator("article.card").first
|
| 141 |
+
card.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 142 |
+
|
| 143 |
+
save = card.locator("button.btn-save")
|
| 144 |
+
assert save.inner_text().strip().startswith("Save"), "expected an unsaved card"
|
| 145 |
+
|
| 146 |
+
save.click()
|
| 147 |
+
|
| 148 |
+
# The swap replaces the actions row; wait for the new state to appear.
|
| 149 |
+
saved = card.locator("button.btn-save[disabled]")
|
| 150 |
+
saved.wait_for(timeout=SWAP_TIMEOUT_MS)
|
| 151 |
+
assert "Saved" in saved.inner_text()
|
| 152 |
+
assert card.locator("button.btn-pass").inner_text().strip() == "Remove"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_save_target_selectors_are_resolvable(page):
|
| 156 |
+
"""Every hx-target on the page must actually resolve in the DOM.
|
| 157 |
+
|
| 158 |
+
This is the generalised form of bug 1 β it catches ANY id whose characters
|
| 159 |
+
break a CSS selector, not just the dot case, and it does so without having
|
| 160 |
+
to click each control.
|
| 161 |
+
"""
|
| 162 |
+
page.goto(f"{BASE_URL}/search?q=transformer", wait_until="domcontentloaded")
|
| 163 |
+
page.locator("article.card").first.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 164 |
+
|
| 165 |
+
bad = page.evaluate(
|
| 166 |
+
"""() => {
|
| 167 |
+
const out = [];
|
| 168 |
+
document.querySelectorAll('[hx-target]').forEach(el => {
|
| 169 |
+
const sel = el.getAttribute('hx-target');
|
| 170 |
+
// htmx's extended syntax is not a plain CSS selector
|
| 171 |
+
if (/^(this|closest |find |next|previous)/.test(sel)) return;
|
| 172 |
+
try {
|
| 173 |
+
if (document.querySelectorAll(sel).length !== 1) {
|
| 174 |
+
out.push([sel, 'matched ' + document.querySelectorAll(sel).length]);
|
| 175 |
+
}
|
| 176 |
+
} catch (e) {
|
| 177 |
+
out.push([sel, 'SyntaxError: ' + e.message]);
|
| 178 |
+
}
|
| 179 |
+
});
|
| 180 |
+
return out;
|
| 181 |
+
}"""
|
| 182 |
+
)
|
| 183 |
+
assert bad == [], f"unresolvable hx-target selectors: {bad}"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_saved_paper_appears_in_library(page):
|
| 187 |
+
"""Save must persist: the paper shows up in the Library on a fresh load."""
|
| 188 |
+
page.goto(f"{BASE_URL}/search?q=chain+of+thought+prompting",
|
| 189 |
+
wait_until="domcontentloaded")
|
| 190 |
+
card = page.locator("article.card").first
|
| 191 |
+
card.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 192 |
+
arxiv_id = card.get_attribute("data-arxiv-id")
|
| 193 |
+
|
| 194 |
+
card.locator("button.btn-save").click()
|
| 195 |
+
card.locator("button.btn-save[disabled]").wait_for(timeout=SWAP_TIMEOUT_MS)
|
| 196 |
+
|
| 197 |
+
page.goto(f"{BASE_URL}/saved", wait_until="domcontentloaded")
|
| 198 |
+
page.locator("article.card").first.wait_for(timeout=SWAP_TIMEOUT_MS)
|
| 199 |
+
assert page.locator(f'article.card[data-arxiv-id="{arxiv_id}"]').count() == 1
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ββ Bug 2: infinite scroll βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
+
|
| 204 |
+
def test_scrolling_appends_more_papers(page):
|
| 205 |
+
"""Scrolling to the bottom must load another page WITHOUT any click.
|
| 206 |
+
|
| 207 |
+
Regression test for the htmx trigger bug. The loader element rendered
|
| 208 |
+
correctly and its endpoint returned 200 the whole time it was broken, so
|
| 209 |
+
markup and HTTP assertions both passed.
|
| 210 |
+
"""
|
| 211 |
+
_onboard(page)
|
| 212 |
+
_load_feed(page)
|
| 213 |
+
|
| 214 |
+
before = page.locator("article.card").count()
|
| 215 |
+
assert before > 0
|
| 216 |
+
|
| 217 |
+
if page.locator(".feed-more").count() == 0:
|
| 218 |
+
pytest.skip("feed had only one page for this user")
|
| 219 |
+
|
| 220 |
+
page.mouse.wheel(0, 40_000)
|
| 221 |
+
# The observer uses an 800px rootMargin, so this should fire before the
|
| 222 |
+
# loader is even fully on screen.
|
| 223 |
+
page.wait_for_function(
|
| 224 |
+
f"document.querySelectorAll('article.card').length > {before}",
|
| 225 |
+
timeout=SWAP_TIMEOUT_MS,
|
| 226 |
+
)
|
| 227 |
+
after = page.locator("article.card").count()
|
| 228 |
+
assert after > before, f"scroll appended nothing ({before} -> {after})"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_feed_has_no_duplicate_papers_across_pages(page):
|
| 232 |
+
"""Paging must not repeat a paper the user has already been shown."""
|
| 233 |
+
_onboard(page)
|
| 234 |
+
_load_feed(page)
|
| 235 |
+
|
| 236 |
+
for _ in range(3):
|
| 237 |
+
if page.locator(".feed-more").count() == 0:
|
| 238 |
+
break
|
| 239 |
+
n = page.locator("article.card").count()
|
| 240 |
+
page.mouse.wheel(0, 40_000)
|
| 241 |
+
try:
|
| 242 |
+
page.wait_for_function(
|
| 243 |
+
f"document.querySelectorAll('article.card').length > {n}",
|
| 244 |
+
timeout=SWAP_TIMEOUT_MS,
|
| 245 |
+
)
|
| 246 |
+
except Exception:
|
| 247 |
+
break
|
| 248 |
+
|
| 249 |
+
ids = page.eval_on_selector_all(
|
| 250 |
+
"article.card", "els => els.map(e => e.dataset.arxivId)"
|
| 251 |
+
)
|
| 252 |
+
assert len(ids) == len(set(ids)), f"duplicate papers in feed: {ids}"
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ββ Dismiss + undo βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
+
|
| 257 |
+
def test_dismiss_shows_undo_and_undo_cancels_the_request(page):
|
| 258 |
+
"""Undo must CANCEL the dismissal, not compensate for it.
|
| 259 |
+
|
| 260 |
+
This matters beyond UX: a committed dismissal is folded into the user's
|
| 261 |
+
negative EWMA profile, and an EWMA is a lossy running average with no exact
|
| 262 |
+
inverse β so an undone dismissal must never have been sent at all.
|
| 263 |
+
"""
|
| 264 |
+
page.goto(f"{BASE_URL}/search?q=transformer", wait_until="domcontentloaded")
|
| 265 |
+
page.locator("article.card").first.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 266 |
+
|
| 267 |
+
posts: list[str] = []
|
| 268 |
+
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
|
| 269 |
+
|
| 270 |
+
card = page.locator("article.card").first
|
| 271 |
+
arxiv_id = card.get_attribute("data-arxiv-id")
|
| 272 |
+
card.locator("button.btn-pass").click()
|
| 273 |
+
|
| 274 |
+
# .first throughout: an error toast can coexist with the undo toast (a slow
|
| 275 |
+
# backend makes that routine), and a bare .toast locator then raises a
|
| 276 |
+
# strict-mode violation instead of testing anything.
|
| 277 |
+
undo = page.locator(".toast .undo").first
|
| 278 |
+
undo.wait_for(timeout=10_000)
|
| 279 |
+
assert "Removed" in page.locator(".toast .msg").first.inner_text()
|
| 280 |
+
undo.click()
|
| 281 |
+
|
| 282 |
+
# Wait past the commit window to prove the POST was truly cancelled.
|
| 283 |
+
page.wait_for_timeout(6_500)
|
| 284 |
+
assert page.locator(f'article.card[data-arxiv-id="{arxiv_id}"]').count() == 1, \
|
| 285 |
+
"undo did not restore the card"
|
| 286 |
+
assert not any("not-interested" in u for u in posts), \
|
| 287 |
+
f"undo failed to cancel the dismissal POST: {posts}"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def test_dismiss_commits_when_not_undone(page):
|
| 291 |
+
"""Left alone, a dismissal must actually be sent after the undo window."""
|
| 292 |
+
page.goto(f"{BASE_URL}/search?q=transformer", wait_until="domcontentloaded")
|
| 293 |
+
page.locator("article.card").first.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 294 |
+
|
| 295 |
+
posts: list[str] = []
|
| 296 |
+
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
|
| 297 |
+
|
| 298 |
+
page.locator("article.card").first.locator("button.btn-pass").click()
|
| 299 |
+
page.wait_for_timeout(7_000)
|
| 300 |
+
assert any("not-interested" in u for u in posts), \
|
| 301 |
+
f"dismissal was never committed: {posts}"
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ββ Theme, layout, console health ββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
+
|
| 306 |
+
def test_theme_toggle_switches_and_persists(page):
|
| 307 |
+
"""Toggling theme must stamp the root element and survive a reload."""
|
| 308 |
+
# /search rather than / β a brand-new user is redirected off the feed into
|
| 309 |
+
# onboarding, which has no topbar.
|
| 310 |
+
page.goto(f"{BASE_URL}/search", wait_until="domcontentloaded")
|
| 311 |
+
page.wait_for_selector(".topbar", timeout=SWAP_TIMEOUT_MS)
|
| 312 |
+
|
| 313 |
+
page.click(".theme-toggle")
|
| 314 |
+
stamped = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
| 315 |
+
assert stamped in ("light", "dark"), f"toggle did not stamp a theme: {stamped}"
|
| 316 |
+
|
| 317 |
+
page.reload(wait_until="domcontentloaded")
|
| 318 |
+
assert page.evaluate(
|
| 319 |
+
"document.documentElement.getAttribute('data-theme')") == stamped, \
|
| 320 |
+
"theme did not persist across reload"
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_text_is_legible_in_both_themes(page):
|
| 324 |
+
"""Body text must never resolve to the same colour as its background.
|
| 325 |
+
|
| 326 |
+
Catches the classic broken-artifact bug where a colour token is defined only
|
| 327 |
+
inside a media query, so one theme renders its text on the other's ground.
|
| 328 |
+
"""
|
| 329 |
+
page.goto(f"{BASE_URL}/search?q=transformer", wait_until="domcontentloaded")
|
| 330 |
+
page.locator("article.card").first.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 331 |
+
|
| 332 |
+
for theme in ("light", "dark"):
|
| 333 |
+
page.evaluate(f"document.documentElement.setAttribute('data-theme','{theme}')")
|
| 334 |
+
colours = page.evaluate(
|
| 335 |
+
"""() => {
|
| 336 |
+
const t = document.querySelector('.card-title');
|
| 337 |
+
const c = document.querySelector('article.card');
|
| 338 |
+
const s = getComputedStyle(t), cs = getComputedStyle(c);
|
| 339 |
+
return {text: s.color, cardBg: cs.backgroundColor,
|
| 340 |
+
bodyBg: getComputedStyle(document.body).backgroundColor};
|
| 341 |
+
}"""
|
| 342 |
+
)
|
| 343 |
+
assert colours["text"] != colours["cardBg"], \
|
| 344 |
+
f"{theme}: title colour equals card background ({colours})"
|
| 345 |
+
assert colours["bodyBg"] not in ("rgba(0, 0, 0, 0)", "transparent"), \
|
| 346 |
+
f"{theme}: body background is transparent ({colours})"
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_no_console_errors_on_feed(page):
|
| 350 |
+
"""A clean console. Both shipped bugs were silent, but this is cheap."""
|
| 351 |
+
errors: list[str] = []
|
| 352 |
+
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
| 353 |
+
page.on("pageerror", lambda e: errors.append(str(e)))
|
| 354 |
+
|
| 355 |
+
_onboard(page)
|
| 356 |
+
_load_feed(page)
|
| 357 |
+
page.mouse.wheel(0, 40_000)
|
| 358 |
+
page.wait_for_timeout(4_000)
|
| 359 |
+
|
| 360 |
+
# Ignore network noise from third-party/offline resources; we care about JS.
|
| 361 |
+
real = [e for e in errors if "net::" not in e and "favicon" not in e]
|
| 362 |
+
assert real == [], f"console errors: {real}"
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def test_no_horizontal_overflow_on_mobile(page, browser):
|
| 366 |
+
"""The page body must never scroll sideways on a phone viewport."""
|
| 367 |
+
ctx = browser.new_context(viewport={"width": 390, "height": 844},
|
| 368 |
+
is_mobile=True, has_touch=True)
|
| 369 |
+
ctx.add_cookies([{"name": "arxiv_user_id",
|
| 370 |
+
"value": f"e2e-{uuid.uuid4()}", "url": BASE_URL}])
|
| 371 |
+
p = ctx.new_page()
|
| 372 |
+
try:
|
| 373 |
+
p.goto(f"{BASE_URL}/search?q=transformer", wait_until="domcontentloaded")
|
| 374 |
+
p.locator("article.card").first.wait_for(timeout=FEED_TIMEOUT_MS)
|
| 375 |
+
overflow = p.evaluate(
|
| 376 |
+
"() => document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
| 377 |
+
assert overflow <= 1, f"page scrolls horizontally by {overflow}px"
|
| 378 |
+
|
| 379 |
+
# Bottom nav must not cover the last card's controls.
|
| 380 |
+
assert p.locator(".botnav").is_visible()
|
| 381 |
+
finally:
|
| 382 |
+
ctx.close()
|
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Static guard against htmx selector bugs that only manifest in a browser.
|
| 3 |
+
|
| 4 |
+
Background
|
| 5 |
+
----------
|
| 6 |
+
htmx passes hx-target / hx-select / hx-include values straight to
|
| 7 |
+
document.querySelectorAll with NO escaping. An arXiv id contains a dot, and in
|
| 8 |
+
a CSS selector a dot starts a class token, so
|
| 9 |
+
|
| 10 |
+
#actions-1706.03762 parses as id="actions-1706" AND class="03762"
|
| 11 |
+
|
| 12 |
+
and because a class token may not begin with a digit, querySelector *raises*
|
| 13 |
+
rather than merely failing to match. The result is a control that silently does
|
| 14 |
+
nothing while the server still returns 200 and writes its row.
|
| 15 |
+
|
| 16 |
+
This is a known, unfixed defect in the 1.x line β htmx issues #1537 and #2601.
|
| 17 |
+
Maintainer position is that escaping is the application's responsibility;
|
| 18 |
+
CSS.escape was rejected for 1.x over IE11 compatibility and only landed in
|
| 19 |
+
2.0.5+. This project is pinned to htmx 1.9.12, so the fix will never arrive
|
| 20 |
+
upstream and these rules have to be enforced here.
|
| 21 |
+
|
| 22 |
+
The sanctioned workaround is the attribute form, [id='β¦'], which matches any id
|
| 23 |
+
regardless of its characters.
|
| 24 |
+
|
| 25 |
+
These are plain string checks over the templates: no server, no browser, fast
|
| 26 |
+
enough to run on every commit. tests/test_e2e_browser.py covers the same class
|
| 27 |
+
dynamically, but needs a live server and a real Chromium.
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import re
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "templates"
|
| 37 |
+
|
| 38 |
+
# htmx attributes whose value is fed to querySelectorAll.
|
| 39 |
+
SELECTOR_ATTRS = (
|
| 40 |
+
"hx-target", "hx-select", "hx-select-oob",
|
| 41 |
+
"hx-include", "hx-indicator", "hx-disabled-elt",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# htmx's extended, non-CSS selector syntax β exempt from the id rules.
|
| 45 |
+
_EXTENDED = re.compile(r"^\s*(this\b|closest\s|find\s|next\b|previous\b|body\b|window\b|document\b)")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
_JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.S)
|
| 49 |
+
_HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _template_files() -> list[Path]:
|
| 53 |
+
return sorted(TEMPLATES.rglob("*.html"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _live_markup(path: Path) -> str:
|
| 57 |
+
"""Template text with comments stripped.
|
| 58 |
+
|
| 59 |
+
These rules describe attributes the browser actually sees. Comments in
|
| 60 |
+
this codebase deliberately quote the broken forms to explain why they are
|
| 61 |
+
banned, so scanning raw text flags its own documentation.
|
| 62 |
+
"""
|
| 63 |
+
text = path.read_text()
|
| 64 |
+
text = _JINJA_COMMENT.sub("", text)
|
| 65 |
+
return _HTML_COMMENT.sub("", text)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _selector_attrs(text: str) -> list[tuple[str, str]]:
|
| 69 |
+
pattern = re.compile(
|
| 70 |
+
r'(' + "|".join(SELECTOR_ATTRS) + r')\s*=\s*"([^"]*)"'
|
| 71 |
+
)
|
| 72 |
+
return pattern.findall(text)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_no_id_selector_interpolates_a_template_variable():
|
| 76 |
+
"""`hx-target="#thing-{{ var }}"` is banned outright.
|
| 77 |
+
|
| 78 |
+
Any Jinja value can contain a dot, colon or leading digit; each of those
|
| 79 |
+
breaks or throws inside a `#id` selector. Use `[id='thing-{{ var }}']`.
|
| 80 |
+
"""
|
| 81 |
+
offenders: list[str] = []
|
| 82 |
+
for path in _template_files():
|
| 83 |
+
for attr, value in _selector_attrs(_live_markup(path)):
|
| 84 |
+
if _EXTENDED.match(value):
|
| 85 |
+
continue
|
| 86 |
+
if value.lstrip().startswith("#") and "{{" in value:
|
| 87 |
+
offenders.append(f"{path.name}: {attr}=\"{value}\"")
|
| 88 |
+
|
| 89 |
+
assert offenders == [], (
|
| 90 |
+
"id selectors built from template variables must use the attribute "
|
| 91 |
+
"form [id='β¦'] β htmx 1.x does not escape them (issues #1537, #2601):\n "
|
| 92 |
+
+ "\n ".join(offenders)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_static_id_selectors_are_valid_css():
|
| 97 |
+
"""Hard-coded `#foo` targets must be selectors a browser accepts."""
|
| 98 |
+
bad: list[str] = []
|
| 99 |
+
valid = re.compile(r"^#[A-Za-z_-][A-Za-z0-9_-]*$")
|
| 100 |
+
for path in _template_files():
|
| 101 |
+
for attr, value in _selector_attrs(_live_markup(path)):
|
| 102 |
+
v = value.strip()
|
| 103 |
+
if _EXTENDED.match(v) or "{{" in v or not v.startswith("#"):
|
| 104 |
+
continue
|
| 105 |
+
if not valid.match(v):
|
| 106 |
+
bad.append(f"{path.name}: {attr}=\"{v}\"")
|
| 107 |
+
|
| 108 |
+
assert bad == [], "invalid static id selectors:\n " + "\n ".join(bad)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_attribute_form_targets_are_quoted():
|
| 112 |
+
"""`[id=β¦]` must quote its value, or a dot/digit still breaks parsing."""
|
| 113 |
+
bad: list[str] = []
|
| 114 |
+
for path in _template_files():
|
| 115 |
+
for attr, value in _selector_attrs(_live_markup(path)):
|
| 116 |
+
v = value.strip()
|
| 117 |
+
if not v.startswith("[id="):
|
| 118 |
+
continue
|
| 119 |
+
if not re.match(r"^\[id=['\"].*['\"]\]$", v):
|
| 120 |
+
bad.append(f"{path.name}: {attr}=\"{v}\"")
|
| 121 |
+
|
| 122 |
+
assert bad == [], "unquoted attribute selectors:\n " + "\n ".join(bad)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_revealed_trigger_is_never_combined_with_another_event():
|
| 126 |
+
"""`hx-trigger="revealed, β¦"` can never fire.
|
| 127 |
+
|
| 128 |
+
htmx's reveal poller collects elements with an exact attribute match:
|
| 129 |
+
|
| 130 |
+
querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']")
|
| 131 |
+
|
| 132 |
+
so any combined spec is invisible to it. htmx's own infinite-scroll recipe
|
| 133 |
+
uses a bare `revealed` and never combines it. This project drives scrolling
|
| 134 |
+
from its own IntersectionObserver in app.js instead β see rec_page.html.
|
| 135 |
+
"""
|
| 136 |
+
offenders: list[str] = []
|
| 137 |
+
for path in _template_files():
|
| 138 |
+
for m in re.finditer(r'hx-trigger\s*=\s*"([^"]*)"', _live_markup(path)):
|
| 139 |
+
spec = m.group(1)
|
| 140 |
+
if "revealed" in spec and "," in spec:
|
| 141 |
+
offenders.append(f"{path.name}: hx-trigger=\"{spec}\"")
|
| 142 |
+
|
| 143 |
+
assert offenders == [], (
|
| 144 |
+
"`revealed` cannot be combined with another trigger:\n "
|
| 145 |
+
+ "\n ".join(offenders)
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_every_hx_target_id_has_a_matching_element_somewhere():
|
| 150 |
+
"""Catch targets pointing at ids no template ever renders.
|
| 151 |
+
|
| 152 |
+
Only checks static ids β variable ones are resolved at request time and are
|
| 153 |
+
covered dynamically by the browser suite.
|
| 154 |
+
"""
|
| 155 |
+
all_text = "\n".join(_live_markup(p) for p in _template_files())
|
| 156 |
+
rendered_ids = set(re.findall(r'id="([A-Za-z_-][A-Za-z0-9_-]*)"', all_text))
|
| 157 |
+
|
| 158 |
+
missing: list[str] = []
|
| 159 |
+
for path in _template_files():
|
| 160 |
+
for attr, value in _selector_attrs(_live_markup(path)):
|
| 161 |
+
v = value.strip()
|
| 162 |
+
if _EXTENDED.match(v) or "{{" in v or not v.startswith("#"):
|
| 163 |
+
continue
|
| 164 |
+
if v[1:] not in rendered_ids:
|
| 165 |
+
missing.append(f"{path.name}: {attr}=\"{v}\"")
|
| 166 |
+
|
| 167 |
+
assert missing == [], (
|
| 168 |
+
"hx-target points at an id no template renders:\n " + "\n ".join(missing)
|
| 169 |
+
)
|